sanity-plugin-mux-input 2.12.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanity-plugin-mux-input",
3
- "version": "2.12.1",
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",
@@ -16,6 +16,7 @@ export const defaultConfig: PluginConfig = {
16
16
  defaultSigned: false,
17
17
  tool: DEFAULT_TOOL_CONFIG,
18
18
  allowedRolesForConfiguration: [],
19
+ acceptedMimeTypes: ['video/*', 'audio/*'],
19
20
  }
20
21
 
21
22
  /**
@@ -17,7 +17,7 @@ const Label = styled.label`
17
17
 
18
18
  export interface FileInputButtonProps extends ButtonProps {
19
19
  onSelect: (files: FileList) => void
20
- accept?: string
20
+ accept: string
21
21
  }
22
22
  export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonProps) => {
23
23
  const inputId = `FileSelect${useId()}`
@@ -34,7 +34,7 @@ export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonPro
34
34
  return (
35
35
  <Label htmlFor={inputId}>
36
36
  <HiddenInput
37
- accept={accept || 'video/*'}
37
+ accept={accept}
38
38
  ref={inputRef}
39
39
  tabIndex={0}
40
40
  type="file"
@@ -2,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="video/*"
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
- : 'File From URL (Unknown size)'}
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={!basicConfig && !config.public_policy && !config.signed_policy}
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={() => startUpload(formatUploadConfig(config))}
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 video or paste URL here
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: any}
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 = event.clipboardData || (window as any).clipboardData
277
- const url = clipboardData.getData('text')?.trim()
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
- setDragState(type?.startsWith('video/') ? 'valid' : 'invalid')
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
- <svg xmlns="http://www.w3.org/2000/svg" width="3em" viewBox="0 0 24 24">
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
- {asset: VideoAssetDocument; thumbnailWidth?: number; forceAspectRatio?: number} & Partial<
22
- Pick<MuxPlayerProps, 'autoPlay'>
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 tone="transparent" style={{aspectRatio: aspectRatio, position: 'relative'}}>
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
+ }
package/src/util/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {ObjectInputProps, PreviewLayoutKey, PreviewProps, SchemaType} from 'sanity'
2
2
  import type {PartialDeep} from 'type-fest'
3
+ import type MuxPlayerElement from '@mux/mux-player'
3
4
 
4
5
  /**
5
6
  * Standard static rendition options available for plugin configuration defaults
@@ -122,6 +123,50 @@ export interface MuxInputConfig {
122
123
  * @defaultValue false
123
124
  */
124
125
  disableTextTrackConfig?: boolean
126
+
127
+ /**
128
+ * The mime types that are accepted by the input.
129
+ *
130
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept}
131
+ * @defaultValue ['video/*','audio/*']
132
+
133
+ */
134
+ acceptedMimeTypes?: ('audio/*' | 'video/*')[]
135
+
136
+ /**
137
+ * Maximum file size allowed for video uploads in bytes.
138
+ * If not specified, no file size validation will be performed.
139
+ *
140
+ * @example 1024 * 1024 * 1024 // 1 GB
141
+ * @defaultValue undefined
142
+ */
143
+ maxAssetFileSize?: number
144
+
145
+ /**
146
+ * Maximum video duration allowed in seconds.
147
+ * If not specified, no duration validation will be performed.
148
+ *
149
+ * @example 2 * 60 * 60 // 2 hours
150
+ * @defaultValue undefined
151
+ */
152
+ maxAssetDuration?: number
153
+
154
+ /**
155
+ * HLS.js configuration options to be passed to the Mux Player.
156
+ * These options allow you to customize the underlying HLS.js playback engine behavior.
157
+ *
158
+ * @see {@link https://github.com/video-dev/hls.js/blob/master/docs/API.md#fine-tuning}
159
+ * @defaultValue undefined
160
+ * @example
161
+ * ```ts
162
+ * {
163
+ * maxBufferLength: 30,
164
+ * lowLatencyMode: true,
165
+ * capLevelToPlayerSize: true
166
+ * }
167
+ * ```
168
+ */
169
+ hlsConfig?: MuxPlayerElement['_hlsConfig']
125
170
  }
126
171
 
127
172
  export interface PluginConfig extends MuxInputConfig {