sanity-plugin-mux-input 2.2.3 → 2.3.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.
- package/LICENSE +1 -1
- package/README.md +154 -22
- package/lib/index.cjs +4002 -3669
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +210 -0
- package/lib/index.d.ts +109 -25
- package/lib/index.esm.js +4384 -0
- package/lib/index.esm.js.map +1 -0
- package/lib/index.js +3967 -3621
- package/lib/index.js.map +1 -1
- package/package.json +47 -51
- package/src/_exports/index.ts +32 -0
- package/src/actions/upload.ts +35 -40
- package/src/clients/upChunkObservable.ts +5 -1
- package/src/components/ConfigureApi.tsx +0 -1
- package/src/components/FileInputArea.tsx +92 -0
- package/src/components/FileInputButton.tsx +3 -2
- package/src/components/FileInputMenuItem.styled.tsx +2 -2
- package/src/components/FileInputMenuItem.tsx +2 -10
- package/src/components/ImportVideosFromMux.tsx +317 -0
- package/src/components/Input.tsx +3 -3
- package/src/components/PlayerActionsMenu.tsx +14 -12
- package/src/components/SelectAsset.tsx +1 -1
- package/src/components/StudioTool.tsx +11 -6
- package/src/components/TextTracksEditor.tsx +214 -0
- package/src/components/UploadConfiguration.tsx +390 -0
- package/src/components/UploadPlaceholder.tsx +41 -55
- package/src/components/Uploader.styled.tsx +0 -1
- package/src/components/Uploader.tsx +384 -0
- package/src/components/VideoDetails/DeleteDialog.tsx +20 -24
- package/src/components/VideoDetails/VideoDetails.tsx +8 -1
- package/src/components/VideoMetadata.tsx +4 -1
- package/src/components/VideoPlayer.tsx +33 -5
- package/src/components/VideoThumbnail.tsx +21 -7
- package/src/components/VideosBrowser.tsx +6 -3
- package/src/components/withFocusRing/withFocusRing.ts +20 -22
- package/src/hooks/useClient.ts +1 -1
- package/src/hooks/useImportMuxAssets.ts +127 -0
- package/src/hooks/useMuxAssets.ts +168 -0
- package/src/plugin.tsx +5 -5
- package/src/util/asserters.ts +9 -0
- package/src/util/createSearchFilter.ts +5 -7
- package/src/util/formatBytes.ts +32 -0
- package/src/util/generateJwt.ts +7 -6
- package/src/util/getAnimatedPosterSrc.ts +1 -1
- package/src/util/getPlaybackId.ts +1 -1
- package/src/util/getPlaybackPolicy.ts +1 -1
- package/src/util/getVideoMetadata.ts +2 -1
- package/src/util/parsers.ts +5 -0
- package/src/util/types.ts +195 -12
- package/lib/index.cjs.js +0 -5
- package/src/components/__legacy__Uploader.tsx +0 -280
- 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
|
+
}
|
package/src/components/Input.tsx
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
102
|
-
onSelect={onUpload}
|
|
103
|
+
onSelect={onSelect}
|
|
103
104
|
text="Upload"
|
|
104
105
|
disabled={readOnly}
|
|
105
|
-
fontSize={
|
|
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={
|
|
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 {
|
|
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<
|
|
8
|
+
const StudioTool: React.FC<PluginConfig> = () => {
|
|
9
9
|
return <VideosBrowser />
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export
|
|
13
|
-
|
|
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
|
-
|
|
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
|
+
}
|