sanity-plugin-mux-input 2.12.1 → 2.14.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 (35) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +133 -0
  3. package/dist/index.d.mts +63 -1
  4. package/dist/index.d.ts +63 -1
  5. package/dist/index.js +1564 -153
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1565 -154
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +1 -1
  10. package/src/_exports/index.ts +1 -0
  11. package/src/actions/assets.ts +75 -0
  12. package/src/components/AddCaptionDialog.tsx +421 -0
  13. package/src/components/CaptionsDialog.tsx +23 -0
  14. package/src/components/EditCaptionDialog.tsx +508 -0
  15. package/src/components/FileInputButton.tsx +2 -2
  16. package/src/components/Onboard.tsx +2 -2
  17. package/src/components/PageSelector.tsx +57 -0
  18. package/src/components/Player.tsx +4 -3
  19. package/src/components/PlayerActionsMenu.tsx +17 -8
  20. package/src/components/TextTracksManager.tsx +781 -0
  21. package/src/components/UploadConfiguration.tsx +181 -4
  22. package/src/components/UploadPlaceholder.tsx +14 -6
  23. package/src/components/Uploader.styled.tsx +8 -15
  24. package/src/components/Uploader.tsx +61 -6
  25. package/src/components/VideoDetails/VideoDetails.tsx +16 -0
  26. package/src/components/VideoInBrowser.tsx +2 -7
  27. package/src/components/VideoPlayer.tsx +35 -6
  28. package/src/components/VideosBrowser.tsx +9 -1
  29. package/src/components/icons/Audio.tsx +13 -0
  30. package/src/hooks/useAccessControl.ts +1 -0
  31. package/src/hooks/useDialogState.ts +1 -1
  32. package/src/util/getVideoMetadata.ts +3 -1
  33. package/src/util/textTracks.ts +219 -0
  34. package/src/util/types.ts +56 -3
  35. package/src/components/FileInputArea.tsx +0 -93
