sanity-plugin-mux-input 2.14.0 → 2.16.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 (55) hide show
  1. package/README.md +25 -24
  2. package/dist/index.d.mts +13 -1
  3. package/dist/index.d.ts +13 -1
  4. package/dist/index.js +1057 -470
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1059 -472
  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/secrets.ts +6 -1
  11. package/src/actions/upload.ts +1 -1
  12. package/src/components/ConfigureApi.tsx +51 -5
  13. package/src/components/EditCaptionDialog.tsx +2 -2
  14. package/src/components/InputBrowser.tsx +8 -2
  15. package/src/components/PageSelector.tsx +4 -7
  16. package/src/components/Player.styled.tsx +7 -2
  17. package/src/components/PlayerActionsMenu.tsx +15 -1
  18. package/src/components/ResyncMetadata.tsx +152 -73
  19. package/src/components/SelectAsset.tsx +9 -3
  20. package/src/components/StudioTool.tsx +2 -2
  21. package/src/components/TextTracksManager.tsx +11 -55
  22. package/src/components/UploadConfiguration.tsx +104 -343
  23. package/src/components/Uploader.tsx +18 -7
  24. package/src/components/VideoDetails/VideoDetails.tsx +55 -19
  25. package/src/components/VideoDetails/useVideoDetails.ts +15 -1
  26. package/src/components/VideoInBrowser.tsx +53 -6
  27. package/src/components/VideoPlayer.tsx +120 -47
  28. package/src/components/VideoThumbnail.tsx +84 -72
  29. package/src/components/VideosBrowser.tsx +7 -5
  30. package/src/components/uploadConfiguration/PlaybackPolicy.tsx +95 -6
  31. package/src/components/uploadConfiguration/PlaybackPolicyOption.tsx +26 -10
  32. package/src/components/uploadConfiguration/ResolutionTierSelector.tsx +71 -0
  33. package/src/components/uploadConfiguration/StaticRenditionSelector.tsx +179 -0
  34. package/src/context/DrmPlaybackWarningContext.tsx +93 -0
  35. package/src/hooks/useFetchFileSize.ts +54 -0
  36. package/src/hooks/useMediaMetadata.ts +100 -0
  37. package/src/hooks/useResyncAsset.ts +110 -0
  38. package/src/hooks/useResyncMuxMetadata.ts +33 -0
  39. package/src/hooks/useSaveSecrets.ts +10 -3
  40. package/src/hooks/useSecretsDocumentValues.ts +9 -1
  41. package/src/hooks/useSecretsFormState.ts +6 -3
  42. package/src/schema.ts +5 -0
  43. package/src/util/addKeysToMuxData.ts +30 -0
  44. package/src/util/asserters.ts +14 -0
  45. package/src/util/createUrlParamsObject.ts +7 -3
  46. package/src/util/generateJwt.ts +11 -2
  47. package/src/util/getPlaybackPolicy.ts +63 -4
  48. package/src/util/getStoryboardSrc.ts +7 -3
  49. package/src/util/getVideoMetadata.ts +1 -0
  50. package/src/util/getVideoSrc.ts +9 -9
  51. package/src/util/readSecrets.ts +3 -1
  52. package/src/util/textTracks.ts +6 -3
  53. package/src/util/tryWithSuspend.ts +22 -0
  54. package/src/util/types.ts +27 -2
  55. package/src/util/getPlaybackId.ts +0 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanity-plugin-mux-input",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "An input component that integrates Sanity Studio with Mux video encoding/hosting service.",
