sanity-plugin-mux-input 2.2.4 → 2.3.1

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 (49) hide show
  1. package/README.md +148 -16
  2. package/lib/index.cjs +3996 -3677
  3. package/lib/index.cjs.map +1 -1
  4. package/lib/index.d.cts +210 -0
  5. package/lib/index.d.ts +109 -25
  6. package/lib/index.esm.js +4390 -0
  7. package/lib/index.esm.js.map +1 -0
  8. package/lib/index.js +3964 -3626
  9. package/lib/index.js.map +1 -1
  10. package/package.json +48 -52
  11. package/src/_exports/index.ts +32 -0
  12. package/src/actions/upload.ts +35 -40
  13. package/src/clients/upChunkObservable.ts +5 -1
  14. package/src/components/ConfigureApi.tsx +0 -1
  15. package/src/components/FileInputArea.tsx +92 -0
  16. package/src/components/FileInputButton.tsx +3 -2
  17. package/src/components/FileInputMenuItem.styled.tsx +2 -2
  18. package/src/components/FileInputMenuItem.tsx +2 -10
  19. package/src/components/ImportVideosFromMux.tsx +317 -0
  20. package/src/components/Input.tsx +3 -3
  21. package/src/components/PlayerActionsMenu.tsx +14 -12
  22. package/src/components/SelectAsset.tsx +1 -1
  23. package/src/components/StudioTool.tsx +11 -6
  24. package/src/components/TextTracksEditor.tsx +214 -0
  25. package/src/components/UploadConfiguration.tsx +390 -0
  26. package/src/components/UploadPlaceholder.tsx +41 -55
  27. package/src/components/Uploader.styled.tsx +0 -1
  28. package/src/components/Uploader.tsx +384 -0
  29. package/src/components/VideoDetails/DeleteDialog.tsx +20 -24
  30. package/src/components/VideoPlayer.tsx +33 -5
  31. package/src/components/VideoThumbnail.tsx +21 -7
  32. package/src/components/VideosBrowser.tsx +6 -3
  33. package/src/components/withFocusRing/withFocusRing.ts +20 -22
  34. package/src/hooks/useClient.ts +1 -1
  35. package/src/hooks/useImportMuxAssets.ts +127 -0
  36. package/src/hooks/useMuxAssets.ts +168 -0
  37. package/src/plugin.tsx +5 -5
  38. package/src/util/asserters.ts +9 -0
  39. package/src/util/createSearchFilter.ts +1 -1
  40. package/src/util/formatBytes.ts +32 -0
  41. package/src/util/generateJwt.ts +1 -0
  42. package/src/util/getAnimatedPosterSrc.ts +1 -1
  43. package/src/util/getPlaybackId.ts +1 -1
  44. package/src/util/getPlaybackPolicy.ts +1 -1
  45. package/src/util/parsers.ts +5 -0
  46. package/src/util/types.ts +195 -12
  47. package/lib/index.cjs.js +0 -5
  48. package/src/components/__legacy__Uploader.tsx +0 -280
  49. package/src/index.ts +0 -29