@@ -0,0 +1,508 @@
1
+ import {DownloadIcon, TranslateIcon, UploadIcon} from '@sanity/icons'
2
+ import {
3
+ Autocomplete,
4
+ Button,
5
+ Card,
6
+ Dialog,
7
+ Flex,
8
+ Label,
9
+ Spinner,
10
+ Stack,
11
+ Text,
12
+ TextInput,
13
+ useToast,
14
+ } from '@sanity/ui'
15
+ import LanguagesList from 'iso-639-1'
16
+ import {useEffect, useId, useRef, useState} from 'react'
17
+
18
+ import {addTextTrackFromUrl, deleteTextTrack, getAsset} from '../actions/assets'
19
+ import {useClient} from '../hooks/useClient'
20
+ import {generateJwt} from '../util/generateJwt'
21
+ import {getPlaybackId} from '../util/getPlaybackId'
22
+ import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
23
+ import {downloadVttFile, extractErrorMessage, pollTrackStatus} from '../util/textTracks'
24
+ import type {MuxTextTrack, VideoAssetDocument} from '../util/types'
25
+
26
+ const LANGUAGE_OPTIONS = LanguagesList.getAllCodes().map((code) => ({
27
+ value: code,
28
+ label: LanguagesList.getNativeName(code),
29
+ }))
30
+
31
+ export interface Props {
32
+ asset: VideoAssetDocument
33
+ track: MuxTextTrack
34
+ onUpdate: (track: MuxTextTrack, oldTrackId?: string) => void
35
+ onClose: () => void
36
+ }
37
+
38
+ export default function EditCaptionDialog({asset, track, onUpdate, onClose}: Props) {
39
+ const client = useClient()
40
+ const toast = useToast()
41
+ const dialogId = `EditCaptionDialog${useId()}`
42
+
43
+ const isAutogenerated =
44
+ track.text_source === 'generated_live' ||
45
+ track.text_source === 'generated_live_final' ||
46
+ track.text_source === 'generated_vod'
47
+
48
+ const [vttUrl, setVttUrl] = useState('')
49
+ const [languageCode, setLanguageCode] = useState(track.language_code || '')
50
+ const [selectedLanguage, setSelectedLanguage] = useState<{value: string; label: string} | null>(
51
+ () => {
52
+ const baseCode = track.language_code?.split('-')[0]
53
+ const found = LANGUAGE_OPTIONS.find(
54
+ (opt) => opt.value === track.language_code || opt.value === baseCode
55
+ )
56
+ if (found) return found
57
+ if (track.name) {
58
+ const foundByName = LANGUAGE_OPTIONS.find((opt) => opt.label === track.name)
59
+ if (foundByName) return foundByName
60
+ }
61
+ return null
62
+ }
63
+ )
64
+ const [name, setName] = useState(track.name || '')
65
+ const [isSubmitting, setIsSubmitting] = useState(false)
66
+ const [downloading, setDownloading] = useState(false)
67
+ const [selectedFile, setSelectedFile] = useState<File | null>(null)
68
+ const fileInputRef = useRef<HTMLInputElement>(null)
69
+
70
+ useEffect(() => {
71
+ setLanguageCode(track.language_code || '')
72
+ setName(track.name || '')
73
+ setVttUrl('')
74
+ const baseCode = track.language_code?.split('-')[0]
75
+ const foundByCode = LANGUAGE_OPTIONS.find(
76
+ (opt) => opt.value === track.language_code || opt.value === baseCode
77
+ )
78
+ const foundByName = track.name ? LANGUAGE_OPTIONS.find((opt) => opt.label === track.name) : null
79
+ setSelectedLanguage(foundByCode || foundByName || null)
80
+ }, [track, asset, client])
81
+
82
+ const handleDownloadCurrentFile = async () => {
83
+ setDownloading(true)
84
+ try {
85
+ await downloadVttFile(client, asset, track)
86
+ } catch (error) {
87
+ let errorMessage = 'Please try again'
88
+ let title = 'Failed to download VTT file'
89
+
90
+ if (error instanceof Error) {
91
+ errorMessage = error.message
92
+ if (error.message.includes('Track')) {
93
+ title = 'Cannot download'
94
+ }
95
+ } else if (error === 'Track ID is missing' || error === 'Track is not ready yet') {
96
+ errorMessage = String(error)
97
+ title = 'Cannot download'
98
+ }
99
+
100
+ toast.push({
101
+ title,
102
+ status: 'error',
103
+ description: errorMessage,
104
+ })
105
+ } finally {
106
+ setDownloading(false)
107
+ }
108
+ }
109
+
110
+ const getCurrentFileName = () => {
111
+ if (track.id && asset.filename) {
112
+ return `${asset.filename}-${track.language_code || 'en'}.vtt`
113
+ }
114
+ return `captions-${track.language_code || 'en'}.vtt`
115
+ }
116
+
117
+ const uploadVttFile = async (file: File): Promise<string> => {
118
+ const assetDocument = await client.assets.upload('file', file, {
119
+ filename: file.name,
120
+ })
121
+ return assetDocument.url
122
+ }
123
+
124
+ const refreshAssetData = async () => {
125
+ if (!asset._id || !asset.assetId) return
126
+ try {
127
+ const latestAssetData = await getAsset(client, asset.assetId)
128
+ await client
129
+ .patch(asset._id)
130
+ .set({data: latestAssetData.data, status: latestAssetData.data.status})
131
+ .commit()
132
+ } catch (refreshError) {
133
+ console.error('Failed to refresh asset data:', refreshError)
134
+ }
135
+ }
136
+
137
+ const handleUpdateTrackWithNewUrl = async () => {
138
+ if (!asset.assetId) {
139
+ throw new Error('Asset ID is required')
140
+ }
141
+
142
+ const trimmedName = name.trim()
143
+ const trimmedLanguageCode = languageCode.trim()
144
+
145
+ const oldTrackId = track.id
146
+
147
+ try {
148
+ await deleteTextTrack(client, asset.assetId, oldTrackId)
149
+ } catch (deleteError) {
150
+ toast.push({
151
+ title: 'Failed to delete old track',
152
+ status: 'error',
153
+ description: 'Could not delete the old track. Please try again or delete it manually.',
154
+ })
155
+ setIsSubmitting(false)
156
+ throw deleteError
157
+ }
158
+
159
+ let vttUrlToUse = vttUrl.trim()
160
+
161
+ if (selectedFile) {
162
+ try {
163
+ vttUrlToUse = await uploadVttFile(selectedFile)
164
+ } catch (uploadError) {
165
+ toast.push({
166
+ title: 'Failed to upload VTT file',
167
+ status: 'error',
168
+ description: 'Could not upload the VTT file to Sanity. Please try again.',
169
+ })
170
+ setIsSubmitting(false)
171
+ throw uploadError
172
+ }
173
+ }
174
+
175
+ try {
176
+ await addTextTrackFromUrl(client, asset.assetId, vttUrlToUse, {
177
+ language_code: trimmedLanguageCode,
178
+ name: trimmedName,
179
+ text_type: 'subtitles',
180
+ })
181
+ } catch (error: unknown) {
182
+ toast.push({
183
+ title: 'Failed to update caption track',
184
+ status: 'error',
185
+ description: extractErrorMessage(error, 'Failed to update caption track'),
186
+ })
187
+ setIsSubmitting(false)
188
+ throw error
189
+ }
190
+
191
+ const result = await pollTrackStatus({
192
+ client,
193
+ assetId: asset.assetId,
194
+ trackName: trimmedName,
195
+ trackLanguageCode: trimmedLanguageCode,
196
+ onTrackErrored: async (erroredTrack) => {
197
+ const errorMessage =
198
+ erroredTrack.error?.messages?.[0] ||
199
+ erroredTrack.error?.type ||
200
+ 'The track failed to download from the provided URL'
201
+ toast.push({
202
+ title: 'Caption track failed',
203
+ status: 'error',
204
+ description: errorMessage,
205
+ })
206
+ await refreshAssetData()
207
+ onUpdate(erroredTrack, oldTrackId)
208
+ setIsSubmitting(false)
209
+ },
210
+ })
211
+
212
+ if (!result.found || !result.track) {
213
+ toast.push({
214
+ title: 'Caption track may have been updated',
215
+ status: 'warning',
216
+ description:
217
+ 'The track was updated but its status could not be determined. It may still be processing. Please refresh the page to see if it appears.',
218
+ })
219
+ setIsSubmitting(false)
220
+ return
221
+ }
222
+
223
+ if (result.status === 'errored') {
224
+ return
225
+ }
226
+
227
+ await refreshAssetData()
228
+
229
+ if (result.status === 'preparing') {
230
+ toast.push({
231
+ title: 'Caption track is processing',
232
+ status: 'info',
233
+ description:
234
+ 'The track was updated and is being processed. It will appear in the list shortly.',
235
+ })
236
+ } else {
237
+ toast.push({
238
+ title: 'Caption track updated',
239
+ status: 'success',
240
+ description: 'Caption track updated successfully',
241
+ })
242
+ }
243
+
244
+ onUpdate(result.track, oldTrackId)
245
+ setIsSubmitting(false)
246
+ }
247
+
248
+ const handleSubmit = async () => {
249
+ if (!name.trim()) {
250
+ toast.push({
251
+ title: 'Audio name required',
252
+ status: 'error',
253
+ description: 'Please enter an audio name for this caption track',
254
+ })
255
+ return
256
+ }
257
+
258
+ if (!languageCode.trim()) {
259
+ toast.push({
260
+ title: 'Language code required',
261
+ status: 'error',
262
+ description: 'Please enter a language code (e.g., en, es, fr)',
263
+ })
264
+ return
265
+ }
266
+
267
+ setIsSubmitting(true)
268
+
269
+ try {
270
+ if (!asset.assetId) {
271
+ throw new Error('Asset ID is required')
272
+ }
273
+
274
+ const originalVttUrl = (() => {
275
+ if (isAutogenerated || !track.id) return ''
276
+ const playbackId = getPlaybackId(asset)
277
+ if (!playbackId) return ''
278
+ let url = `https://stream.mux.com/${playbackId}/text/${track.id}.vtt`
279
+ if (getPlaybackPolicy(asset) === 'signed') {
280
+ const token = generateJwt(client, playbackId, 'v')
281
+ url += `?token=${token}`
282
+ }
283
+ return url
284
+ })()
285
+
286
+ const urlChanged =
287
+ selectedFile !== null || (vttUrl.trim() && vttUrl.trim() !== originalVttUrl)
288
+
289
+ if (!urlChanged) {
290
+ toast.push({
291
+ title: 'No changes',
292
+ status: 'info',
293
+ description:
294
+ 'Please provide a new VTT file or URL using the "Replace" button or URL field to update the track.',
295
+ })
296
+ setIsSubmitting(false)
297
+ return
298
+ }
299
+
300
+ if (urlChanged) {
301
+ if (!selectedFile && vttUrl.trim()) {
302
+ try {
303
+ void new URL(vttUrl.trim())
304
+ } catch {
305
+ toast.push({
306
+ title: 'Invalid URL',
307
+ status: 'error',
308
+ description: 'Please enter a valid URL (e.g., https://example.com/subtitles.vtt)',
309
+ })
310
+ setIsSubmitting(false)
311
+ return
312
+ }
313
+ }
314
+
315
+ if (!selectedFile && !vttUrl.trim()) {
316
+ toast.push({
317
+ title: 'VTT file or URL required',
318
+ status: 'error',
319
+ description: 'Please select a VTT file or enter a VTT file URL',
320
+ })
321
+ setIsSubmitting(false)
322
+ return
323
+ }
324
+
325
+ await handleUpdateTrackWithNewUrl()
326
+ }
327
+
328
+ onClose()
329
+ } catch (error) {
330
+ toast.push({
331
+ title: 'Failed to update caption track',
332
+ status: 'error',
333
+ description: error instanceof Error ? error.message : 'Please try again',
334
+ })
335
+ } finally {
336
+ setIsSubmitting(false)
337
+ }
338
+ }
339
+
340
+ return (
341
+ <Dialog
342
+ id={dialogId}
343
+ header="Edit Caption Track"
344
+ onClose={onClose}
345
+ width={1}
346
+ onClickOutside={onClose}
347
+ >
348
+ <Stack padding={4} space={4}>
349
+ <Stack space={2}>
350
+ <Card padding={3} marginBottom={2} tone="transparent" border radius={2}>
351
+ <Flex align="center" justify="space-between">
352
+ <Text>{getCurrentFileName()}</Text>
353
+ <Flex gap={2}>
354
+ {track.status !== 'errored' && (
355
+ <Button
356
+ icon={
357
+ downloading ? (
358
+ <Spinner
359
+ style={{
360
+ verticalAlign: 'middle',
361
+ display: 'inline-block',
362
+ marginTop: '-2px',
363
+ width: '0.5em',
364
+ height: '0.5em',
365
+ }}
366
+ />
367
+ ) : (
368
+ <DownloadIcon />
369
+ )
370
+ }
371
+ text="Download"
372
+ mode="ghost"
373
+ tone="primary"
374
+ fontSize={1}
375
+ padding={2}
376
+ onClick={handleDownloadCurrentFile}
377
+ disabled={downloading || isSubmitting}
378
+ />
379
+ )}
380
+ <Button
381
+ icon={UploadIcon}
382
+ text="Replace"
383
+ mode="ghost"
384
+ tone="primary"
385
+ fontSize={1}
386
+ padding={2}
387
+ onClick={() => fileInputRef.current?.click()}
388
+ disabled={isSubmitting}
389
+ />
390
+ </Flex>
391
+ </Flex>
392
+ <input
393
+ ref={fileInputRef}
394
+ type="file"
395
+ accept=".vtt,text/vtt"
396
+ style={{display: 'none'}}
397
+ onChange={(e) => {
398
+ if (e.target.files && e.target.files.length > 0 && !isSubmitting) {
399
+ setSelectedFile(e.target.files[0])
400
+ setVttUrl('')
401
+ }
402
+ }}
403
+ />
404
+ {selectedFile && (
405
+ <Text size={1} muted style={{marginTop: 8}}>
406
+ Selected: {selectedFile.name}
407
+ </Text>
408
+ )}
409
+ </Card>
410
+ <Stack space={2}>
411
+ <Label htmlFor="vtt-url">VTT File URL</Label>
412
+ <TextInput
413
+ id="vtt-url"
414
+ placeholder="https://example.com/subtitles.vtt"
415
+ value={vttUrl}
416
+ onChange={(e) => {
417
+ setVttUrl(e.currentTarget.value)
418
+ setSelectedFile(null)
419
+ }}
420
+ disabled={isSubmitting}
421
+ />
422
+ <Text size={1} muted>
423
+ Add a URL to replace the existing VTT file with a new one
424
+ </Text>
425
+ </Stack>
426
+ </Stack>
427
+
428
+ <Stack space={2}>
429
+ <Label htmlFor="caption-name">Audio name</Label>
430
+ <Autocomplete
431
+ id="caption-name"
432
+ value={selectedLanguage?.value || ''}
433
+ onChange={(newValue) => {
434
+ const selected = LANGUAGE_OPTIONS.find((opt) => opt.value === newValue)
435
+ if (selected) {
436
+ setSelectedLanguage(selected)
437
+ setLanguageCode(selected.value)
438
+ setName(selected.label)
439
+ }
440
+ }}
441
+ options={LANGUAGE_OPTIONS}
442
+ icon={TranslateIcon}
443
+ placeholder="Select language"
444
+ filterOption={(query, option) =>
445
+ option.label.toLowerCase().indexOf(query.toLowerCase()) > -1 ||
446
+ option.value.toLowerCase().indexOf(query.toLowerCase()) > -1
447
+ }
448
+ openButton
449
+ renderValue={(value) => LANGUAGE_OPTIONS.find((l) => l.value === value)?.label || value}
450
+ renderOption={(option) => (
451
+ <Card data-as="button" padding={3} radius={2} tone="inherit">
452
+ <Text size={2} textOverflow="ellipsis">
453
+ {option.label} ({option.value})
454
+ </Text>
455
+ </Card>
456
+ )}
457
+ disabled={isSubmitting}
458
+ />
459
+ </Stack>
460
+
461
+ <Stack space={2}>
462
+ <Label htmlFor="caption-language">Language Code</Label>
463
+ <TextInput
464
+ id="caption-language"
465
+ placeholder="en-US"
466
+ value={languageCode}
467
+ onChange={(e) => {
468
+ setLanguageCode(e.currentTarget.value)
469
+ if (selectedLanguage && selectedLanguage.value !== e.currentTarget.value) {
470
+ setSelectedLanguage(null)
471
+ if (!name || name === selectedLanguage.label) {
472
+ setName('')
473
+ }
474
+ }
475
+ }}
476
+ disabled={isSubmitting}
477
+ />
478
+ </Stack>
479
+
480
+ <Flex gap={2} justify="flex-end" marginTop={2}>
481
+ <Button text="Cancel" mode="ghost" onClick={onClose} disabled={isSubmitting} />
482
+ <Button
483
+ text="Update Caption Track"
484
+ tone="primary"
485
+ icon={
486
+ isSubmitting ? (
487
+ <Spinner
488
+ style={{
489
+ verticalAlign: 'middle',
490
+ display: 'inline-block',
491
+ marginBottom: '-3px',
492
+ width: '1em',
493
+ height: '1em',
494
+ marginRight: '-6px',
495
+ }}
496
+ />
497
+ ) : (
498
+ UploadIcon
499
+ )
500
+ }
501
+ onClick={handleSubmit}
502
+ disabled={isSubmitting}
503
+ />
504
+ </Flex>
505
+ </Stack>
506
+ </Dialog>
507
+ )
508
+ }
@@ -17,7 +17,7 @@ const Label = styled.label`
17
17
 
18
18
  export interface FileInputButtonProps extends ButtonProps {
19
19
  onSelect: (files: FileList) => void
20
- accept?: string
20
+ accept: string
21
21
  }
22
22
  export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonProps) => {
23
23
  const inputId = `FileSelect${useId()}`
@@ -34,7 +34,7 @@ export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonPro
34
34
  return (
35
35
  <Label htmlFor={inputId}>
36
36
  <HiddenInput
37
- accept={accept || 'video/*'}
37
+ accept={accept}
38
38
  ref={inputRef}
39
39
  tabIndex={0}
40
40
  type="file"
@@ -2,10 +2,10 @@ import {PlugIcon} from '@sanity/icons'
2
2
  import {Button, Card, Flex, Grid, Heading, Inline, Text} from '@sanity/ui'
3
3
  import {useCallback} from 'react'
4
4
 
5
+ import {useAccessControl} from '../hooks/useAccessControl'
5
6
  import type {SetDialogState} from '../hooks/useDialogState'
6
- import MuxLogo from './MuxLogo'
7
7
  import {PluginConfig} from '../util/types'
8
- import {useAccessControl} from '../hooks/useAccessControl'
8
+ import MuxLogo from './MuxLogo'
9
9
 
10
10
  interface OnboardProps {
11
11
  setDialogState: SetDialogState
@@ -0,0 +1,57 @@
1
+ /* eslint-disable @typescript-eslint/no-shadow */
2
+ import {ChevronLeftIcon, ChevronRightIcon} from '@sanity/icons'
3
+ import {Button, Label} from '@sanity/ui'
4
+ import {Dispatch, SetStateAction, useEffect} from 'react'
5
+
6
+ const PageSelector = (props: {
7
+ page: number
8
+ setPage: Dispatch<SetStateAction<number>>
9
+ total: number
10
+ // eslint-disable-next-line react/no-unused-prop-types
11
+ limit: number
12
+ }) => {
13
+ const page = props.page
14
+ const setPage = props.setPage
15
+
16
+ useEffect(() => {
17
+ // Constraint in bounds.
18
+ const clamped = Math.min(props.total - 1, Math.max(0, page))
19
+ if (page !== clamped) {
20
+ setPage(clamped)
21
+ }
22
+ }, [page, props.total, setPage])
23
+
24
+ return (
25
+ <>
26
+ <Button
27
+ icon={ChevronLeftIcon}
28
+ mode="bleed"
29
+ padding={3}
30
+ style={{cursor: 'pointer'}}
31
+ disabled={page <= 0}
32
+ onClick={() => {
33
+ setPage((page) => {
34
+ return Math.min(props.total - 1, Math.max(0, page - 1))
35
+ })
36
+ }}
37
+ />
38
+ <Label muted>
39
+ Page {page + 1}/{props.total}
40
+ </Label>
41
+ <Button
42
+ icon={ChevronRightIcon}
43
+ mode="bleed"
44
+ padding={3}
45
+ style={{cursor: 'pointer'}}
46
+ disabled={page >= props.total - 1}
47
+ onClick={() => {
48
+ setPage((page) => {
49
+ return Math.min(props.total - 1, Math.max(0, page + 1))
50
+ })
51
+ }}
52
+ />
53
+ </>
54
+ )
55
+ }
56
+
57
+ export default PageSelector
@@ -2,7 +2,7 @@ import {Card, Text} from '@sanity/ui'
2
2
  import React, {useEffect, useMemo, useRef} from 'react'
3
3
 
4
4
  import {useCancelUpload} from '../hooks/useCancelUpload'
5
- import type {MuxInputProps, VideoAssetDocument} from '../util/types'
5
+ import type {MuxInputProps, PluginConfig, VideoAssetDocument} from '../util/types'
6
6
  import {TopControls} from './Player.styled'
7
7
  import {UploadProgress} from './UploadProgress'
8
8
  import VideoPlayer from './VideoPlayer'
@@ -10,9 +10,10 @@ import VideoPlayer from './VideoPlayer'
10
10
  interface Props extends Pick<MuxInputProps, 'onChange' | 'readOnly'> {
11
11
  buttons?: React.ReactNode
12
12
  asset: VideoAssetDocument
13
+ config?: PluginConfig
13
14
  }
14
15
 
15
- const Player = ({asset, buttons, readOnly, onChange}: Props) => {
16
+ const Player = ({asset, buttons, readOnly, onChange, config}: Props) => {
16
17
  const isLoading = useMemo<boolean | string>(() => {
17
18
  if (asset?.status === 'preparing') {
18
19
  return 'Preparing the video'
@@ -91,7 +92,7 @@ const Player = ({asset, buttons, readOnly, onChange}: Props) => {
91
92
  }
92
93
 
93
94
  return (
94
- <VideoPlayer asset={asset}>
95
+ <VideoPlayer asset={asset} hlsConfig={config?.hlsConfig}>
95
96
  {buttons && <TopControls slot="top-chrome">{buttons}</TopControls>}
96
97
  {isPreparingStaticRenditions && (
97
98
  <Card
@@ -5,6 +5,7 @@ import {
5
5
  PlugIcon,
6
6
  ResetIcon,
7
7
  SearchIcon,
8
+ TranslateIcon,
8
9
  UploadIcon,
9
10
  } from '@sanity/icons'
10
11
  import {
@@ -25,11 +26,11 @@ import {memo, useCallback, useEffect, useMemo, useState} from 'react'
25
26
  import {PatchEvent, unset} from 'sanity'
26
27
  import {styled} from 'styled-components'
27
28
 
29
+ import {useAccessControl} from '../hooks/useAccessControl'
28
30
  import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
29
31
  import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
30
32
  import type {MuxInputProps, PluginConfig, VideoAssetDocument} from '../util/types'
31
33
  import {FileInputMenuItem} from './FileInputMenuItem'
32
- import {useAccessControl} from '../hooks/useAccessControl'
33
34
 
34
35
  const LockCard = styled(Card)`
