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.
- package/LICENSE +1 -1
- package/README.md +133 -0
- package/dist/index.d.mts +63 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +1564 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1565 -154
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/_exports/index.ts +1 -0
- package/src/actions/assets.ts +75 -0
- package/src/components/AddCaptionDialog.tsx +421 -0
- package/src/components/CaptionsDialog.tsx +23 -0
- package/src/components/EditCaptionDialog.tsx +508 -0
- package/src/components/FileInputButton.tsx +2 -2
- package/src/components/Onboard.tsx +2 -2
- package/src/components/PageSelector.tsx +57 -0
- package/src/components/Player.tsx +4 -3
- package/src/components/PlayerActionsMenu.tsx +17 -8
- package/src/components/TextTracksManager.tsx +781 -0
- package/src/components/UploadConfiguration.tsx +181 -4
- package/src/components/UploadPlaceholder.tsx +14 -6
- package/src/components/Uploader.styled.tsx +8 -15
- package/src/components/Uploader.tsx +61 -6
- package/src/components/VideoDetails/VideoDetails.tsx +16 -0
- package/src/components/VideoInBrowser.tsx +2 -7
- package/src/components/VideoPlayer.tsx +35 -6
- package/src/components/VideosBrowser.tsx +9 -1
- package/src/components/icons/Audio.tsx +13 -0
- package/src/hooks/useAccessControl.ts +1 -0
- package/src/hooks/useDialogState.ts +1 -1
- package/src/util/getVideoMetadata.ts +3 -1
- package/src/util/textTracks.ts +219 -0
- package/src/util/types.ts +56 -3
- package/src/components/FileInputArea.tsx +0 -93
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {DocumentVideoIcon, UploadIcon} from '@sanity/icons'
|
|
1
|
+
import {DocumentVideoIcon, ErrorOutlineIcon, UploadIcon} from '@sanity/icons'
|
|
2
2
|
import {Box, Button, Card, Checkbox, Dialog, Flex, Label, Radio, Stack, Text} from '@sanity/ui'
|
|
3
3
|
import {uuid} from '@sanity/uuid'
|
|
4
4
|
import LanguagesList from 'iso-639-1'
|
|
@@ -6,6 +6,7 @@ import {useEffect, useId, useMemo, useReducer, useRef, useState} from 'react'
|
|
|
6
6
|
import {FormField} from 'sanity'
|
|
7
7
|
|
|
8
8
|
import formatBytes from '../util/formatBytes'
|
|
9
|
+
import {formatSeconds} from '../util/formatSeconds'
|
|
9
10
|
import {
|
|
10
11
|
type AutogeneratedTextTrack,
|
|
11
12
|
type CustomTextTrack,
|
|
@@ -190,6 +191,138 @@ export default function UploadConfiguration({
|
|
|
190
191
|
isAdvancedMode ? 'advanced' : 'standard'
|
|
191
192
|
)
|
|
192
193
|
|
|
194
|
+
// Video validations
|
|
195
|
+
const [videoDuration, setVideoDuration] = useState<number | null>(null)
|
|
196
|
+
const [urlFileSize, setUrlFileSize] = useState<number | null>(null)
|
|
197
|
+
const [isLoadingDuration, setIsLoadingDuration] = useState(false)
|
|
198
|
+
const [isLoadingFileSize, setIsLoadingFileSize] = useState(false)
|
|
199
|
+
const [validationError, setValidationError] = useState<string | null>(null)
|
|
200
|
+
const [canSkipFileSizeValidation, setCanSkipFileSizeValidation] = useState(false)
|
|
201
|
+
|
|
202
|
+
const MAX_FILE_SIZE = pluginConfig.maxAssetFileSize
|
|
203
|
+
const MAX_DURATION_SECONDS = pluginConfig.maxAssetDuration
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
setVideoDuration(null)
|
|
207
|
+
setUrlFileSize(null)
|
|
208
|
+
setIsLoadingDuration(false)
|
|
209
|
+
setIsLoadingFileSize(false)
|
|
210
|
+
setValidationError(null)
|
|
211
|
+
setCanSkipFileSizeValidation(false)
|
|
212
|
+
|
|
213
|
+
let videoElement: HTMLVideoElement | null = null
|
|
214
|
+
let currentVideoSrc: string | null = null
|
|
215
|
+
|
|
216
|
+
const cleanupVideo = (shouldRevokeUrl: boolean) => {
|
|
217
|
+
if (videoElement) {
|
|
218
|
+
videoElement.onloadedmetadata = null
|
|
219
|
+
videoElement.onerror = null
|
|
220
|
+
videoElement.src = ''
|
|
221
|
+
videoElement.load()
|
|
222
|
+
videoElement = null
|
|
223
|
+
}
|
|
224
|
+
if (shouldRevokeUrl && currentVideoSrc?.startsWith('blob:')) {
|
|
225
|
+
URL.revokeObjectURL(currentVideoSrc)
|
|
226
|
+
}
|
|
227
|
+
currentVideoSrc = null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const validateDuration = (videoSrc: string, shouldRevokeUrl = false) => {
|
|
231
|
+
if (!MAX_DURATION_SECONDS || MAX_DURATION_SECONDS <= 0) return
|
|
232
|
+
|
|
233
|
+
setIsLoadingDuration(true)
|
|
234
|
+
videoElement = document.createElement('video')
|
|
235
|
+
videoElement.preload = 'metadata'
|
|
236
|
+
currentVideoSrc = videoSrc
|
|
237
|
+
|
|
238
|
+
videoElement.onloadedmetadata = () => {
|
|
239
|
+
const duration = videoElement!.duration
|
|
240
|
+
setVideoDuration(duration)
|
|
241
|
+
setIsLoadingDuration(false)
|
|
242
|
+
|
|
243
|
+
if (duration > MAX_DURATION_SECONDS) {
|
|
244
|
+
setValidationError(
|
|
245
|
+
`Video duration (${formatSeconds(duration)}) exceeds maximum allowed duration of ${formatSeconds(MAX_DURATION_SECONDS)}`
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
cleanupVideo(shouldRevokeUrl)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
videoElement.onerror = () => {
|
|
253
|
+
setIsLoadingDuration(false)
|
|
254
|
+
console.warn('Could not read video metadata for validation')
|
|
255
|
+
cleanupVideo(shouldRevokeUrl)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
videoElement.src = videoSrc
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const validateFileSize = (size: number): boolean => {
|
|
262
|
+
if (MAX_FILE_SIZE === undefined || size <= MAX_FILE_SIZE) {
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
setValidationError(
|
|
267
|
+
`File size (${formatBytes(size)}) exceeds maximum allowed size of ${formatBytes(MAX_FILE_SIZE)}`
|
|
268
|
+
)
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Validate file uploads
|
|
273
|
+
if (stagedUpload.type === 'file') {
|
|
274
|
+
const file = stagedUpload.files[0]
|
|
275
|
+
if (validateFileSize(file.size)) {
|
|
276
|
+
validateDuration(URL.createObjectURL(file), true)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Validate URL uploads
|
|
281
|
+
if (stagedUpload.type === 'url') {
|
|
282
|
+
const url = stagedUpload.url
|
|
283
|
+
|
|
284
|
+
// Get file size from URL
|
|
285
|
+
const fetchFileSize = async () => {
|
|
286
|
+
setIsLoadingFileSize(true)
|
|
287
|
+
try {
|
|
288
|
+
const response = await fetch(url, {method: 'HEAD'})
|
|
289
|
+
const contentLength = response.headers.get('content-length')
|
|
290
|
+
const fileSize = contentLength ? parseInt(contentLength, 10) : null
|
|
291
|
+
|
|
292
|
+
setIsLoadingFileSize(false)
|
|
293
|
+
if (fileSize) {
|
|
294
|
+
setUrlFileSize(fileSize)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Validate file size if limit is configured and size is available
|
|
298
|
+
const shouldValidateDuration =
|
|
299
|
+
MAX_FILE_SIZE === undefined || fileSize === null || validateFileSize(fileSize)
|
|
300
|
+
|
|
301
|
+
if (fileSize === null && MAX_FILE_SIZE !== undefined) {
|
|
302
|
+
// Size unknown but size limit is configured - skip file size validation
|
|
303
|
+
setCanSkipFileSizeValidation(true)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (shouldValidateDuration) {
|
|
307
|
+
validateDuration(url)
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
setIsLoadingFileSize(false)
|
|
311
|
+
console.warn('Could not validate file size from URL')
|
|
312
|
+
// Skip validation of file size, but still validate duration
|
|
313
|
+
setCanSkipFileSizeValidation(true)
|
|
314
|
+
validateDuration(url)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fetchFileSize()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return () => {
|
|
322
|
+
cleanupVideo(true)
|
|
323
|
+
}
|
|
324
|
+
}, [stagedUpload, MAX_FILE_SIZE, MAX_DURATION_SECONDS])
|
|
325
|
+
|
|
193
326
|
// Helper to toggle a rendition
|
|
194
327
|
const toggleRendition = (rendition: StaticRenditionResolution) => {
|
|
195
328
|
const current = config.static_renditions
|
|
@@ -252,6 +385,19 @@ export default function UploadConfiguration({
|
|
|
252
385
|
onClose={onClose}
|
|
253
386
|
>
|
|
254
387
|
<Stack padding={4} space={2}>
|
|
388
|
+
{validationError && (
|
|
389
|
+
<Card padding={3} tone="critical" radius={2} marginBottom={2}>
|
|
390
|
+
<Flex gap={2} align="flex-start">
|
|
391
|
+
<ErrorOutlineIcon width={20} height={20} />
|
|
392
|
+
<Stack space={2}>
|
|
393
|
+
<Text size={1} weight="semibold">
|
|
394
|
+
Validation Error
|
|
395
|
+
</Text>
|
|
396
|
+
<Text size={1}>{validationError}</Text>
|
|
397
|
+
</Stack>
|
|
398
|
+
</Flex>
|
|
399
|
+
</Card>
|
|
400
|
+
)}
|
|
255
401
|
<Label size={3}>FILE TO UPLOAD</Label>
|
|
256
402
|
<Card
|
|
257
403
|
tone="transparent"
|
|
@@ -269,8 +415,30 @@ export default function UploadConfiguration({
|
|
|
269
415
|
<Text as="p" size={1} muted>
|
|
270
416
|
{stagedUpload.type === 'file'
|
|
271
417
|
? `Direct File Upload (${formatBytes(stagedUpload.files[0].size)})`
|
|
272
|
-
:
|
|
418
|
+
: (() => {
|
|
419
|
+
if (urlFileSize) {
|
|
420
|
+
return `File From URL (${formatBytes(urlFileSize)})`
|
|
421
|
+
}
|
|
422
|
+
if (isLoadingFileSize) {
|
|
423
|
+
return 'File From URL (Loading size...)'
|
|
424
|
+
}
|
|
425
|
+
return 'File From URL (Unknown size)'
|
|
426
|
+
})()}
|
|
273
427
|
</Text>
|
|
428
|
+
{stagedUpload.type === 'file' && (
|
|
429
|
+
<Stack space={1}>
|
|
430
|
+
{isLoadingDuration && (
|
|
431
|
+
<Text as="p" size={1} muted>
|
|
432
|
+
Reading video metadata...
|
|
433
|
+
</Text>
|
|
434
|
+
)}
|
|
435
|
+
{videoDuration !== null && !validationError && (
|
|
436
|
+
<Text as="p" size={1} muted>
|
|
437
|
+
Duration: {formatSeconds(videoDuration)}
|
|
438
|
+
</Text>
|
|
439
|
+
)}
|
|
440
|
+
</Stack>
|
|
441
|
+
)}
|
|
274
442
|
</Stack>
|
|
275
443
|
</Flex>
|
|
276
444
|
</Card>
|
|
@@ -488,11 +656,20 @@ export default function UploadConfiguration({
|
|
|
488
656
|
|
|
489
657
|
<Box marginTop={4}>
|
|
490
658
|
<Button
|
|
491
|
-
disabled={
|
|
659
|
+
disabled={
|
|
660
|
+
(!basicConfig && !config.public_policy && !config.signed_policy) ||
|
|
661
|
+
validationError !== null ||
|
|
662
|
+
isLoadingDuration ||
|
|
663
|
+
(isLoadingFileSize && !canSkipFileSizeValidation)
|
|
664
|
+
}
|
|
492
665
|
icon={UploadIcon}
|
|
493
666
|
text="Upload"
|
|
494
667
|
tone="positive"
|
|
495
|
-
onClick={() =>
|
|
668
|
+
onClick={() => {
|
|
669
|
+
if (!validationError) {
|
|
670
|
+
startUpload(formatUploadConfig(config))
|
|
671
|
+
}
|
|
672
|
+
}}
|
|
496
673
|
/>
|
|
497
674
|
</Box>
|
|
498
675
|
</Stack>
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import {PlugIcon, SearchIcon, UploadIcon} from '@sanity/icons'
|
|
2
|
-
import {DocumentVideoIcon} from '@sanity/icons'
|
|
1
|
+
import {DocumentVideoIcon, PlugIcon, SearchIcon, UploadIcon} from '@sanity/icons'
|
|
3
2
|
import {Button, Card, Flex, Inline, Text} from '@sanity/ui'
|
|
4
3
|
import {useCallback} from 'react'
|
|
5
4
|
|
|
6
|
-
import type {SetDialogState} from '../hooks/useDialogState'
|
|
7
|
-
import {FileInputButton, type FileInputButtonProps} from './FileInputButton'
|
|
8
5
|
import {useAccessControl} from '../hooks/useAccessControl'
|
|
6
|
+
import type {SetDialogState} from '../hooks/useDialogState'
|
|
9
7
|
import {PluginConfig} from '../util/types'
|
|
8
|
+
import {FileInputButton, type FileInputButtonProps} from './FileInputButton'
|
|
9
|
+
|
|
10
|
+
function formatAcceptString(accept: string): string {
|
|
11
|
+
return accept
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((type) => type.trim().replace('/*', ''))
|
|
14
|
+
.join(' or ')
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
interface UploadPlaceholderProps {
|
|
12
18
|
setDialogState: SetDialogState
|
|
@@ -15,9 +21,10 @@ interface UploadPlaceholderProps {
|
|
|
15
21
|
needsSetup: boolean
|
|
16
22
|
onSelect: FileInputButtonProps['onSelect']
|
|
17
23
|
config: PluginConfig
|
|
24
|
+
accept: string
|
|
18
25
|
}
|
|
19
26
|
export default function UploadPlaceholder(props: UploadPlaceholderProps) {
|
|
20
|
-
const {setDialogState, readOnly, onSelect, hovering, needsSetup} = props
|
|
27
|
+
const {setDialogState, readOnly, onSelect, hovering, needsSetup, accept} = props
|
|
21
28
|
const handleBrowse = useCallback(() => setDialogState('select-video'), [setDialogState])
|
|
22
29
|
const handleConfigureApi = useCallback(() => setDialogState('secrets'), [setDialogState])
|
|
23
30
|
const {hasConfigAccess} = useAccessControl(props.config)
|
|
@@ -48,12 +55,13 @@ export default function UploadPlaceholder(props: UploadPlaceholderProps) {
|
|
|
48
55
|
</Flex>
|
|
49
56
|
<Flex justify="center">
|
|
50
57
|
<Text size={1} muted>
|
|
51
|
-
Drag
|
|
58
|
+
Drag {formatAcceptString(accept)} file or paste URL here
|
|
52
59
|
</Text>
|
|
53
60
|
</Flex>
|
|
54
61
|
</Flex>
|
|
55
62
|
<Inline space={2}>
|
|
56
63
|
<FileInputButton
|
|
64
|
+
accept={accept}
|
|
57
65
|
mode="bleed"
|
|
58
66
|
tone="default"
|
|
59
67
|
icon={UploadIcon}
|
|
@@ -5,9 +5,6 @@ import {styled} from 'styled-components'
|
|
|
5
5
|
|
|
6
6
|
import {withFocusRing} from './withFocusRing'
|
|
7
7
|
|
|
8
|
-
const ctrlKey = 17
|
|
9
|
-
const cmdKey = 91
|
|
10
|
-
|
|
11
8
|
const UploadCardWithFocusRing = withFocusRing(Card)
|
|
12
9
|
|
|
13
10
|
interface UploadCardProps {
|
|
@@ -21,22 +18,19 @@ interface UploadCardProps {
|
|
|
21
18
|
}
|
|
22
19
|
export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps>(
|
|
23
20
|
({children, tone, onPaste, onDrop, onDragEnter, onDragLeave, onDragOver}, forwardedRef) => {
|
|
24
|
-
const ctrlDown = useRef(false)
|
|
25
21
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
26
22
|
const handleKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>((event) => {
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const target = event.target as HTMLElement
|
|
24
|
+
|
|
25
|
+
// Don't steal focus when pasting into the VTT input
|
|
26
|
+
if (target.closest('#vtt-url')) {
|
|
27
|
+
return
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
if (
|
|
29
|
+
|
|
30
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
|
32
31
|
inputRef.current!.focus()
|
|
33
32
|
}
|
|
34
33
|
}, [])
|
|
35
|
-
const handleKeyUp = useCallback<React.KeyboardEventHandler<HTMLDivElement>>((event) => {
|
|
36
|
-
if (event.keyCode == ctrlKey || event.keyCode == cmdKey) {
|
|
37
|
-
ctrlDown.current = false
|
|
38
|
-
}
|
|
39
|
-
}, [])
|
|
40
34
|
|
|
41
35
|
return (
|
|
42
36
|
<UploadCardWithFocusRing
|
|
@@ -47,14 +41,13 @@ export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps>(
|
|
|
47
41
|
shadow={0}
|
|
48
42
|
tabIndex={0}
|
|
49
43
|
onKeyDown={handleKeyDown}
|
|
50
|
-
onKeyUp={handleKeyUp}
|
|
51
44
|
onPaste={onPaste}
|
|
52
45
|
onDrop={onDrop}
|
|
53
46
|
onDragEnter={onDragEnter}
|
|
54
47
|
onDragLeave={onDragLeave}
|
|
55
48
|
onDragOver={onDragOver}
|
|
56
49
|
>
|
|
57
|
-
<HiddenInput ref={inputRef}
|
|
50
|
+
<HiddenInput ref={inputRef} />
|
|
58
51
|
{children}
|
|
59
52
|
</UploadCardWithFocusRing>
|
|
60
53
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {ErrorOutlineIcon} from '@sanity/icons'
|
|
2
2
|
import {Button, CardTone, Flex, Text, useToast} from '@sanity/ui'
|
|
3
|
-
import React, {useEffect, useReducer, useRef, useState} from 'react'
|
|
3
|
+
import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react'
|
|
4
4
|
import {type Observable, Subject, Subscription} from 'rxjs'
|
|
5
5
|
import {takeUntil, tap} from 'rxjs/operators'
|
|
6
6
|
import type {SanityClient} from 'sanity'
|
|
@@ -66,7 +66,7 @@ type UploaderStateAction =
|
|
|
66
66
|
| Extract<UploadUrlEvent, {type: 'url'}>
|
|
67
67
|
))
|
|
68
68
|
| {action: 'progress'; percent: number}
|
|
69
|
-
| {action: 'error'; error:
|
|
69
|
+
| {action: 'error'; error: Error}
|
|
70
70
|
| {action: 'complete' | 'reset'}
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -101,6 +101,7 @@ export default function Uploader(props: Props) {
|
|
|
101
101
|
case 'commitUpload':
|
|
102
102
|
return Object.assign({}, prev, {uploadStatus: {progress: 0}})
|
|
103
103
|
case 'progressInfo': {
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
104
105
|
const {type, action: _, ...payload} = action
|
|
105
106
|
return Object.assign({}, prev, {
|
|
106
107
|
uploadStatus: {
|
|
@@ -257,12 +258,37 @@ export default function Uploader(props: Props) {
|
|
|
257
258
|
})
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
const invalidFileToast = useCallback(() => {
|
|
262
|
+
toast.push({
|
|
263
|
+
status: 'error',
|
|
264
|
+
title: `Invalid file type. Accepted types: ${props.config.acceptedMimeTypes?.join(', ')}`,
|
|
265
|
+
})
|
|
266
|
+
}, [props.config.acceptedMimeTypes, toast])
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Validates if any file in the provided FileList or File array has an unsupported MIME type
|
|
270
|
+
* @param files - FileList or File array to validate
|
|
271
|
+
* @returns true if any file has an invalid MIME type, false if all files are valid
|
|
272
|
+
*/
|
|
273
|
+
const isInvalidFile = (files: FileList | File[]) => {
|
|
274
|
+
const isInvalid = Array.from(files).some((file) => {
|
|
275
|
+
return !props.config.acceptedMimeTypes?.some((acceptedType) => {
|
|
276
|
+
// Convert mime type pattern to regex (e.g., 'audio/*' -> /^audio\/.*$/)
|
|
277
|
+
const pattern = `^${acceptedType.replace('*', '.*')}$`
|
|
278
|
+
return new RegExp(pattern).test(file.type)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
return isInvalid
|
|
283
|
+
}
|
|
284
|
+
|
|
260
285
|
/* -------------------------- Upload Initialization ------------------------- */
|
|
261
286
|
// The below populate the uploadInput state field, which then triggers the
|
|
262
287
|
// upload configuration, or the startUpload function if no config is required.
|
|
263
288
|
|
|
264
289
|
// Stages an upload from the file selector
|
|
265
290
|
const handleUpload = (files: FileList | File[]) => {
|
|
291
|
+
if (isInvalidFile(files)) return
|
|
266
292
|
dispatch({
|
|
267
293
|
action: 'stageUpload',
|
|
268
294
|
input: {type: 'file', files},
|
|
@@ -271,10 +297,18 @@ export default function Uploader(props: Props) {
|
|
|
271
297
|
|
|
272
298
|
// Stages and validates an upload from pasting an asset URL
|
|
273
299
|
const handlePaste: React.ClipboardEventHandler<HTMLInputElement> = (event) => {
|
|
300
|
+
const target = event.target as HTMLElement
|
|
301
|
+
|
|
302
|
+
// Ignore paste coming from the VTT URL input
|
|
303
|
+
if (target.closest('#vtt-url')) {
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
274
307
|
event.preventDefault()
|
|
275
308
|
event.stopPropagation()
|
|
276
|
-
const clipboardData =
|
|
277
|
-
|
|
309
|
+
const clipboardData =
|
|
310
|
+
event.clipboardData || (window as Window & {clipboardData?: DataTransfer}).clipboardData
|
|
311
|
+
const url = clipboardData?.getData('text')?.trim()
|
|
278
312
|
if (!isValidUrl(url)) {
|
|
279
313
|
toast.push({status: 'error', title: 'Invalid URL for Mux video input.'})
|
|
280
314
|
return
|
|
@@ -284,9 +318,14 @@ export default function Uploader(props: Props) {
|
|
|
284
318
|
|
|
285
319
|
// Stages and validates an upload from dragging+dropping files or folders
|
|
286
320
|
const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
|
|
287
|
-
setDragState(null)
|
|
288
321
|
event.preventDefault()
|
|
289
322
|
event.stopPropagation()
|
|
323
|
+
if (dragState === 'invalid') {
|
|
324
|
+
invalidFileToast()
|
|
325
|
+
setDragState(null)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
setDragState(null)
|
|
290
329
|
extractDroppedFiles(event.nativeEvent.dataTransfer!).then((files) => {
|
|
291
330
|
dispatch({
|
|
292
331
|
action: 'stageUpload',
|
|
@@ -306,7 +345,16 @@ export default function Uploader(props: Props) {
|
|
|
306
345
|
event.stopPropagation()
|
|
307
346
|
dragEnteredEls.current.push(event.target)
|
|
308
347
|
const type = event.dataTransfer.items?.[0]?.type
|
|
309
|
-
|
|
348
|
+
const mimeTypes = props.config.acceptedMimeTypes
|
|
349
|
+
|
|
350
|
+
// Check if the dragged file type matches any of the accepted mime types
|
|
351
|
+
const isValidType = mimeTypes?.some((acceptedType) => {
|
|
352
|
+
// Convert mime type pattern to regex (e.g., 'video/*' -> /^video\/.*$/)
|
|
353
|
+
const pattern = `^${acceptedType.replace('*', '.*')}$`
|
|
354
|
+
return new RegExp(pattern).test(type)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
setDragState(isValidType ? 'valid' : 'invalid')
|
|
310
358
|
}
|
|
311
359
|
|
|
312
360
|
const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
|
|
@@ -370,6 +418,10 @@ export default function Uploader(props: Props) {
|
|
|
370
418
|
let tone: CardTone | undefined
|
|
371
419
|
if (dragState) tone = dragState === 'valid' ? 'positive' : 'critical'
|
|
372
420
|
|
|
421
|
+
const acceptMimeString = props.config?.acceptedMimeTypes?.length
|
|
422
|
+
? props.config.acceptedMimeTypes.join(',')
|
|
423
|
+
: 'video/*, audio/*'
|
|
424
|
+
|
|
373
425
|
return (
|
|
374
426
|
<>
|
|
375
427
|
<UploadCard
|
|
@@ -390,8 +442,10 @@ export default function Uploader(props: Props) {
|
|
|
390
442
|
readOnly={props.readOnly}
|
|
391
443
|
asset={props.asset}
|
|
392
444
|
onChange={props.onChange}
|
|
445
|
+
config={props.config}
|
|
393
446
|
buttons={
|
|
394
447
|
<PlayerActionsMenu
|
|
448
|
+
accept={acceptMimeString}
|
|
395
449
|
asset={props.asset}
|
|
396
450
|
dialogState={props.dialogState}
|
|
397
451
|
setDialogState={props.setDialogState}
|
|
@@ -405,6 +459,7 @@ export default function Uploader(props: Props) {
|
|
|
405
459
|
</DialogStateProvider>
|
|
406
460
|
) : (
|
|
407
461
|
<UploadPlaceholder
|
|
462
|
+
accept={acceptMimeString}
|
|
408
463
|
hovering={dragState !== null}
|
|
409
464
|
onSelect={handleUpload}
|
|
410
465
|
readOnly={!!props.readOnly}
|
|
@@ -27,10 +27,12 @@ import {
|
|
|
27
27
|
import React, {useEffect, useState} from 'react'
|
|
28
28
|
|
|
29
29
|
import {DIALOGS_Z_INDEX} from '../../util/constants'
|
|
30
|
+
import type {MuxTextTrack} from '../../util/types'
|
|
30
31
|
import FormField from '../FormField'
|
|
31
32
|
import IconInfo from '../IconInfo'
|
|
32
33
|
import {ResolutionIcon} from '../icons/Resolution'
|
|
33
34
|
import {StopWatchIcon} from '../icons/StopWatch'
|
|
35
|
+
import TextTracksManager from '../TextTracksManager'
|
|
34
36
|
import VideoPlayer from '../VideoPlayer'
|
|
35
37
|
import DeleteDialog from './DeleteDialog'
|
|
36
38
|
import useVideoDetails, {VideoDetailsProps} from './useVideoDetails'
|
|
@@ -203,6 +205,20 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
|
|
|
203
205
|
>
|
|
204
206
|
<Stack space={4} flex={1} sizing="border">
|
|
205
207
|
<VideoPlayer asset={props.asset} autoPlay={props.asset.autoPlay || false} />
|
|
208
|
+
{tab === 'details' && (
|
|
209
|
+
<TextTracksManager
|
|
210
|
+
asset={props.asset}
|
|
211
|
+
iconOnly
|
|
212
|
+
collapseTracks
|
|
213
|
+
tracks={
|
|
214
|
+
displayInfo?.text_tracks ||
|
|
215
|
+
props.asset.data?.tracks?.filter(
|
|
216
|
+
(track): track is MuxTextTrack => track.type === 'text'
|
|
217
|
+
) ||
|
|
218
|
+
[]
|
|
219
|
+
}
|
|
220
|
+
/>
|
|
221
|
+
)}
|
|
206
222
|
</Stack>
|
|
207
223
|
<Stack space={4} flex={1} sizing="border">
|
|
208
224
|
<TabList space={2}>
|
|
@@ -7,6 +7,7 @@ import {THUMBNAIL_ASPECT_RATIO} from '../util/constants'
|
|
|
7
7
|
import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
|
|
8
8
|
import {VideoAssetDocument} from '../util/types'
|
|
9
9
|
import IconInfo from './IconInfo'
|
|
10
|
+
import {AudioIcon} from './icons/Audio'
|
|
10
11
|
import VideoMetadata from './VideoMetadata'
|
|
11
12
|
import VideoPlayer, {assetIsAudio} from './VideoPlayer'
|
|
12
13
|
import VideoThumbnail from './VideoThumbnail'
|
|
@@ -152,13 +153,7 @@ export default function VideoInBrowser({
|
|
|
152
153
|
justifyContent: 'center',
|
|
153
154
|
}}
|
|
154
155
|
>
|
|
155
|
-
<
|
|
156
|
-
<path
|
|
157
|
-
fill="currentColor"
|
|
158
|
-
style={{opacity: '0.65'}}
|
|
159
|
-
d="M10.75 19q.95 0 1.6-.65t.65-1.6V13h3v-2h-4v3.875q-.275-.2-.587-.288t-.663-.087q-.95 0-1.6.65t-.65 1.6t.65 1.6t1.6.65M6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13V4H6v16h12V9zM6 4v5zv16z"
|
|
160
|
-
/>
|
|
161
|
-
</svg>
|
|
156
|
+
<AudioIcon width="3em" height="3em" />
|
|
162
157
|
</div>
|
|
163
158
|
) : (
|
|
164
159
|
<VideoThumbnail asset={asset} />
|
|
@@ -10,17 +10,23 @@ import {AUDIO_ASPECT_RATIO, MIN_ASPECT_RATIO} from '../util/constants'
|
|
|
10
10
|
import {getPosterSrc} from '../util/getPosterSrc'
|
|
11
11
|
import {getVideoSrc} from '../util/getVideoSrc'
|
|
12
12
|
import type {VideoAssetDocument} from '../util/types'
|
|
13
|
+
import CaptionsDialog from './CaptionsDialog'
|
|
13
14
|
import EditThumbnailDialog from './EditThumbnailDialog'
|
|
15
|
+
import {AudioIcon} from './icons/Audio'
|
|
14
16
|
|
|
15
17
|
export default function VideoPlayer({
|
|
16
18
|
asset,
|
|
17
19
|
thumbnailWidth = 250,
|
|
18
20
|
children,
|
|
21
|
+
hlsConfig,
|
|
19
22
|
...props
|
|
20
23
|
}: PropsWithChildren<
|
|
21
|
-
{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
{
|
|
25
|
+
asset: VideoAssetDocument
|
|
26
|
+
thumbnailWidth?: number
|
|
27
|
+
forceAspectRatio?: number
|
|
28
|
+
hlsConfig?: MuxPlayerProps['_hlsConfig']
|
|
29
|
+
} & Partial<Pick<MuxPlayerProps, 'autoPlay'>>
|
|
24
30
|
>) {
|
|
25
31
|
const client = useClient()
|
|
26
32
|
const {dialogState} = useDialogStateContext()
|
|
@@ -67,11 +73,31 @@ export default function VideoPlayer({
|
|
|
67
73
|
|
|
68
74
|
return (
|
|
69
75
|
<>
|
|
70
|
-
<Card
|
|
76
|
+
<Card
|
|
77
|
+
tone="transparent"
|
|
78
|
+
style={{
|
|
79
|
+
aspectRatio: aspectRatio,
|
|
80
|
+
position: 'relative',
|
|
81
|
+
...(isAudio && {display: 'flex', alignItems: 'flex-end'}),
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
71
84
|
{videoSrc && (
|
|
72
85
|
<>
|
|
86
|
+
{isAudio && (
|
|
87
|
+
<AudioIcon
|
|
88
|
+
style={{
|
|
89
|
+
padding: '0.5em',
|
|
90
|
+
width: '2.2em',
|
|
91
|
+
height: '2.2em',
|
|
92
|
+
position: 'absolute',
|
|
93
|
+
top: 0,
|
|
94
|
+
left: 0,
|
|
95
|
+
zIndex: 1,
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
)}
|
|
73
99
|
<MuxPlayer
|
|
74
|
-
poster={thumbnailSrc}
|
|
100
|
+
poster={isAudio ? undefined : thumbnailSrc}
|
|
75
101
|
ref={muxPlayer}
|
|
76
102
|
{...props}
|
|
77
103
|
playsInline
|
|
@@ -89,11 +115,13 @@ export default function VideoPlayer({
|
|
|
89
115
|
page_type: 'Preview Player',
|
|
90
116
|
}}
|
|
91
117
|
audio={isAudio}
|
|
118
|
+
_hlsConfig={hlsConfig}
|
|
92
119
|
style={{
|
|
93
|
-
height: '100%',
|
|
120
|
+
...(!isAudio && {height: '100%'}),
|
|
94
121
|
width: '100%',
|
|
95
122
|
display: 'block',
|
|
96
123
|
objectFit: 'contain',
|
|
124
|
+
...(isAudio && {alignSelf: 'end'}),
|
|
97
125
|
}}
|
|
98
126
|
/>
|
|
99
127
|
{children}
|
|
@@ -122,6 +150,7 @@ export default function VideoPlayer({
|
|
|
122
150
|
{dialogState === 'edit-thumbnail' && (
|
|
123
151
|
<EditThumbnailDialog asset={asset} currentTime={muxPlayer?.current?.currentTime} />
|
|
124
152
|
)}
|
|
153
|
+
{dialogState === 'edit-captions' && <CaptionsDialog asset={asset} />}
|
|
125
154
|
</>
|
|
126
155
|
)
|
|
127
156
|
}
|
|
@@ -6,6 +6,7 @@ import useAssets from '../hooks/useAssets'
|
|
|
6
6
|
import type {VideoAssetDocument} from '../util/types'
|
|
7
7
|
import ConfigureApi from './ConfigureApi'
|
|
8
8
|
import ImportVideosFromMux from './ImportVideosFromMux'
|
|
9
|
+
import PageSelector from './PageSelector'
|
|
9
10
|
import ResyncMetadata from './ResyncMetadata'
|
|
10
11
|
import {SelectSortOptions} from './SelectSortOptions'
|
|
11
12
|
import SpinnerBox from './SpinnerBox'
|
|
@@ -19,12 +20,18 @@ export interface VideosBrowserProps {
|
|
|
19
20
|
|
|
20
21
|
export default function VideosBrowser({onSelect}: VideosBrowserProps) {
|
|
21
22
|
const {assets, isLoading, searchQuery, setSearchQuery, setSort, sort} = useAssets()
|
|
23
|
+
const [page, setPage] = useState<number>(0)
|
|
24
|
+
const pageLimit = 20
|
|
25
|
+
const pageTotal = Math.floor(assets.length / pageLimit) + 1
|
|
22
26
|
const [editedAsset, setEditedAsset] = useState<VideoDetailsProps['asset'] | null>(null)
|
|
23
27
|
const freshEditedAsset = useMemo(
|
|
24
28
|
() => assets.find((a) => a._id === editedAsset?._id) || editedAsset,
|
|
25
29
|
[editedAsset, assets]
|
|
26
30
|
)
|
|
27
31
|
|
|
32
|
+
const pageStart = page * pageLimit
|
|
33
|
+
const pageEnd = pageStart + pageLimit
|
|
34
|
+
|
|
28
35
|
const placement = onSelect ? 'input' : 'tool'
|
|
29
36
|
return (
|
|
30
37
|
<>
|
|
@@ -40,6 +47,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
|
|
|
40
47
|
placeholder="Search videos"
|
|
41
48
|
/>
|
|
42
49
|
<SelectSortOptions setSort={setSort} sort={sort} />
|
|
50
|
+
<PageSelector page={page} setPage={setPage} total={pageTotal} limit={pageLimit} />
|
|
43
51
|
</Flex>
|
|
44
52
|
{placement === 'tool' && (
|
|
45
53
|
<Inline space={2}>
|
|
@@ -62,7 +70,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
|
|
|
62
70
|
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
|
63
71
|
}}
|
|
64
72
|
>
|
|
65
|
-
{assets.map((asset) => (
|
|
73
|
+
{assets.slice(pageStart, pageEnd).map((asset) => (
|
|
66
74
|
<VideoInBrowser
|
|
67
75
|
key={asset._id}
|
|
68
76
|
asset={asset}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {SVGProps} from 'react'
|
|
2
|
+
|
|
3
|
+
export function AudioIcon(props: SVGProps<SVGSVGElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
|
6
|
+
<path
|
|
7
|
+
fill="currentColor"
|
|
8
|
+
style={{opacity: '0.65'}}
|
|
9
|
+
d="M10.75 19q.95 0 1.6-.65t.65-1.6V13h3v-2h-4v3.875q-.275-.2-.587-.288t-.663-.087q-.95 0-1.6.65t-.65 1.6t.65 1.6t1.6.65M6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13V4H6v16h12V9zM6 4v5zv16z"
|
|
10
|
+
/>
|
|
11
|
+
</svg>
|
|
12
|
+
)
|
|
13
|
+
}
|