@@ -0,0 +1,317 @@
1
+ import {CheckmarkCircleIcon, ErrorOutlineIcon, RetrieveIcon, RetryIcon} from '@sanity/icons'
2
+ import {
3
+ Box,
4
+ Button,
5
+ Card,
6
+ Checkbox,
7
+ Code,
8
+ Dialog,
9
+ Flex,
10
+ Heading,
11
+ Spinner,
12
+ Stack,
13
+ Text,
14
+ } from '@sanity/ui'
15
+ import {truncateString, useFormattedDuration} from 'sanity'
16
+ import styled from 'styled-components'
17
+
18
+ import useImportMuxAssets from '../hooks/useImportMuxAssets'
19
+ import {DIALOGS_Z_INDEX} from '../util/constants'
20
+ import type {MuxAsset} from '../util/types'
21
+ import VideoThumbnail from './VideoThumbnail'
22
+
23
+ const MissingAssetCheckbox = styled(Checkbox)`
24
+ position: static !important;
25
+
26
+ input::after {
27
+ content: '';
28
+ position: absolute;
29
+ inset: 0;
30
+ display: block;
31
+ cursor: pointer;
32
+ z-index: 1000;
33
+ }
34
+ `
35
+
36
+ function MissingAsset({
37
+ asset,
38
+ selectAsset,
39
+ selected,
40
+ }: {
41
+ asset: MuxAsset
42
+ selectAsset: (selected: boolean) => void
43
+ selected: boolean
44
+ }) {
45
+ const duration = useFormattedDuration(asset.duration * 1000)
46
+
47
+ return (
48
+ <Card
49
+ key={asset.id}
50
+ tone={selected ? 'positive' : undefined}
51
+ border
52
+ paddingX={2}
53
+ paddingY={3}
54
+ style={{position: 'relative'}}
55
+ radius={1}
56
+ >
57
+ <Flex align="center" gap={2}>
58
+ <MissingAssetCheckbox
59
+ checked={selected}
60
+ onChange={(e) => {
61
+ selectAsset(e.currentTarget.checked)
62
+ }}
63
+ aria-label={selected ? `Import video ${asset.id}` : `Skip import of video ${asset.id}`}
64
+ />
65
+ <VideoThumbnail
66
+ asset={{
67
+ assetId: asset.id,
68
+ data: asset,
69
+ filename: asset.id,
70
+ playbackId: asset.playback_ids.find((p) => p.id)?.id,
71
+ }}
72
+ width={150}
73
+ />
74
+ <Stack space={2}>
75
+ <Flex align="center" gap={1}>
76
+ <Code size={2}>{truncateString(asset.id, 15)}</Code>{' '}
77
+ <Text muted size={2}>
78
+ ({duration.formatted})
79
+ </Text>
80
+ </Flex>
81
+ <Text size={1}>
82
+ Uploaded at{' '}
83
+ {new Date(Number(asset.created_at) * 1000).toLocaleDateString('en', {
84
+ year: 'numeric',
85
+ day: '2-digit',
86
+ month: '2-digit',
87
+ })}
88
+ </Text>
89
+ </Stack>
90
+ </Flex>
91
+ </Card>
92
+ )
93
+ }
94
+
95
+ // eslint-disable-next-line complexity
96
+ function ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {
97
+ const {importState} = props
98
+
99
+ const canTriggerImport =
100
+ (importState === 'idle' || importState === 'error') && props.selectedAssets.length > 0
101
+ const isImporting = importState === 'importing'
102
+ const noAssetsToImport =
103
+ props.missingAssets?.length === 0 && !props.muxAssets.loading && !props.assetsInSanityLoading
104
+
105
+ return (
106
+ <Dialog
107
+ header={'Import videos from Mux'}
108
+ zOffset={DIALOGS_Z_INDEX}
109
+ id="video-details-dialog"
110
+ onClose={props.closeDialog}
111
+ onClickOutside={props.closeDialog}
112
+ width={1}
113
+ position="fixed"
114
+ footer={
115
+ importState !== 'done' &&
116
+ !noAssetsToImport && (
117
+ <Card padding={3}>
118
+ <Flex justify="space-between" align="center">
119
+ <Button
120
+ fontSize={2}
121
+ padding={3}
122
+ mode="bleed"
123
+ text="Cancel"
124
+ tone="critical"
125
+ onClick={props.closeDialog}
126
+ disabled={isImporting}
127
+ />
128
+ {props.missingAssets && (
129
+ <Button
130
+ icon={RetrieveIcon}
131
+ fontSize={2}
132
+ padding={3}
133
+ mode="ghost"
134
+ text={
135
+ props.selectedAssets?.length > 0
136
+ ? `Import ${props.selectedAssets.length} video(s)`
137
+ : 'No video(s) selected'
138
+ }
139
+ tone="positive"
140
+ onClick={props.importAssets}
141
+ iconRight={isImporting && Spinner}
142
+ disabled={!canTriggerImport}
143
+ />
144
+ )}
145
+ </Flex>
146
+ </Card>
147
+ )
148
+ }
149
+ >
150
+ <Box padding={3}>
151
+ {/* LOADING ASSETS STATE */}
152
+ {(props.muxAssets.loading || props.assetsInSanityLoading) && (
153
+ <Card tone="primary" marginBottom={5} padding={3} border>
154
+ <Flex align="center" gap={4}>
155
+ <Spinner muted size={4} />
156
+ <Stack space={2}>
157
+ <Text size={2} weight="semibold">
158
+ Loading assets from Mux
159
+ </Text>
160
+ <Text size={1}>
161
+ This may take a while.
162
+ {props.missingAssets &&
163
+ props.missingAssets.length > 0 &&
164
+ ` There are at least ${props.missingAssets.length} video${props.missingAssets.length > 1 ? 's' : ''} currently not in Sanity...`}
165
+ </Text>
166
+ </Stack>
167
+ </Flex>
168
+ </Card>
169
+ )}
170
+
171
+ {/* ERROR LOADING MUX */}
172
+ {props.muxAssets.error && (
173
+ <Card tone="critical" marginBottom={5} padding={3} border>
174
+ <Flex align="center" gap={2}>
175
+ <ErrorOutlineIcon fontSize={36} />
176
+ <Stack space={2}>
177
+ <Text size={2} weight="semibold">
178
+ There was an error getting all data from Mux
179
+ </Text>
180
+ <Text size={1}>
181
+ {props.missingAssets
182
+ ? `But we've found ${props.missingAssets.length} video${props.missingAssets.length > 1 ? 's' : ''} not in Sanity, which you can start importing now.`
183
+ : 'Please try again or contact a developer for help.'}
184
+ </Text>
185
+ </Stack>
186
+ </Flex>
187
+ </Card>
188
+ )}
189
+
190
+ {/* IMPORTING STATE */}
191
+ {importState === 'importing' && (
192
+ <Card tone="primary" marginBottom={5} padding={3} border>
193
+ <Flex align="center" gap={4}>
194
+ <Spinner muted size={4} />
195
+ <Stack space={2}>
196
+ <Text size={2} weight="semibold">
197
+ Importing {props.selectedAssets.length} video
198
+ {props.selectedAssets.length > 1 && 's'} from Mux
199
+ </Text>
200
+ </Stack>
201
+ </Flex>
202
+ </Card>
203
+ )}
204
+
205
+ {/* ERROR IMPORTING */}
206
+ {importState === 'error' && (
207
+ <Card tone="critical" marginBottom={5} padding={3} border>
208
+ <Flex align="center" gap={2}>
209
+ <ErrorOutlineIcon fontSize={36} />
210
+ <Stack space={2}>
211
+ <Text size={2} weight="semibold">
212
+ There was an error importing videos
213
+ </Text>
214
+ <Text size={1}>
215
+ {props.importError
216
+ ? `Error: ${props.importError}`
217
+ : 'Please try again or contact a developer for help.'}
218
+ </Text>
219
+ <Box marginTop={1}>
220
+ <Button
221
+ icon={RetryIcon}
222
+ text="Retry"
223
+ tone="primary"
224
+ onClick={props.importAssets}
225
+ />
226
+ </Box>
227
+ </Stack>
228
+ </Flex>
229
+ </Card>
230
+ )}
231
+
232
+ {/* NO ASSETS TO IMPORT or SUCESS STATE */}
233
+ {(noAssetsToImport || importState === 'done') && (
234
+ <Stack paddingY={5} marginBottom={4} space={3} style={{textAlign: 'center'}}>
235
+ <Box>
236
+ <CheckmarkCircleIcon fontSize={48} />
237
+ </Box>
238
+ <Heading size={2}>
239
+ {importState === 'done'
240
+ ? `Videos imported successfully`
241
+ : 'There are no Mux videos to import'}
242
+ </Heading>
243
+ <Text size={2}>
244
+ {importState === 'done'
245
+ ? 'You can now use them in your Sanity content.'
246
+ : "They're all in Sanity and ready to be used in your content."}
247
+ </Text>
248
+ </Stack>
249
+ )}
250
+
251
+ {/* MISSING ASSETS SELECTOR */}
252
+ {props.missingAssets &&
253
+ props.missingAssets.length > 0 &&
254
+ (importState === 'idle' || importState === 'error') && (
255
+ <Stack space={4}>
256
+ <Heading size={1}>
257
+ There are {props.missingAssets.length}
258
+ {props.muxAssets.loading && '+'} Mux video{props.missingAssets.length > 1 && 's'}{' '}
259
+ not in Sanity
260
+ </Heading>
261
+ {!props.muxAssets.loading && (
262
+ <Flex align="center" paddingX={2}>
263
+ <Checkbox
264
+ id="import-all"
265
+ style={{display: 'block'}}
266
+ onClick={(e) => {
267
+ const selectAll = e.currentTarget.checked
268
+ if (selectAll) {
269
+ // eslint-disable-next-line no-unused-expressions
270
+ props.missingAssets && props.setSelectedAssets(props.missingAssets)
271
+ } else {
272
+ props.setSelectedAssets([])
273
+ }
274
+ }}
275
+ checked={props.selectedAssets.length === props.missingAssets.length}
276
+ />
277
+ <Box flex={1} paddingLeft={3} as="label" htmlFor="import-all">
278
+ <Text>Import all</Text>
279
+ </Box>
280
+ </Flex>
281
+ )}
282
+ {props.missingAssets.map((asset) => (
283
+ <MissingAsset
284
+ key={asset.id}
285
+ asset={asset}
286
+ selectAsset={(selected) => {
287
+ if (selected) {
288
+ props.setSelectedAssets([...props.selectedAssets, asset])
289
+ } else {
290
+ props.setSelectedAssets(props.selectedAssets.filter((a) => a.id !== asset.id))
291
+ }
292
+ }}
293
+ selected={props.selectedAssets.some((a) => a.id === asset.id)}
294
+ />
295
+ ))}
296
+ </Stack>
297
+ )}
298
+ </Box>
299
+ </Dialog>
300
+ )
301
+ }
302
+
303
+ export default function ImportVideosFromMux() {
304
+ const importAssets = useImportMuxAssets()
305
+
306
+ if (!importAssets.hasSecrets) {
307
+ return
308
+ }
309
+
310
+ if (importAssets.dialogOpen) {
311
+ // eslint-disable-next-line consistent-return
312
+ return <ImportVideosDialog {...importAssets} />
313
+ }
314
+
315
+ // eslint-disable-next-line consistent-return
316
+ return <Button mode="bleed" text="Import from Mux" onClick={importAssets.openDialog} />
317
+ }
@@ -6,15 +6,15 @@ import {useClient} from '../hooks/useClient'
6
6
  import {useDialogState} from '../hooks/useDialogState'
