sanity-plugin-mux-input 2.15.0 → 2.17.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.
@@ -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'
@@ -66,9 +68,15 @@ function PlayerActionsMenu(
66
68
  const [menuElement, setMenuRef] = useState<HTMLDivElement | null>(null)
67
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
  }
@@ -10,8 +10,9 @@ import {
10
10
  import {Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text, useToast} from '@sanity/ui'
11
11
  import {useEffect, useId, useMemo, useState} from 'react'
12
12
 
13
- import {deleteTextTrack, getAsset} from '../actions/assets'
13
+ import {deleteTextTrack} from '../actions/assets'
14
14
  import {useClient} from '../hooks/useClient'
15
+ import {useResyncAsset} from '../hooks/useResyncAsset'
15
16
  import {downloadVttFile} from '../util/textTracks'
16
17
  import type {MuxTextTrack, VideoAssetDocument} from '../util/types'
17
18
  import AddCaptionDialog from './AddCaptionDialog'
@@ -203,6 +204,7 @@ export default function TextTracksManager({
203
204
  const client = useClient()
204
205
  const toast = useToast()
205
206
  const dialogId = `DeleteCaptionDialog${useId()}`
207
+ const {resyncAsset} = useResyncAsset()
206
208
  const [downloadingTrackId, setDownloadingTrackId] = useState<string | null>(null)
207
209
  const [deletingTrackId, setDeletingTrackId] = useState<string | null>(null)
208
210
  const [addedTracks, setAddedTracks] = useState<MuxTextTrack[]>([])
@@ -216,27 +218,6 @@ export default function TextTracksManager({
216
218
 
217
219
  const MAX_VISIBLE_TRACKS = 4
218
220
 
219
- useEffect(() => {
220
- if (!asset.assetId || !asset._id) return
221
-
222
- const assetId = asset.assetId
223
- const documentId = asset._id
224
-
225
- const refreshAsset = async () => {
226
- try {
227
- const response = await getAsset(client, assetId)
228
- await client
229
- .patch(documentId)
230
- .set({data: response.data, status: response.data.status})
231
- .commit()
232
- } catch (error) {
233
- console.error('Failed to refresh asset data:', error)
234
- }
235
- }
236
-
237
- refreshAsset()
238
- }, [asset.assetId, asset._id, client])
239
-
240
221
  const realTracks: MuxTextTrack[] = propTracks
241
222
  ? propTracks
242
223
  : asset.data?.tracks?.filter((track): track is MuxTextTrack => track.type === 'text') || []
@@ -344,20 +325,13 @@ export default function TextTracksManager({
344
325
  return undefined
345
326
  }
346
327
 
347
- const assetId = asset.assetId
348
- const documentId = asset._id
349
-
350
328
  const interval = setInterval(async () => {
351
329
  try {
352
- const response = await getAsset(client, assetId)
353
- await client
354
- .patch(documentId)
355
- .set({data: response.data, status: response.data.status})
356
- .commit()
330
+ const muxData = await resyncAsset(asset)
331
+ if (!muxData) return
357
332
 
358
333
  const fetchedTracks =
359
- response.data.tracks?.filter((track): track is MuxTextTrack => track.type === 'text') ||
360
- []
334
+ muxData.tracks?.filter((track): track is MuxTextTrack => track.type === 'text') || []
361
335
 
362
336
  const isMockTrackReplaced = (
363
337
  mockTrack: MuxTextTrack,
@@ -444,7 +418,7 @@ export default function TextTracksManager({
444
418
  }, 3000) // Poll every 3 seconds
445
419
 
446
420
  return () => clearInterval(interval)
447
- }, [allTracks, asset.assetId, asset._id, client])
421
+ }, [allTracks, asset, resyncAsset])
448
422
 
449
423
  const visibleTracks = allTracks
450
424
  .filter(
@@ -506,17 +480,8 @@ export default function TextTracksManager({
506
480
  }
507
481
  await deleteTextTrack(client, asset.assetId, track.id)
508
482
 
509
- if (asset._id) {
510
- try {
511
- const response = await getAsset(client, asset.assetId)
512
- await client
513
- .patch(asset._id)
514
- .set({data: response.data, status: response.data.status})
515
- .commit()
516
- } catch (refreshError) {
517
- console.error('Failed to refresh asset data:', refreshError)
518
- }
519
- }
483
+ // Refresh asset data after deletion
484
+ await resyncAsset(asset)
520
485
 
521
486
  toast.push({
522
487
  title: 'Successfully deleted caption track',
@@ -600,17 +565,8 @@ export default function TextTracksManager({
600
565
 
601
566
  setTrackToEdit(null)
602
567
 
603
- if (asset._id && asset.assetId) {
604
- try {
605
- const response = await getAsset(client, asset.assetId)
606
- await client
607
- .patch(asset._id)
608
- .set({data: response.data, status: response.data.status})
609
- .commit()
610
- } catch (refreshError) {
611
- console.error('Failed to refresh asset data:', refreshError)
612
- }
613
- }
568
+ // Refresh asset data after update
569
+ await resyncAsset(asset)
614
570
  }
615
571
 
616
572
  const getTrackSourceLabel = (track: MuxTextTrack) => {