5
5
  "keywords": [
6
6
  "sanity",
@@ -14,6 +14,7 @@ export const defaultConfig: PluginConfig = {
14
14
  normalize_audio: false,
15
15
  defaultPublic: true,
16
16
  defaultSigned: false,
17
+ defaultDrm: false,
17
18
  tool: DEFAULT_TOOL_CONFIG,
18
19
  allowedRolesForConfiguration: [],
19
20
  acceptedMimeTypes: ['video/*', 'audio/*'],
@@ -9,6 +9,7 @@ interface SecretsDocument {
9
9
  enableSignedUrls: boolean
10
10
  signingKeyId: string
11
11
  signingKeyPrivate: string
12
+ drmConfigId: string
12
13
  }
13
14
  // eslint-disable-next-line max-params
14
15
  export function saveSecrets(
@@ -17,7 +18,8 @@ export function saveSecrets(
17
18
  secretKey: string,
18
19
  enableSignedUrls: boolean,
19
20
  signingKeyId: string,
20
- signingKeyPrivate: string
21
+ signingKeyPrivate: string,
22
+ drmConfigId: string
21
23
  ): Promise<SecretsDocument> {
22
24
  const doc: SecretsDocument = {
23
25
  _id: 'secrets.mux',
@@ -27,7 +29,10 @@ export function saveSecrets(
27
29
  enableSignedUrls,
28
30
  signingKeyId,
29
31
  signingKeyPrivate,
32
+ drmConfigId,
30
33
  }
34
+ doc.signingKeyId = enableSignedUrls ? signingKeyId : ''
35
+ doc.signingKeyPrivate = enableSignedUrls ? signingKeyPrivate : ''
31
36
 
32
37
  return client.createOrReplace(doc)
33
38
  }
@@ -158,7 +158,7 @@ type UploadResponse = {
158
158
  new_asset_settings: {
159
159
  static_renditions?: {resolution: string}[]
160
160
  passthrough: string
161
- playback_policies: ['public' | 'signed']
161
+ playback_policies: ['public' | 'signed' | 'drm']
162
162
  }
163
163
  status: string
164
164
  timeout: number
@@ -32,7 +32,7 @@ export interface ConfigureApiDialogProps {
32
32
  secrets: Secrets
33
33
  }
34
34
 
35
- const fieldNames = ['token', 'secretKey', 'enableSignedUrls'] as const
35
+ const fieldNames = ['token', 'secretKey', 'enableSignedUrls', 'drmConfigId'] as const
36
36
 
37
37
  // Internal dialog component that can be used with external state
38
38
  export function ConfigureApiDialog({secrets, setDialogState}: ConfigureApiDialogProps) {
@@ -44,11 +44,12 @@ export function ConfigureApiDialog({secrets, setDialogState}: ConfigureApiDialog
44
44
  () =>
45
45
  secrets.token !== state.token ||
46
46
  secrets.secretKey !== state.secretKey ||
47
- secrets.enableSignedUrls !== state.enableSignedUrls,
47
+ secrets.enableSignedUrls !== state.enableSignedUrls ||
48
+ secrets.drmConfigId !== state.drmConfigId,
48
49
  [secrets, state]
49
50
  )
50
51
  const id = `ConfigureApi${useId()}`
51
- const [tokenId, secretKeyId, enableSignedUrlsId] = useMemo<typeof fieldNames>(
52
+ const [tokenId, secretKeyId, enableSignedUrlsId, drmConfigIdId] = useMemo<typeof fieldNames>(
52
53
  () => fieldNames.map((field) => `${id}-${field}`) as unknown as typeof fieldNames,
53
54
  [id]
54
55
  )
@@ -63,8 +64,8 @@ export function ConfigureApiDialog({secrets, setDialogState}: ConfigureApiDialog
63
64
  if (!saving.current && event.currentTarget.reportValidity()) {
64
65
  saving.current = true
65
66
  dispatch({type: 'submit'})
66
- const {token, secretKey, enableSignedUrls} = state
67
- handleSaveSecrets({token, secretKey, enableSignedUrls})
67
+ const {token, secretKey, enableSignedUrls, drmConfigId} = state
68
+ handleSaveSecrets({token, secretKey, enableSignedUrls, drmConfigId})
68
69
  .then((savedSecrets) => {
69
70
  const {projectId, dataset} = client.config()
70
71
  clear([cacheNs, secretsId, projectId, dataset])
@@ -106,6 +107,15 @@ export function ConfigureApiDialog({secrets, setDialogState}: ConfigureApiDialog
106
107
  },
107
108
  [dispatch]
108
109
  )
110
+ const handleChangeDrmConfigId = useCallback(
111
+ (event: React.FormEvent<HTMLInputElement>) => {
112
+ dispatch({
113
+ type: 'change',
114
+ payload: {name: 'drmConfigId', value: event.currentTarget.value},
115
+ })
116
+ },
117
+ [dispatch]
118
+ )
109
119
 
110
120
  useEffect(() => {
111
121
  if (firstField.current) {
@@ -202,6 +212,42 @@ export function ConfigureApiDialog({secrets, setDialogState}: ConfigureApiDialog
202
212
  ) : null}
203
213
  </Stack>
204
214
 
215
+ <FormField title="DRM Configuration ID" inputId={drmConfigIdId}>
216
+ <TextInput
217
+ id={drmConfigIdId}
218
+ onChange={handleChangeDrmConfigId}
219
+ type="text"
220
+ value={state.drmConfigId ?? ''}
221
+ required={false}
222
+ />
223
+ </FormField>
224
+ <Card padding={[3, 3, 3]} radius={2} shadow={1} tone="neutral">
225
+ <Stack space={3}>
226
+ <Text size={1}>
227
+ DRM (Digital Rights Management) provides an extra layer of content security for
228
+ video content streamed from Mux. For additional information check out our{' '}
229
+ <a
230
+ href="https://www.mux.com/docs/guides/protect-videos-with-drm#play-drm-protected-videos"
231
+ target="_blank"
232
+ rel="noopener noreferrer"
233
+ >
234
+ DRM Guide
235
+ </a>
236
+ .
237
+ </Text>
238
+ <Text size={1}>
239
+ <a
240
+ href="https://www.mux.com/support/human"
241
+ target="_blank"
242
+ rel="noopener noreferrer"
243
+ >
244
+ Contact us
245
+ </a>{' '}
246
+ to get started using DRM.
247
+ </Text>
248
+ </Stack>
249
+ </Card>
250
+
205
251
  <Inline space={2}>
206
252
  <Button
207
253
  text="Save"
@@ -18,7 +18,7 @@ import {useEffect, useId, useRef, useState} from 'react'
18
18
  import {addTextTrackFromUrl, deleteTextTrack, getAsset} from '../actions/assets'
19
19
  import {useClient} from '../hooks/useClient'
20
20
  import {generateJwt} from '../util/generateJwt'
21
- import {getPlaybackId} from '../util/getPlaybackId'
21
+ import {getPlaybackId} from '../util/getPlaybackPolicy'
22
22
  import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
23
23
  import {downloadVttFile, extractErrorMessage, pollTrackStatus} from '../util/textTracks'
24
24
  import type {MuxTextTrack, VideoAssetDocument} from '../util/types'
@@ -276,7 +276,7 @@ export default function EditCaptionDialog({asset, track, onUpdate, onClose}: Pro
276
276
  const playbackId = getPlaybackId(asset)
277
277
  if (!playbackId) return ''
278
278
  let url = `https://stream.mux.com/${playbackId}/text/${track.id}.vtt`
279
- if (getPlaybackPolicy(asset) === 'signed') {
279
+ if (getPlaybackPolicy(asset)?.policy === 'signed') {
280
280
  const token = generateJwt(client, playbackId, 'v')
281
281
  url += `?token=${token}`
282
282
  }
@@ -16,7 +16,8 @@ export default function InputBrowser({
16
16
  setDialogState,
17
17
  asset,
18
18
  onChange,
19
- }: Pick<SelectAssetProps, 'onChange' | 'asset'> & {
19
+ config,
20
+ }: Pick<SelectAssetProps, 'onChange' | 'asset' | 'config'> & {
20
21
  setDialogState: SetDialogState
21
22
  }) {
22
23
  const id = `InputBrowser${useId()}`
@@ -29,7 +30,12 @@ export default function InputBrowser({
29
30
  onClose={handleClose}
30
31
  width={2}
31
32
  >
32
- <SelectAsset asset={asset} onChange={onChange} setDialogState={setDialogState} />
33
+ <SelectAsset
34
+ config={config}
35
+ asset={asset}
36
+ onChange={onChange}
37
+ setDialogState={setDialogState}
38
+ />
33
39
  </StyledDialog>
34
40
  )
35
41
  }
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-shadow */
2
1
  import {ChevronLeftIcon, ChevronRightIcon} from '@sanity/icons'
3
2
  import {Button, Label} from '@sanity/ui'
4
3
  import {Dispatch, SetStateAction, useEffect} from 'react'
@@ -7,8 +6,6 @@ const PageSelector = (props: {
7
6
  page: number
8
7
  setPage: Dispatch<SetStateAction<number>>
9
8
  total: number
10
- // eslint-disable-next-line react/no-unused-prop-types
11
- limit: number
12
9
  }) => {
13
10
  const page = props.page
14
11
  const setPage = props.setPage
@@ -30,8 +27,8 @@ const PageSelector = (props: {
30
27
  style={{cursor: 'pointer'}}
31
28
  disabled={page <= 0}
32
29
  onClick={() => {
33
- setPage((page) => {
34
- return Math.min(props.total - 1, Math.max(0, page - 1))
30
+ setPage((p) => {
31
+ return Math.min(props.total - 1, Math.max(0, p - 1))
35
32
  })
36
33
  }}
37
34
  />
@@ -45,8 +42,8 @@ const PageSelector = (props: {
45
42
  style={{cursor: 'pointer'}}
46
43
  disabled={page >= props.total - 1}
47
44
  onClick={() => {
48
- setPage((page) => {
49
- return Math.min(props.total - 1, Math.max(0, page + 1))
45
+ setPage((p) => {
46
+ return Math.min(props.total - 1, Math.max(0, p + 1))
50
47
  })
51
48
  }}
52
49
  />
@@ -1,4 +1,4 @@
1
- import {useState} from 'react'
1
+ import {Suspense, useState} from 'react'
2
2
  import {styled} from 'styled-components'
3
3
 
4
4
  import {useClient} from '../hooks/useClient'
@@ -46,5 +46,10 @@ export function ThumbnailsMetadataTrack({asset}: ThumbnailsMetadataTrackProps) {
46
46
  // Why useState instead of useMemo? Because we really really only want to run it exactly once and useMemo doesn't make that guarantee
47
47
  const [src] = useState<string>(() => getStoryboardSrc({asset, client}))
48
48
 
49
- return <track label="thumbnails" default kind="metadata" src={src} />
49
+ return (
50
+ /* We use Suspense here because `getStoryboardSrc` uses suspend() under the hood */
51
+ <Suspense fallback={null}>
52
+ <track label="thumbnails" default kind="metadata" src={src} />
53
+ </Suspense>
54
+ )
50
55
  }
@@ -5,6 +5,7 @@ import {
5
5
  PlugIcon,
6
6
  ResetIcon,
7
7
  SearchIcon,
8
+ SyncIcon,
8
9
  TranslateIcon,
9
10
  UploadIcon,
10
11
  } from '@sanity/icons'
@@ -28,6 +29,7 @@ import {styled} from 'styled-components'
28
29
 
29
30
  import {useAccessControl} from '../hooks/useAccessControl'
30
31
  import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
32
+ import {useResyncAsset} from '../hooks/useResyncAsset'
31
33
  import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
32
34
  import type {MuxInputProps, PluginConfig, VideoAssetDocument} from '../util/types'
33
35
  import {FileInputMenuItem} from './FileInputMenuItem'
@@ -64,11 +66,17 @@ function PlayerActionsMenu(
64
66
  const {asset, readOnly, dialogState, setDialogState, onChange, onSelect, accept} = props
65
67
  const [open, setOpen] = useState(false)
66
68
  const [menuElement, setMenuRef] = useState<HTMLDivElement | null>(null)
67
- const isSigned = useMemo(() => getPlaybackPolicy(asset) === 'signed', [asset])
69
+ const isSigned = useMemo(() => getPlaybackPolicy(asset)?.policy === 'signed', [asset])
68
70
  const {hasConfigAccess} = useAccessControl(props.config)
71
+ const {resyncAsset, isResyncing} = useResyncAsset({showToast: true})
69
72
 
70
73
  const onReset = useCallback(() => onChange(PatchEvent.from(unset([]))), [onChange])
71
74
 
75
+ const handleResync = useCallback(async () => {
76
+ setOpen(false)
77
+ await resyncAsset(asset)
78
+ }, [resyncAsset, asset])
79
+
72
80
  useEffect(() => {
73
81
  if (open && dialogState) {
74
82
  setOpen(false)
@@ -134,6 +142,12 @@ function PlayerActionsMenu(
134
142
  text="Captions"
135
143
  onClick={() => setDialogState('edit-captions')}
136
144
  />
145
+ <MenuItem
146
+ icon={SyncIcon}
147
+ text="Resync from Mux"
148
+ onClick={handleResync}
149
+ disabled={readOnly || isResyncing}
150
+ />
137
151
  </>
138
152
  )}
139
153
  <MenuDivider />
@@ -1,28 +1,106 @@
1
1
  import {CheckmarkCircleIcon, ErrorOutlineIcon, SyncIcon} from '@sanity/icons'
2
- import {Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text} from '@sanity/ui'
2
+ import {Box, Button, Card, Dialog, Flex, Heading, Radio, Spinner, Stack, Text} from '@sanity/ui'
3
+ import {useState} from 'react'
3
4
 
4
5
  import useResyncMuxMetadata from '../hooks/useResyncMuxMetadata'
5
6
  import {isEmptyOrPlaceholderTitle} from '../util/assetTitlePlaceholder'
6
7
  import {DIALOGS_Z_INDEX} from '../util/constants'
7
8
 
8
- // eslint-disable-next-line complexity
9
+ type SyncOption = 'fillEmpty' | 'syncTitles' | 'fullResync'
10
+
11
+ interface OptionCardProps {
12
+ id: SyncOption
13
+ selected: boolean
14
+ onSelect: (id: SyncOption) => void
15
+ title: string
16
+ count: number
17
+ description: string
18
+ disabled?: boolean
19
+ }
20
+
21
+ function OptionCard({
22
+ id,
23
+ selected,
24
+ onSelect,
25
+ title,
26
+ count,
27
+ description,
28
+ disabled,
29
+ }: OptionCardProps) {
30
+ return (
31
+ <Card
32
+ as="label"
33
+ padding={3}
34
+ radius={2}
35
+ border
36
+ tone={selected ? 'primary' : 'default'}
37
+ style={{
38
+ cursor: disabled ? 'not-allowed' : 'pointer',
39
+ opacity: disabled ? 0.5 : 1,
40
+ }}
41
+ >
42
+ <Flex gap={3} align="flex-start">
43
+ <Box paddingTop={1}>
44
+ <Radio
45
+ checked={selected}
46
+ onChange={() => onSelect(id)}
47
+ disabled={disabled}
48
+ name="sync-option"
49
+ />
50
+ </Box>
51
+ <Stack space={2} flex={1}>
52
+ <Flex align="center" gap={2}>
53
+ <Text size={2} weight="semibold">
54
+ {title} ({count})
55
+ </Text>
56
+ </Flex>
57
+ <Text size={1} muted>
58
+ {description}
59
+ </Text>
60
+ </Stack>
61
+ </Flex>
62
+ </Card>
63
+ )
64
+ }
65
+
9
66
  function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
10
67
  const {resyncState} = props
11
68
 
12
- const canTriggerResync = resyncState === 'idle' || resyncState === 'error'
13
- const isResyncing = resyncState === 'syncing'
14
- const isDone = resyncState === 'done'
15
-
16
69
  const videosToUpdate = props.matchedAssets?.filter((m) => m.muxAsset).length || 0
17
70
  const videosWithEmptyOrPlaceholder =
18
71
  props.matchedAssets?.filter(
19
72
  (m) => m.muxAsset && m.muxTitle && isEmptyOrPlaceholderTitle(m.currentTitle, m.muxAsset.id)
20
73
  ).length || 0
21
74
 
75
+ const hasEmptyTitles = videosWithEmptyOrPlaceholder > 0
76
+ const defaultOption: SyncOption = hasEmptyTitles ? 'fillEmpty' : 'syncTitles'
77
+ const [selectedOption, setSelectedOption] = useState<SyncOption>(defaultOption)
78
+
79
+ const canTriggerResync = resyncState === 'idle' || resyncState === 'error'
80
+ const isResyncing = resyncState === 'syncing'
81
+ const isDone = resyncState === 'done'
82
+ const isLoading = props.muxAssets.loading || props.sanityAssetsLoading
83
+
84
+ const handleSync = () => {
85
+ switch (selectedOption) {
86
+ case 'fillEmpty':
87
+ props.syncOnlyEmpty()
88
+ break
89
+ case 'syncTitles':
90
+ props.syncAllVideos()
91
+ break
92
+ case 'fullResync':
93
+ props.syncFullData()
94
+ break
95
+ default:
96
+ break
97
+ }
98
+ }
99
+
22
100
  return (
23
101
  <Dialog
24
102
  animate
25
- header={'Resync Metadata from Mux'}
103
+ header="Sync with Mux"
26
104
  zOffset={DIALOGS_Z_INDEX}
27
105
  id="resync-metadata-dialog"
28
106
  onClose={props.closeDialog}
@@ -32,40 +110,25 @@ function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
32
110
  footer={
33
111
  !isDone && (
34
112
  <Card padding={3}>
35
- <Flex justify="space-between" align="center">
113
+ <Flex justify="flex-end" gap={2}>
36
114
  <Button
37
115
  fontSize={2}
38
116
  padding={3}
39
117
  mode="ghost"
40
118
  text="Cancel"
41
- tone="critical"
42
119
  onClick={props.closeDialog}
43
120
  disabled={isResyncing}
44
121
  />
45
- <Flex gap={2}>
46
- {videosWithEmptyOrPlaceholder > 0 && (
47
- <Button
48
- fontSize={2}
49
- padding={3}
50
- mode="ghost"
51
- text={`Update empty (${videosWithEmptyOrPlaceholder})`}
52
- tone="caution"
53
- onClick={props.syncOnlyEmpty}
54
- disabled={isResyncing || !canTriggerResync}
55
- />
56
- )}
57
- <Button
58
- icon={SyncIcon}
59
- fontSize={2}
60
- padding={3}
61
- mode="ghost"
62
- text={`Update all (${videosToUpdate})`}
63
- tone="positive"
64
- onClick={props.syncAllVideos}
65
- iconRight={isResyncing && Spinner}
66
- disabled={!canTriggerResync}
67
- />
68
- </Flex>
122
+ <Button
123
+ icon={SyncIcon}
124
+ fontSize={2}
125
+ padding={3}
126
+ text="Run sync"
127
+ tone="primary"
128
+ onClick={handleSync}
129
+ iconRight={isResyncing && Spinner}
130
+ disabled={!canTriggerResync || isLoading}
131
+ />
69
132
  </Flex>
70
133
  </Card>
71
134
  )
@@ -73,15 +136,17 @@ function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
73
136
  >
74
137
  <Box padding={4}>
75
138
  {/* LOADING ASSETS STATE */}
76
- {(props.muxAssets.loading || props.sanityAssetsLoading) && (
77
- <Card tone="primary" marginBottom={5} padding={3} border>
139
+ {isLoading && (
140
+ <Card tone="primary" marginBottom={4} padding={3} border radius={2}>
78
141
  <Flex align="center" gap={4}>
79
142
  <Spinner muted size={4} />
80
143
  <Stack space={2}>
81
144
  <Text size={2} weight="semibold">
82
145
  Loading assets from Mux
83
146
  </Text>
84
- <Text size={1}>This may take a while.</Text>
147
+ <Text size={1} muted>
148
+ This may take a while.
149
+ </Text>
85
150
  </Stack>
86
151
  </Flex>
87
152
  </Card>
@@ -89,7 +154,7 @@ function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
89
154
 
90
155
  {/* ERROR LOADING MUX */}
91
156
  {props.muxAssets.error && (
92
- <Card tone="critical" marginBottom={5} padding={3} border>
157
+ <Card tone="critical" marginBottom={4} padding={3} border radius={2}>
93
158
  <Flex align="center" gap={2}>
94
159
  <ErrorOutlineIcon fontSize={36} />
95
160
  <Stack space={2}>
@@ -104,14 +169,16 @@ function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
104
169
 
105
170
  {/* SYNCING STATE */}
106
171
  {resyncState === 'syncing' && (
107
- <Card tone="primary" marginBottom={5} padding={3} border>
172
+ <Card tone="primary" marginBottom={4} padding={3} border radius={2}>
108
173
  <Flex align="center" gap={4}>
109
174
  <Spinner muted size={4} />
110
175
  <Stack space={2}>
111
176
  <Text size={2} weight="semibold">
112
- Updating video metadata
177
+ Syncing metadata
178
+ </Text>
179
+ <Text size={1} muted>
180
+ Updating videos from Mux...
113
181
  </Text>
114
- <Text size={1}>Syncing titles from Mux...</Text>
115
182
  </Stack>
116
183
  </Flex>
117
184
  </Card>
@@ -119,7 +186,7 @@ function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
119
186
 
120
187
  {/* ERROR SYNCING */}
121
188
  {resyncState === 'error' && (
122
- <Card tone="critical" marginBottom={5} padding={3} border>
189
+ <Card tone="critical" marginBottom={4} padding={3} border radius={2}>
123
190
  <Flex align="center" gap={2}>
124
191
  <ErrorOutlineIcon fontSize={36} />
125
192
  <Stack space={2}>
@@ -138,45 +205,57 @@ function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
138
205
 
139
206
  {/* SUCCESS STATE */}
140
207
  {resyncState === 'done' && (
141
- <Stack paddingY={5} marginBottom={4} space={3} style={{textAlign: 'center'}}>
208
+ <Stack paddingY={5} space={3} style={{textAlign: 'center'}}>
142
209
  <Box>
143
210
  <CheckmarkCircleIcon fontSize={48} />
144
211
  </Box>
145
- <Heading size={2}>Metadata synced successfully</Heading>
146
- <Text size={2}>All video titles have been updated from Mux.</Text>
212
+ <Heading size={2}>Sync completed</Heading>
213
+ <Text size={2} muted>
214
+ Videos have been updated from Mux.
215
+ </Text>
147
216
  </Stack>
148
217
  )}
149
218
 
150
- {/* CONFIRMATION MESSAGE */}
151
- {resyncState === 'idle' && !props.muxAssets.loading && !props.sanityAssetsLoading && (
219
+ {/* OPTIONS */}
220
+ {!isDone && !isLoading && !props.muxAssets.error && (
152
221
  <Stack space={4}>
153
- <Heading size={1}>
154
- There {videosToUpdate === 1 ? 'is' : 'are'} {videosToUpdate} video
155
- {videosToUpdate === 1 ? '' : 's'} with Mux metadata
156
- </Heading>
157
- <Text size={2}>
158
- This will update video titles in Sanity to match those in Mux. No new videos will be
159
- created.
222
+ <Text size={1} muted>
223
+ Found {videosToUpdate} video{videosToUpdate === 1 ? '' : 's'} linked to Mux.
160
224
  </Text>
161
- {videosWithEmptyOrPlaceholder > 0 && (
162
- <Card padding={3} tone="caution" border>
163
- <Flex align="flex-start" gap={2}>
164
- <Box>
165
- <ErrorOutlineIcon />
166
- </Box>
167
- <Stack space={2}>
168
- <Text size={2} weight="semibold">
169
- Videos with empty or placeholder titles
170
- </Text>
171
- <Text size={1} muted>
172
- {videosWithEmptyOrPlaceholder} video
173
- {videosWithEmptyOrPlaceholder === 1 ? '' : 's'} without titles or with
174
- placeholder titles (e.g., &quot;Asset #123&quot;) can be updated selectively.
175
- </Text>
176
- </Stack>
177
- </Flex>
178
- </Card>
179
- )}
225
+
226
+ <Stack space={3}>
227
+ {hasEmptyTitles && (
228
+ <OptionCard
229
+ id="fillEmpty"
230
+ selected={selectedOption === 'fillEmpty'}
231
+ onSelect={setSelectedOption}
232
+ title="Fill missing titles only"
233
+ count={videosWithEmptyOrPlaceholder}
234
+ description="Updates only videos without a title or with placeholder titles (e.g., 'Asset #123') using the title from Mux."
235
+ disabled={isResyncing}
236
+ />
237
+ )}
238
+
239
+ <OptionCard
240
+ id="syncTitles"
241
+ selected={selectedOption === 'syncTitles'}
242
+ onSelect={setSelectedOption}
243
+ title="Sync all titles"
244
+ count={videosToUpdate}
245
+ description="Replaces the title in Sanity with the title from Mux for all videos."
246
+ disabled={isResyncing}
247
+ />
248
+
249
+ <OptionCard
250
+ id="fullResync"
251
+ selected={selectedOption === 'fullResync'}
252
+ onSelect={setSelectedOption}
253
+ title="Full resync"
254
+ count={videosToUpdate}
255
+ description="Updates all fields from Mux including status, duration, tracks, captions, and renditions."
256
+ disabled={isResyncing}
257
+ />
258
+ </Stack>
180
259
  </Stack>
181
260
  )}
182
261
  </Box>
@@ -197,5 +276,5 @@ export default function ResyncMetadata() {
197
276
  }
198
277
 
199
278
  // eslint-disable-next-line consistent-return
200
- return <Button mode="bleed" text="Resync Metadata" onClick={resyncMetadata.openDialog} />
279
+ return <Button mode="bleed" text="Sync with Mux" onClick={resyncMetadata.openDialog} />
201
280
  }
@@ -2,15 +2,21 @@ import {useCallback} from 'react'
2
2
  import {PatchEvent, set, setIfMissing, unset} from 'sanity'
3
3
 
4
4
  import type {SetDialogState} from '../hooks/useDialogState'
5
- import type {MuxInputProps, VideoAssetDocument} from '../util/types'
5
+ import type {MuxInputProps, PluginConfig, VideoAssetDocument} from '../util/types'
6
6
  import VideosBrowser, {type VideosBrowserProps} from './VideosBrowser'
7
7
 
8
8
  export interface Props extends Pick<MuxInputProps, 'onChange'> {
9
9
  asset?: VideoAssetDocument | null | undefined
10
10
  setDialogState: SetDialogState
11
+ config: PluginConfig
11
12
  }
12
13
 
13
- export default function SelectAssets({asset: selectedAsset, onChange, setDialogState}: Props) {
14
+ export default function SelectAssets({
15
+ asset: selectedAsset,
16
+ onChange,
17
+ setDialogState,
18
+ config,
19
+ }: Props) {
14
20
  const handleSelect = useCallback<Required<VideosBrowserProps>['onSelect']>(
15
21
  (chosenAsset) => {
16
22
  if (!chosenAsset?._id) {
@@ -29,5 +35,5 @@ export default function SelectAssets({asset: selectedAsset, onChange, setDialogS
29
35
  [onChange, setDialogState, selectedAsset]
30
36
  )
31
37
 
32
- return <VideosBrowser onSelect={handleSelect} />
38
+ return <VideosBrowser onSelect={handleSelect} config={config} />
33
39
  }
@@ -4,8 +4,8 @@ import type {PluginConfig} from '../util/types'
4
4
  import ToolIcon from './icons/ToolIcon'
5
5
  import VideosBrowser from './VideosBrowser'
6
6
 
7
- const StudioTool: React.FC<PluginConfig> = () => {
8
- return <VideosBrowser />
7
+ const StudioTool: React.FC<PluginConfig> = (config) => {
8
+ return <VideosBrowser config={config} />
9
9
  }
10
10
 
11
11
  export const DEFAULT_TOOL_CONFIG = {