7
7
  import {useMuxPolling} from '../hooks/useMuxPolling'
8
8
  import {useSecretsDocumentValues} from '../hooks/useSecretsDocumentValues'
9
- import type {Config, MuxInputProps} from '../util/types'
10
- import Uploader from './__legacy__Uploader'
9
+ import type {MuxInputProps, PluginConfig} from '../util/types'
11
10
  import ConfigureApi from './ConfigureApi'
12
11
  import ErrorBoundaryCard from './ErrorBoundaryCard'
13
12
  import {InputFallback} from './Input.styled'
14
13
  import Onboard from './Onboard'
14
+ import Uploader from './Uploader'
15
15
 
16
16
  export interface InputProps extends MuxInputProps {
17
- config: Config
17
+ config: PluginConfig
18
18
  }
19
19
  const Input = (props: InputProps) => {
20
20
  const client = useClient()
@@ -1,4 +1,5 @@
1
1
  import {
2
+ EllipsisHorizontalIcon,
2
3
  EllipsisVerticalIcon,
3
4
  LockIcon,
4
5
  PlugIcon,
@@ -43,14 +44,15 @@ const LockButton = styled(Button)`
43
44
  color: white;
44
45
  `
45
46
 
46
- export interface Props extends Pick<MuxInputProps, 'onChange' | 'readOnly'> {
47
- asset: VideoAssetDocument
48
- onUpload: (files: File[]) => void
49
- dialogState: DialogState
50
- setDialogState: SetDialogState
51
- }
52
- function PlayerActionsMenu(props: Props) {
53
- const {asset, readOnly, dialogState, setDialogState, onChange, onUpload} = props
47
+ function PlayerActionsMenu(
48
+ props: Pick<MuxInputProps, 'onChange' | 'readOnly'> & {
49
+ asset: VideoAssetDocument
50
+ onSelect: (files: File[]) => void
51
+ dialogState: DialogState
52
+ setDialogState: SetDialogState
53
+ }
54
+ ) {
55
+ const {asset, readOnly, dialogState, setDialogState, onChange, onSelect} = props
54
56
  const [open, setOpen] = useState(false)
55
57
  const [menuElement, setMenuRef] = useState<HTMLDivElement | null>(null)
56
58
  const isSigned = useMemo(() => getPlaybackPolicy(asset) === 'signed', [asset])
@@ -98,11 +100,10 @@ function PlayerActionsMenu(props: Props) {
98
100
  <FileInputMenuItem
99
101
  accept="video/*"
100
102
  icon={UploadIcon}
101
- mode="bleed"
102
- onSelect={onUpload}
103
+ onSelect={onSelect}
103
104
  text="Upload"
104
105
  disabled={readOnly}
105
- fontSize={2}
106
+ fontSize={1}
106
107
  />
107
108
  <MenuItem
108
109
  icon={SearchIcon}
@@ -129,8 +130,9 @@ function PlayerActionsMenu(props: Props) {
129
130
  open={open}
130
131
  >
131
132
  <Button
132
- icon={EllipsisVerticalIcon}
133
+ icon={EllipsisHorizontalIcon}
133
134
  mode="ghost"
135
+ fontSize={1}
134
136
  onClick={() => {
135
137
  setDialogState(false)
136
138
  setOpen(true)
@@ -19,7 +19,7 @@ export default function SelectAssets({asset: selectedAsset, onChange, setDialogS
19
19
  if (chosenAsset._id !== selectedAsset?._id) {
20
20
  onChange(
21
21
  PatchEvent.from([
22
- setIfMissing({asset: {}}),
22
+ setIfMissing({asset: {}, _type: 'mux.video'}),
23
23
  set({_type: 'reference', _weak: true, _ref: chosenAsset._id}, ['asset']),
24
24
  ])
25
25
  )
@@ -1,20 +1,25 @@
1
1
  import React from 'react'
2
2
  import {Tool} from 'sanity'
3
3
 
4
- import {Config} from '../util/types'
4
+ import {PluginConfig} from '../util/types'
5
5
  import ToolIcon from './icons/ToolIcon'
6
6
  import VideosBrowser from './VideosBrowser'
7
7
 
8
- const StudioTool: React.FC<Config> = () => {
8
+ const StudioTool: React.FC<PluginConfig> = () => {
9
9
  return <VideosBrowser />
10
10
  }
11
11
 
12
- export default function createStudioTool(config: Config): Tool {
13
- const toolConfig = typeof config.tool === 'object' ? config.tool : {}
12
+ export const DEFAULT_TOOL_CONFIG = {
13
+ icon: ToolIcon,
14
+ title: 'Videos',
15
+ }
16
+
17
+ export default function createStudioTool(config: PluginConfig): Tool {
18
+ const toolConfig = typeof config.tool === 'object' ? config.tool : DEFAULT_TOOL_CONFIG
14
19
  return {
15
20
  name: 'mux',
16
- title: toolConfig.title || 'Videos',
21
+ icon: toolConfig.icon || DEFAULT_TOOL_CONFIG.icon,
22
+ title: toolConfig.title || DEFAULT_TOOL_CONFIG.title,
17
23
  component: (props: any) => <StudioTool {...config} {...props} />,
18
- icon: toolConfig.icon || ToolIcon,
19
24
  }
20
25
  }
@@ -0,0 +1,214 @@
1
+ import {AddIcon, DocumentTextIcon, ResetIcon, TranslateIcon, TrashIcon} from '@sanity/icons'
2
+ import {Autocomplete, Button, Card, Code, Flex, Radio, Stack, Text} from '@sanity/ui'
3
+ import LanguagesList from 'iso-639-1'
4
+ import {Dispatch} from 'react'
5
+
6
+ import {uuid} from '@sanity/uuid'
7
+ import {FormField} from 'sanity'
8
+ import {SUPPORTED_MUX_LANGUAGES, UploadTextTrack, isCustomTextTrack} from '../util/types'
9
+ import FileInputArea from './FileInputArea'
10
+
11
+ const ALL_LANGUAGE_CODES = LanguagesList.getAllCodes().map((code) => ({
12
+ value: code,
13
+ label: LanguagesList.getNativeName(code),
14
+ }))
15
+
16
+ const SUBTITLE_LANGUAGES: Record<
17
+ Extract<UploadTextTrack, {language_code: any}>['type'],
18
+ {value: string; label: string}[]
19
+ > = {
20
+ autogenerated: SUPPORTED_MUX_LANGUAGES.map((lang) => ({
21
+ value: lang.code,
22
+ label: lang.label,
23
+ })),
24
+ subtitles: ALL_LANGUAGE_CODES,
25
+ captions: ALL_LANGUAGE_CODES,
26
+ }
27
+
28
+ /**
29
+ * Subtitles and Captions are uploaded via .srt and .vtt files, which we can't currently support
30
+ * due to the lack of a server to receive Mux's requests to these files' URLs.
31
+ *
32
+ * For now, only auto-generated subtitles are supported.
33
+ */
34
+ const TRACK_TYPES = [
35
+ {value: 'autogenerated', label: 'Auto-generated Subtitles'},
36
+ // {value: 'subtitles', label: 'Subtitles'},
37
+ // {value: 'captions', label: 'Closed Captions'},
38
+ ] as const
39
+
40
+ type TrackSubAction =
41
+ | {subAction: 'add'}
42
+ | {subAction: 'update'; value: Partial<UploadTextTrack>}
43
+ | {subAction: 'delete'}
44
+
45
+ export type TrackAction = {action: 'track'; id: string} & TrackSubAction
46
+
47
+ /**
48
+ * Handles editing of a single text track, dispatching actions back to the
49
+ * parent UploadConfiguration state object for changing internal state.
50
+ */
51
+ function TrackEditor({
52
+ canAutoGenerate,
53
+ track,
54
+ dispatch,
55
+ }: {
56
+ canAutoGenerate: boolean
57
+ track: Partial<UploadTextTrack> & {_id: string}
58
+ dispatch: Dispatch<TrackAction>
59
+ }) {
60
+ const {_id: id, type} = track
61
+ const dispatchTrackAction = (args: TrackSubAction) => dispatch({action: 'track', id, ...args})
62
+
63
+ const trackTypes = TRACK_TYPES.filter(
64
+ ({value}) => !(value === 'autogenerated' && !canAutoGenerate)
65
+ )
66
+
67
+ if (trackTypes.length === 0) return null
68
+
69
+ return (
70
+ <Card border padding={3} radius={2} style={{position: 'relative'}}>
71
+ <Stack space={3}>
72
+ {trackTypes.length > 1 && (
73
+ <FormField title="Auto-generated subtitles">
74
+ <Flex gap={3}>
75
+ {trackTypes.map(({value, label}) => {
76
+ const inputId = `${id}--type-${value}`
77
+ return (
78
+ <Flex key={value} align="center" gap={2}>
79
+ <Radio
80
+ checked={type === value}
81
+ name="track-type"
82
+ onChange={(e) =>
83
+ dispatchTrackAction({
84
+ subAction: 'update',
85
+ value: {
86
+ type: e.currentTarget.value as UploadTextTrack['type'],
87
+ },
88
+ })
89
+ }
90
+ value={value}
91
+ id={inputId}
92
+ />
93
+ <Text as="label" htmlFor={inputId}>
94
+ {label}
95
+ </Text>
96
+ </Flex>
97
+ )
98
+ })}
99
+ </Flex>
100
+ </FormField>
101
+ )}
102
+
103
+ <Autocomplete
104
+ id={`${id}--language`}
105
+ value={track.language_code}
106
+ onChange={(newValue) =>
107
+ dispatchTrackAction({
108
+ subAction: 'update',
109
+ value: {
110
+ language_code: newValue,
111
+ name: LanguagesList.getNativeName(newValue),
112
+ },
113
+ })
114
+ }
115
+ options={SUBTITLE_LANGUAGES[track.type!]}
116
+ icon={TranslateIcon}
117
+ placeholder="Select language"
118
+ filterOption={(query, option) =>
119
+ option.label.toLowerCase().indexOf(query.toLowerCase()) > -1 ||
120
+ option.value.toLowerCase().indexOf(query.toLowerCase()) > -1
121
+ }
122
+ openButton
123
+ renderValue={(value) =>
124
+ SUBTITLE_LANGUAGES[track.type!].find((l) => l.value === value)?.label || value
125
+ }
126
+ renderOption={(option) => (
127
+ <Card data-as="button" padding={3} radius={2} tone="inherit">
128
+ <Text size={2} textOverflow="ellipsis">
129
+ {option.label} ({option.value})
130
+ </Text>
131
+ </Card>
132
+ )}
133
+ />
134
+
135
+ <Flex>
136
+ <Button
137
+ icon={TrashIcon}
138
+ tone="critical"
139
+ mode="ghost"
140
+ onClick={() => dispatchTrackAction({subAction: 'delete'})}
141
+ text="Delete"
142
+ />
143
+ </Flex>
144
+ </Stack>
145
+ </Card>
146
+ )
147
+ }
148
+
149
+ export default function TextTracksEditor({
150
+ canAutoGenerate,
151
+ tracks,
152
+ dispatch,
153
+ }: {
154
+ canAutoGenerate: boolean
155
+ tracks: (Partial<UploadTextTrack> & {_id: string})[]
156
+ dispatch: Dispatch<TrackAction>
157
+ }) {
158
+ const trackTypes = TRACK_TYPES.filter(
159
+ ({value}) => !(value === 'autogenerated' && !canAutoGenerate)
160
+ )
161
+
162
+ if (trackTypes.length === 0) return null
163
+
164
+ return (
165
+ <FormField
166
+ title="Captions & Subtitles"
167
+ description="Provide text tracks for video accessibility."
168
+ >
169
+ <Stack space={2}>
170
+ {tracks.map((track) => (
171
+ <TrackEditor
172
+ key={track._id}
173
+ canAutoGenerate={canAutoGenerate}
174
+ track={track}
175
+ dispatch={dispatch}
176
+ />
177
+ ))}
178
+ <Button
179
+ icon={AddIcon}
180
+ onClick={() => dispatch({action: 'track', id: uuid(), subAction: 'add'})}
181
+ text="New caption/subtitle"
182
+ mode="ghost"
183
+ />
184
+ </Stack>
185
+ </FormField>
186
+ )
187
+ }
188
+
189
+ function getFileTextContents(file: File) {
190
+ return new Promise<string>((resolve, reject) => {
191
+ const reader = new FileReader()
192
+
193
+ reader.onload = () => {
194
+ if (typeof reader.result === 'string') {
195
+ resolve(reader.result)
196
+ } else {
197
+ reject(new Error('Could not read file'))
198
+ }
199
+ }
200
+
201
+ reader.onerror = reject
202
+
203
+ reader.readAsText(file)
204
+ })
205
+ }
206
+
207
+ async function fileToTrackFile(file: File) {
208
+ return {
209
+ name: file.name,
210
+ size: file.size,
211
+ type: file.type,
212
+ contents: await getFileTextContents(file),
213
+ }
214
+ }