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.
Files changed (35) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +133 -0
  3. package/dist/index.d.mts +63 -1
  4. package/dist/index.d.ts +63 -1
  5. package/dist/index.js +1564 -153
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1565 -154
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +1 -1
  10. package/src/_exports/index.ts +1 -0
  11. package/src/actions/assets.ts +75 -0
  12. package/src/components/AddCaptionDialog.tsx +421 -0
  13. package/src/components/CaptionsDialog.tsx +23 -0
  14. package/src/components/EditCaptionDialog.tsx +508 -0
  15. package/src/components/FileInputButton.tsx +2 -2
  16. package/src/components/Onboard.tsx +2 -2
  17. package/src/components/PageSelector.tsx +57 -0
  18. package/src/components/Player.tsx +4 -3
  19. package/src/components/PlayerActionsMenu.tsx +17 -8
  20. package/src/components/TextTracksManager.tsx +781 -0
  21. package/src/components/UploadConfiguration.tsx +181 -4
  22. package/src/components/UploadPlaceholder.tsx +14 -6
  23. package/src/components/Uploader.styled.tsx +8 -15
  24. package/src/components/Uploader.tsx +61 -6
  25. package/src/components/VideoDetails/VideoDetails.tsx +16 -0
  26. package/src/components/VideoInBrowser.tsx +2 -7
  27. package/src/components/VideoPlayer.tsx +35 -6
  28. package/src/components/VideosBrowser.tsx +9 -1
  29. package/src/components/icons/Audio.tsx +13 -0
  30. package/src/hooks/useAccessControl.ts +1 -0
  31. package/src/hooks/useDialogState.ts +1 -1
  32. package/src/util/getVideoMetadata.ts +3 -1
  33. package/src/util/textTracks.ts +219 -0
  34. package/src/util/types.ts +56 -3
  35. 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
- : '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}
@@ -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
- if (event.keyCode == ctrlKey || event.keyCode == cmdKey) {
28
- ctrlDown.current = true
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
- const vKey = 86
31
- if (ctrlDown.current && event.keyCode == vKey) {
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} onPaste={onPaste} />
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: 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},
@@ -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 = event.clipboardData || (window as any).clipboardData
277
- const url = clipboardData.getData('text')?.trim()
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
- setDragState(type?.startsWith('video/') ? 'valid' : 'invalid')
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
- <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} />
@@ -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
- {asset: VideoAssetDocument; thumbnailWidth?: number; forceAspectRatio?: number} & Partial<
22
- Pick<MuxPlayerProps, 'autoPlay'>
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 tone="transparent" style={{aspectRatio: aspectRatio, position: 'relative'}}>
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
+ }
@@ -1,4 +1,5 @@
1
1
  import {useCurrentUser} from 'sanity'
2
+
2
3
  import {PluginConfig} from '../util/types'
3
4
 
4
5
  export const useAccessControl = (config: PluginConfig) => {