35
36
  position: absolute;
@@ -57,9 +58,10 @@ function PlayerActionsMenu(
57
58
  dialogState: DialogState
58
59
  setDialogState: SetDialogState
59
60
  config: PluginConfig
61
+ accept: string
60
62
  }
61
63
  ) {
62
- const {asset, readOnly, dialogState, setDialogState, onChange, onSelect} = props
64
+ const {asset, readOnly, dialogState, setDialogState, onChange, onSelect, accept} = props
63
65
  const [open, setOpen] = useState(false)
64
66
  const [menuElement, setMenuRef] = useState<HTMLDivElement | null>(null)
65
67
  const isSigned = useMemo(() => getPlaybackPolicy(asset) === 'signed', [asset])
@@ -108,7 +110,7 @@ function PlayerActionsMenu(
108
110
  </Label>
109
111
  </Box>
110
112
  <FileInputMenuItem
111
- accept="video/*"
113
+ accept={accept}
112
114
  icon={UploadIcon}
113
115
  onSelect={onSelect}
114
116
  text="Upload"
@@ -121,11 +123,18 @@ function PlayerActionsMenu(
121
123
  onClick={() => setDialogState('select-video')}
122
124
  />
123
125
  {isVideoAsset(asset) && (
124
- <MenuItem
125
- icon={ImageIcon}
126
- text="Thumbnail"
127
- onClick={() => setDialogState('edit-thumbnail')}
128
- />
126
+ <>
127
+ <MenuItem
128
+ icon={ImageIcon}
129
+ text="Thumbnail"
130
+ onClick={() => setDialogState('edit-thumbnail')}
131
+ />
132
+ <MenuItem
133
+ icon={TranslateIcon}
134
+ text="Captions"
135
+ onClick={() => setDialogState('edit-captions')}
136
+ />
137
+ </>
129
138
  )}
130
139
  <MenuDivider />
131
140
  {hasConfigAccess && (