sanity-plugin-mux-input 2.12.0 → 2.13.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 +41 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +188 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +188 -72
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/_exports/index.ts +1 -0
- package/src/components/FileInputButton.tsx +2 -2
- package/src/components/Player.tsx +4 -3
- package/src/components/PlayerActionsMenu.tsx +4 -3
- package/src/components/UploadConfiguration.tsx +181 -4
- package/src/components/UploadPlaceholder.tsx +14 -6
- package/src/components/Uploader.tsx +54 -6
- package/src/components/VideoInBrowser.tsx +2 -7
- package/src/components/VideoPlayer.tsx +33 -6
- package/src/components/icons/Audio.tsx +13 -0
- package/src/util/types.ts +45 -0
- package/src/components/FileInputArea.tsx +0 -93
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanity-plugin-mux-input",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "An input component that integrates Sanity Studio with Mux video encoding/hosting service.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sanity",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"peerDependencies": {
|
|
105
105
|
"react": "^18.3 || ^19",
|
|
106
106
|
"react-is": "^18.3 || ^19",
|
|
107
|
-
"sanity": "^3.42.0 || ^4.0.0-0",
|
|
107
|
+
"sanity": "^3.42.0 || ^4.0.0-0 || ^5.0.0",
|
|
108
108
|
"styled-components": "^5 || ^6"
|
|
109
109
|
},
|
|
110
110
|
"engines": {
|
package/src/_exports/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
37
|
+
accept={accept}
|
|
38
38
|
ref={inputRef}
|
|
39
39
|
tabIndex={0}
|
|
40
40
|
type="file"
|
|
@@ -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
|
|
@@ -25,11 +25,11 @@ import {memo, useCallback, useEffect, useMemo, useState} from 'react'
|
|
|
25
25
|
import {PatchEvent, unset} from 'sanity'
|
|
26
26
|
import {styled} from 'styled-components'
|
|
27
27
|
|
|
28
|
+
import {useAccessControl} from '../hooks/useAccessControl'
|
|
28
29
|
import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
|
|
29
30
|
import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
|
|
30
31
|
import type {MuxInputProps, PluginConfig, VideoAssetDocument} from '../util/types'
|
|
31
32
|
import {FileInputMenuItem} from './FileInputMenuItem'
|
|
32
|
-
import {useAccessControl} from '../hooks/useAccessControl'
|
|
33
33
|
|
|
34
34
|
const LockCard = styled(Card)`
|
|
35
35
|
position: absolute;
|
|
@@ -57,9 +57,10 @@ function PlayerActionsMenu(
|
|
|
57
57
|
dialogState: DialogState
|
|
58
58
|
setDialogState: SetDialogState
|
|
59
59
|
config: PluginConfig
|
|
60
|
+
accept: string
|
|
60
61
|
}
|
|
61
62
|
) {
|
|
62
|
-
const {asset, readOnly, dialogState, setDialogState, onChange, onSelect} = props
|
|
63
|
+
const {asset, readOnly, dialogState, setDialogState, onChange, onSelect, accept} = props
|
|
63
64
|
const [open, setOpen] = useState(false)
|
|
64
65
|
const [menuElement, setMenuRef] = useState<HTMLDivElement | null>(null)
|
|
65
66
|
const isSigned = useMemo(() => getPlaybackPolicy(asset) === 'signed', [asset])
|
|
@@ -108,7 +109,7 @@ function PlayerActionsMenu(
|
|
|
108
109
|
</Label>
|
|
109
110
|
</Box>
|
|
110
111
|
<FileInputMenuItem
|
|
111
|
-
accept=
|
|
112
|
+
accept={accept}
|
|
112
113
|
icon={UploadIcon}
|
|
113
114
|
onSelect={onSelect}
|
|
114
115
|
text="Upload"
|
|
@@ -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}
|
|
@@ -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},
|
|
@@ -273,8 +299,9 @@ export default function Uploader(props: Props) {
|
|
|
273
299
|
const handlePaste: React.ClipboardEventHandler<HTMLInputElement> = (event) => {
|
|
274
300
|
event.preventDefault()
|
|
275
301
|
event.stopPropagation()
|
|
276
|
-
const clipboardData =
|
|
277
|
-
|
|
302
|
+
const clipboardData =
|
|
303
|
+
event.clipboardData || (window as Window & {clipboardData?: DataTransfer}).clipboardData
|
|
304
|
+
const url = clipboardData?.getData('text')?.trim()
|
|
278
305
|
if (!isValidUrl(url)) {
|
|
279
306
|
toast.push({status: 'error', title: 'Invalid URL for Mux video input.'})
|
|
280
307
|
return
|
|
@@ -284,9 +311,14 @@ export default function Uploader(props: Props) {
|
|
|
284
311
|
|
|
285
312
|
// Stages and validates an upload from dragging+dropping files or folders
|
|
286
313
|
const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
|
|
287
|
-
setDragState(null)
|
|
288
314
|
event.preventDefault()
|
|
289
315
|
event.stopPropagation()
|
|
316
|
+
if (dragState === 'invalid') {
|
|
317
|
+
invalidFileToast()
|
|
318
|
+
setDragState(null)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
setDragState(null)
|
|
290
322
|
extractDroppedFiles(event.nativeEvent.dataTransfer!).then((files) => {
|
|
291
323
|
dispatch({
|
|
292
324
|
action: 'stageUpload',
|
|
@@ -306,7 +338,16 @@ export default function Uploader(props: Props) {
|
|
|
306
338
|
event.stopPropagation()
|
|
307
339
|
dragEnteredEls.current.push(event.target)
|
|
308
340
|
const type = event.dataTransfer.items?.[0]?.type
|
|
309
|
-
|
|
341
|
+
const mimeTypes = props.config.acceptedMimeTypes
|
|
342
|
+
|
|
343
|
+
// Check if the dragged file type matches any of the accepted mime types
|
|
344
|
+
const isValidType = mimeTypes?.some((acceptedType) => {
|
|
345
|
+
// Convert mime type pattern to regex (e.g., 'video/*' -> /^video\/.*$/)
|
|
346
|
+
const pattern = `^${acceptedType.replace('*', '.*')}$`
|
|
347
|
+
return new RegExp(pattern).test(type)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
setDragState(isValidType ? 'valid' : 'invalid')
|
|
310
351
|
}
|
|
311
352
|
|
|
312
353
|
const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
|
|
@@ -370,6 +411,10 @@ export default function Uploader(props: Props) {
|
|
|
370
411
|
let tone: CardTone | undefined
|
|
371
412
|
if (dragState) tone = dragState === 'valid' ? 'positive' : 'critical'
|
|
372
413
|
|
|
414
|
+
const acceptMimeString = props.config?.acceptedMimeTypes?.length
|
|
415
|
+
? props.config.acceptedMimeTypes.join(',')
|
|
416
|
+
: 'video/*, audio/*'
|
|
417
|
+
|
|
373
418
|
return (
|
|
374
419
|
<>
|
|
375
420
|
<UploadCard
|
|
@@ -390,8 +435,10 @@ export default function Uploader(props: Props) {
|
|
|
390
435
|
readOnly={props.readOnly}
|
|
391
436
|
asset={props.asset}
|
|
392
437
|
onChange={props.onChange}
|
|
438
|
+
config={props.config}
|
|
393
439
|
buttons={
|
|
394
440
|
<PlayerActionsMenu
|
|
441
|
+
accept={acceptMimeString}
|
|
395
442
|
asset={props.asset}
|
|
396
443
|
dialogState={props.dialogState}
|
|
397
444
|
setDialogState={props.setDialogState}
|
|
@@ -405,6 +452,7 @@ export default function Uploader(props: Props) {
|
|
|
405
452
|
</DialogStateProvider>
|
|
406
453
|
) : (
|
|
407
454
|
<UploadPlaceholder
|
|
455
|
+
accept={acceptMimeString}
|
|
408
456
|
hovering={dragState !== null}
|
|
409
457
|
onSelect={handleUpload}
|
|
410
458
|
readOnly={!!props.readOnly}
|
|
@@ -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} />
|
|
@@ -11,16 +11,21 @@ import {getPosterSrc} from '../util/getPosterSrc'
|
|
|
11
11
|
import {getVideoSrc} from '../util/getVideoSrc'
|
|
12
12
|
import type {VideoAssetDocument} from '../util/types'
|
|
13
13
|
import EditThumbnailDialog from './EditThumbnailDialog'
|
|
14
|
+
import {AudioIcon} from './icons/Audio'
|
|
14
15
|
|
|
15
16
|
export default function VideoPlayer({
|
|
16
17
|
asset,
|
|
17
18
|
thumbnailWidth = 250,
|
|
18
19
|
children,
|
|
20
|
+
hlsConfig,
|
|
19
21
|
...props
|
|
20
22
|
}: PropsWithChildren<
|
|
21
|
-
{
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
{
|
|
24
|
+
asset: VideoAssetDocument
|
|
25
|
+
thumbnailWidth?: number
|
|
26
|
+
forceAspectRatio?: number
|
|
27
|
+
hlsConfig?: MuxPlayerProps['_hlsConfig']
|
|
28
|
+
} & Partial<Pick<MuxPlayerProps, 'autoPlay'>>
|
|
24
29
|
>) {
|
|
25
30
|
const client = useClient()
|
|
26
31
|
const {dialogState} = useDialogStateContext()
|
|
@@ -67,11 +72,31 @@ export default function VideoPlayer({
|
|
|
67
72
|
|
|
68
73
|
return (
|
|
69
74
|
<>
|
|
70
|
-
<Card
|
|
75
|
+
<Card
|
|
76
|
+
tone="transparent"
|
|
77
|
+
style={{
|
|
78
|
+
aspectRatio: aspectRatio,
|
|
79
|
+
position: 'relative',
|
|
80
|
+
...(isAudio && {display: 'flex', alignItems: 'flex-end'}),
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
71
83
|
{videoSrc && (
|
|
72
84
|
<>
|
|
85
|
+
{isAudio && (
|
|
86
|
+
<AudioIcon
|
|
87
|
+
style={{
|
|
88
|
+
padding: '0.5em',
|
|
89
|
+
width: '2.2em',
|
|
90
|
+
height: '2.2em',
|
|
91
|
+
position: 'absolute',
|
|
92
|
+
top: 0,
|
|
93
|
+
left: 0,
|
|
94
|
+
zIndex: 1,
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
73
98
|
<MuxPlayer
|
|
74
|
-
poster={thumbnailSrc}
|
|
99
|
+
poster={isAudio ? undefined : thumbnailSrc}
|
|
75
100
|
ref={muxPlayer}
|
|
76
101
|
{...props}
|
|
77
102
|
playsInline
|
|
@@ -89,11 +114,13 @@ export default function VideoPlayer({
|
|
|
89
114
|
page_type: 'Preview Player',
|
|
90
115
|
}}
|
|
91
116
|
audio={isAudio}
|
|
117
|
+
_hlsConfig={hlsConfig}
|
|
92
118
|
style={{
|
|
93
|
-
height: '100%',
|
|
119
|
+
...(!isAudio && {height: '100%'}),
|
|
94
120
|
width: '100%',
|
|
95
121
|
display: 'block',
|
|
96
122
|
objectFit: 'contain',
|
|
123
|
+
...(isAudio && {alignSelf: 'end'}),
|
|
97
124
|
}}
|
|
98
125
|
/>
|
|
99
126
|
{children}
|
|
@@ -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
|
+
}
|