sanity-plugin-mux-input 2.2.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +154 -22
  3. package/lib/index.cjs +4002 -3669
  4. package/lib/index.cjs.map +1 -1
  5. package/lib/index.d.cts +210 -0
  6. package/lib/index.d.ts +109 -25
  7. package/lib/index.esm.js +4384 -0
  8. package/lib/index.esm.js.map +1 -0
  9. package/lib/index.js +3967 -3621
  10. package/lib/index.js.map +1 -1
  11. package/package.json +47 -51
  12. package/src/_exports/index.ts +32 -0
  13. package/src/actions/upload.ts +35 -40
  14. package/src/clients/upChunkObservable.ts +5 -1
  15. package/src/components/ConfigureApi.tsx +0 -1
  16. package/src/components/FileInputArea.tsx +92 -0
  17. package/src/components/FileInputButton.tsx +3 -2
  18. package/src/components/FileInputMenuItem.styled.tsx +2 -2
  19. package/src/components/FileInputMenuItem.tsx +2 -10
  20. package/src/components/ImportVideosFromMux.tsx +317 -0
  21. package/src/components/Input.tsx +3 -3
  22. package/src/components/PlayerActionsMenu.tsx +14 -12
  23. package/src/components/SelectAsset.tsx +1 -1
  24. package/src/components/StudioTool.tsx +11 -6
  25. package/src/components/TextTracksEditor.tsx +214 -0
  26. package/src/components/UploadConfiguration.tsx +390 -0
  27. package/src/components/UploadPlaceholder.tsx +41 -55
  28. package/src/components/Uploader.styled.tsx +0 -1
  29. package/src/components/Uploader.tsx +384 -0
  30. package/src/components/VideoDetails/DeleteDialog.tsx +20 -24
  31. package/src/components/VideoDetails/VideoDetails.tsx +8 -1
  32. package/src/components/VideoMetadata.tsx +4 -1
  33. package/src/components/VideoPlayer.tsx +33 -5
  34. package/src/components/VideoThumbnail.tsx +21 -7
  35. package/src/components/VideosBrowser.tsx +6 -3
  36. package/src/components/withFocusRing/withFocusRing.ts +20 -22
  37. package/src/hooks/useClient.ts +1 -1
  38. package/src/hooks/useImportMuxAssets.ts +127 -0
  39. package/src/hooks/useMuxAssets.ts +168 -0
  40. package/src/plugin.tsx +5 -5
  41. package/src/util/asserters.ts +9 -0
  42. package/src/util/createSearchFilter.ts +5 -7
  43. package/src/util/formatBytes.ts +32 -0
  44. package/src/util/generateJwt.ts +7 -6
  45. package/src/util/getAnimatedPosterSrc.ts +1 -1
  46. package/src/util/getPlaybackId.ts +1 -1
  47. package/src/util/getPlaybackPolicy.ts +1 -1
  48. package/src/util/getVideoMetadata.ts +2 -1
  49. package/src/util/parsers.ts +5 -0
  50. package/src/util/types.ts +195 -12
  51. package/lib/index.cjs.js +0 -5
  52. package/src/components/__legacy__Uploader.tsx +0 -280
  53. package/src/index.ts +0 -29
@@ -0,0 +1,384 @@
1
+ import {ErrorOutlineIcon} from '@sanity/icons'
2
+ import {Button, CardTone, Flex, Text, useToast} from '@sanity/ui'
3
+ import React, {useEffect, useReducer, useRef, useState} from 'react'
4
+ import {Subject, Subscription, type Observable} from 'rxjs'
5
+ import {takeUntil, tap} from 'rxjs/operators'
6
+ import type {SanityClient} from 'sanity'
7
+ import {PatchEvent, set, setIfMissing} from 'sanity'
8
+
9
+ import {uploadFile, uploadUrl} from '../actions/upload'
10
+ import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
11
+ import {isValidUrl} from '../util/asserters'
12
+ import {extractDroppedFiles} from '../util/extractFiles'
13
+ import type {
14
+ MuxInputProps,
15
+ MuxNewAssetSettings,
16
+ PluginConfig,
17
+ Secrets,
18
+ VideoAssetDocument,
19
+ } from '../util/types'
20
+ import InputBrowser from './InputBrowser'
21
+ import Player from './Player'
22
+ import PlayerActionsMenu from './PlayerActionsMenu'
23
+ import UploadConfiguration from './UploadConfiguration'
24
+ import {UploadCard} from './Uploader.styled'
25
+ import UploadPlaceholder from './UploadPlaceholder'
26
+ import {UploadProgress} from './UploadProgress'
27
+
28
+ interface Props extends Pick<MuxInputProps, 'onChange' | 'readOnly'> {
29
+ config: PluginConfig
30
+ client: SanityClient
31
+ secrets: Secrets
32
+ asset: VideoAssetDocument | null | undefined
33
+ dialogState: DialogState
34
+ setDialogState: SetDialogState
35
+ needsSetup: boolean
36
+ }
37
+
38
+ export type StagedUpload = {type: 'file'; files: FileList | File[]} | {type: 'url'; url: string}
39
+ type UploadStatus = {
40
+ progress: number
41
+ file?: {name: string | undefined; type: string}
42
+ uuid?: string
43
+ url?: string
44
+ }
45
+
46
+ interface State {
47
+ stagedUpload: StagedUpload | null
48
+ uploadStatus: UploadStatus | null
49
+ error: Error | null
50
+ }
51
+
52
+ const INITIAL_STATE: State = {
53
+ stagedUpload: null,
54
+ uploadStatus: null,
55
+ error: null,
56
+ }
57
+
58
+ type UploadFileEvent = ReturnType<typeof uploadFile> extends Observable<infer T> ? T : never
59
+ type UploadUrlEvent = ReturnType<typeof uploadUrl> extends Observable<infer T> ? T : never
60
+ type UploaderStateAction =
61
+ | {action: 'stageUpload'; input: NonNullable<State['stagedUpload']>}
62
+ | {action: 'commitUpload'}
63
+ | ({action: 'progressInfo'} & (
64
+ | Extract<UploadFileEvent, {type: 'uuid' | 'file'}>
65
+ | Extract<UploadUrlEvent, {type: 'url'}>
66
+ ))
67
+ | {action: 'progress'; percent: number}
68
+ | {action: 'error'; error: any}
69
+ | {action: 'complete' | 'reset'}
70
+
71
+ /**
72
+ * The main interface for inputting a Mux Video. It handles staging an upload
73
+ * file, setting its configuration, displaying upload progress, and showing
74
+ * the preview player.
75
+ */
76
+ export default function Uploader(props: Props) {
77
+ const toast = useToast()
78
+ const containerRef = useRef<HTMLDivElement>(null)
79
+
80
+ const dragEnteredEls = useRef<EventTarget[]>([])
81
+ const [dragState, setDragState] = useState<'valid' | 'invalid' | null>(null)
82
+
83
+ const cancelUploadButton = useRef(
84
+ (() => {
85
+ const events$ = new Subject()
86
+ return {
87
+ observable: events$.asObservable(),
88
+ handleClick: ((event) => events$.next(event)) as React.MouseEventHandler<HTMLButtonElement>,
89
+ }
90
+ })()
91
+ ).current
92
+
93
+ const uploadRef = useRef<Subscription | null>(null)
94
+ const [state, dispatch] = useReducer(
95
+ (prev: State, action: UploaderStateAction) => {
96
+ switch (action.action) {
97
+ case 'stageUpload':
98
+ return Object.assign({}, INITIAL_STATE, {stagedUpload: action.input})
99
+ case 'commitUpload':
100
+ return Object.assign({}, prev, {uploadStatus: {progress: 0}})
101
+ case 'progressInfo': {
102
+ const {type, action: _, ...payload} = action
103
+ return Object.assign({}, prev, {
104
+ uploadStatus: {
105
+ ...prev.uploadStatus,
106
+ progress: prev.uploadStatus!.progress,
107
+ ...payload,
108
+ },
109
+ } satisfies Pick<typeof prev, 'uploadStatus'>)
110
+ }
111
+ case 'progress':
112
+ return Object.assign({}, prev, {
113
+ uploadStatus: {
114
+ ...prev.uploadStatus,
115
+ progress: action.percent,
116
+ },
117
+ } satisfies Pick<typeof prev, 'uploadStatus'>)
118
+ case 'reset':
119
+ case 'complete':
120
+ // Clear upload observable on completion
121
+ uploadRef.current?.unsubscribe()
122
+ uploadRef.current = null
123
+ return INITIAL_STATE
124
+ case 'error':
125
+ // Clear upload observable on error
126
+ uploadRef.current?.unsubscribe()
127
+ uploadRef.current = null
128
+ return Object.assign({}, INITIAL_STATE, {error: action.error})
129
+ default:
130
+ return prev
131
+ }
132
+ },
133
+ {
134
+ stagedUpload: null,
135
+ uploadStatus: null,
136
+ error: null,
137
+ }
138
+ )
139
+
140
+ // Make sure we close out the upload observer on dismount
141
+ useEffect(() => {
142
+ return () => {
143
+ if (uploadRef.current && !uploadRef.current.closed) {
144
+ uploadRef.current.unsubscribe()
145
+ }
146
+ }
147
+ }, [])
148
+
149
+ /* -------------------------------------------------------------------------- */
150
+ /* Uploading */
151
+ /* -------------------------------------------------------------------------- */
152
+
153
+ /**
154
+ * Begins a file or URL upload with the staged files or URL.
155
+ *
156
+ * Should only be called from the UploadConfiguration component, which provides
157
+ * the Mux configuration for the direct asset upload.
158
+ *
159
+ * @param settings The Mux new_asset_settings object to send to Sanity
160
+ * @returns
161
+ */
162
+ const startUpload = (settings: MuxNewAssetSettings) => {
163
+ const {stagedUpload} = state
164
+ if (!stagedUpload || uploadRef.current) return
165
+ dispatch({action: 'commitUpload'})
166
+ let uploadObservable: Observable<UploadFileEvent | UploadUrlEvent>
167
+ // eslint-disable-next-line default-case
168
+ switch (stagedUpload.type) {
169
+ case 'url':
170
+ uploadObservable = uploadUrl({
171
+ client: props.client,
172
+ url: stagedUpload.url,
173
+ settings,
174
+ })
175
+ break
176
+ case 'file':
177
+ uploadObservable = uploadFile({
178
+ client: props.client,
179
+ file: stagedUpload.files[0],
180
+ settings,
181
+ }).pipe(
182
+ takeUntil(
183
+ cancelUploadButton.observable.pipe(
184
+ tap(() => {
185
+ if (state.uploadStatus?.uuid) {
186
+ props.client.delete(state.uploadStatus.uuid)
187
+ }
188
+ })
189
+ )
190
+ )
191
+ )
192
+ break
193
+ }
194
+ uploadRef.current = uploadObservable.subscribe({
195
+ next: (event) => {
196
+ switch (event.type) {
197
+ case 'uuid':
198
+ case 'file':
199
+ case 'url':
200
+ dispatch({action: 'progressInfo', ...event})
201
+ break
202
+ case 'progress':
203
+ dispatch({action: 'progress', percent: event.percent})
204
+ break
205
+ case 'success':
206
+ dispatch({action: 'progress', percent: 100})
207
+ props.onChange(
208
+ PatchEvent.from([
209
+ setIfMissing({asset: {}}),
210
+ set({_type: 'reference', _weak: true, _ref: event.asset._id}, ['asset']),
211
+ ])
212
+ )
213
+ break
214
+ case 'pause':
215
+ case 'resume':
216
+ default:
217
+ break
218
+ }
219
+ },
220
+ complete: () => dispatch({action: 'complete'}),
221
+ error: (error) => dispatch({action: 'error', error}),
222
+ })
223
+ }
224
+
225
+ /* -------------------------- Upload Initialization ------------------------- */
226
+ // The below populate the uploadInput state field, which then triggers the
227
+ // upload configuration, or the startUpload function if no config is required.
228
+
229
+ // Stages an upload from the file selector
230
+ const handleUpload = (files: FileList | File[]) => {
231
+ dispatch({
232
+ action: 'stageUpload',
233
+ input: {type: 'file', files},
234
+ })
235
+ }
236
+
237
+ // Stages and validates an upload from pasting an asset URL
238
+ const handlePaste: React.ClipboardEventHandler<HTMLInputElement> = (event) => {
239
+ event.preventDefault()
240
+ event.stopPropagation()
241
+ const clipboardData = event.clipboardData || (window as any).clipboardData
242
+ const url = clipboardData.getData('text')
243
+ if (!isValidUrl(url)) {
244
+ toast.push({status: 'error', title: 'Invalid URL for Mux video input.'})
245
+ return
246
+ }
247
+ dispatch({action: 'stageUpload', input: {type: 'url', url: url}})
248
+ }
249
+
250
+ // Stages and validates an upload from dragging+dropping files or folders
251
+ const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
252
+ setDragState(null)
253
+ event.preventDefault()
254
+ event.stopPropagation()
255
+ extractDroppedFiles(event.nativeEvent.dataTransfer!).then((files) => {
256
+ dispatch({
257
+ action: 'stageUpload',
258
+ input: {type: 'file', files},
259
+ })
260
+ })
261
+ }
262
+
263
+ /* ------------------------------- Drag State ------------------------------- */
264
+
265
+ const handleDragOver: React.DragEventHandler<HTMLDivElement> = (event) => {
266
+ event.preventDefault()
267
+ event.stopPropagation()
268
+ }
269
+
270
+ const handleDragEnter: React.DragEventHandler<HTMLDivElement> = (event) => {
271
+ event.stopPropagation()
272
+ dragEnteredEls.current.push(event.target)
273
+ const type = event.dataTransfer.items?.[0]?.type
274
+ setDragState(type?.startsWith('video/') ? 'valid' : 'invalid')
275
+ }
276
+
277
+ const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
278
+ event.stopPropagation()
279
+ const idx = dragEnteredEls.current.indexOf(event.target)
280
+ if (idx > -1) {
281
+ dragEnteredEls.current.splice(idx, 1)
282
+ }
283
+ if (dragEnteredEls.current.length === 0) {
284
+ setDragState(null)
285
+ }
286
+ }
287
+
288
+ /* -------------------------------- Rendering ------------------------------- */
289
+
290
+ // Upload has errored
291
+ if (state.error !== null) {
292
+ const error = {state}
293
+ return (
294
+ <Flex gap={3} direction="column" justify="center" align="center">
295
+ <Text size={5} muted>
296
+ <ErrorOutlineIcon />
297
+ </Text>
298
+ <Text>Something went wrong</Text>
299
+ {error instanceof Error && error.message && (
300
+ <Text size={1} muted>
301
+ {error.message}
302
+ </Text>
303
+ )}
304
+ <Button text="Upload another file" onClick={() => dispatch({action: 'reset'})} />
305
+ </Flex>
306
+ )
307
+ }
308
+
309
+ // Upload is in progress
310
+ if (state.uploadStatus !== null) {
311
+ const {uploadStatus} = state
312
+ return (
313
+ <UploadProgress
314
+ onCancel={cancelUploadButton.handleClick}
315
+ progress={uploadStatus.progress}
316
+ filename={uploadStatus.file?.name || uploadStatus.url}
317
+ />
318
+ )
319
+ }
320
+
321
+ // Upload needs configuration
322
+ if (state.stagedUpload !== null) {
323
+ return (
324
+ <UploadConfiguration
325
+ stagedUpload={state.stagedUpload}
326
+ pluginConfig={props.config}
327
+ secrets={props.secrets}
328
+ startUpload={startUpload}
329
+ onClose={() => dispatch({action: 'reset'})}
330
+ />
331
+ )
332
+ }
333
+
334
+ // Default: No staged upload
335
+ let tone: CardTone | undefined
336
+ if (dragState) tone = dragState === 'valid' ? 'positive' : 'critical'
337
+
338
+ return (
339
+ <>
340
+ <UploadCard
341
+ tone={tone}
342
+ onDrop={handleDrop}
343
+ onDragOver={handleDragOver}
344
+ onDragLeave={handleDragLeave}
345
+ onDragEnter={handleDragEnter}
346
+ onPaste={handlePaste}
347
+ ref={containerRef}
348
+ >
349
+ {props.asset ? (
350
+ <Player
351
+ readOnly={props.readOnly}
352
+ asset={props.asset}
353
+ onChange={props.onChange}
354
+ buttons={
355
+ <PlayerActionsMenu
356
+ asset={props.asset}
357
+ dialogState={props.dialogState}
358
+ setDialogState={props.setDialogState}
359
+ onChange={props.onChange}
360
+ onSelect={handleUpload}
361
+ readOnly={props.readOnly}
362
+ />
363
+ }
364
+ />
365
+ ) : (
366
+ <UploadPlaceholder
367
+ hovering={dragState !== null}
368
+ onSelect={handleUpload}
369
+ readOnly={!!props.readOnly}
370
+ setDialogState={props.setDialogState}
371
+ needsSetup={props.needsSetup}
372
+ />
373
+ )}
374
+ </UploadCard>
375
+ {props.dialogState === 'select-video' && (
376
+ <InputBrowser
377
+ asset={props.asset}
378
+ onChange={props.onChange}
379
+ setDialogState={props.setDialogState}
380
+ />
381
+ )}
382
+ </>
383
+ )
384
+ }
@@ -1,12 +1,12 @@
1
1
  import {TrashIcon} from '@sanity/icons'
2
- import {Button, Card, Checkbox, Dialog, Flex, Heading, Stack, Text, useToast} from '@sanity/ui'
3
- import React, {useEffect, useState} from 'react'
4
- import {SanityDocument} from 'sanity'
2
+ import {Box, Button, Card, Checkbox, Dialog, Flex, Heading, Stack, Text, useToast} from '@sanity/ui'
3
+ import {useEffect, useState} from 'react'
4
+ import type {SanityDocument} from 'sanity'
5
5
 
6
6
  import {deleteAsset} from '../../actions/assets'
7
7
  import {useClient} from '../../hooks/useClient'
8
8
  import {DIALOGS_Z_INDEX} from '../../util/constants'
9
- import {PluginPlacement, VideoAssetDocument} from '../../util/types'
9
+ import type {PluginPlacement, VideoAssetDocument} from '../../util/types'
10
10
  import SpinnerBox from '../SpinnerBox'
11
11
  import VideoReferences from './VideoReferences'
12
12
 
@@ -69,26 +69,9 @@ export default function DeleteDialog({
69
69
  onClickOutside={cancelDelete}
70
70
  width={1}
71
71
  position="fixed"
72
- footer={
73
- <Card padding={3}>
74
- <Flex justify="space-between" align="center">
75
- <Button
76
- icon={TrashIcon}
77
- fontSize={2}
78
- padding={3}
79
- text="Delete video"
80
- tone="critical"
81
- onClick={confirmDelete}
82
- disabled={['processing_deletion', 'checkingReferences', 'cantDelete'].some(
83
- (s) => s === state
84
- )}
85
- />
86
- </Flex>
87
- </Card>
88
- }
89
72
  >
90
73
  <Card
91
- padding={5}
74
+ padding={3}
92
75
  style={{
93
76
  minHeight: '150px',
94
77
  display: 'flex',
@@ -105,7 +88,7 @@ export default function DeleteDialog({
105
88
  )}
106
89
  {state === 'cantDelete' && (
107
90
  <>
108
- <Heading size={2}>Video can't be deleted</Heading>
91
+ <Heading size={2}>Video can&apos;t be deleted</Heading>
109
92
  <Text size={2} style={{marginBottom: '2rem'}}>
110
93
  There are {references?.length} document{references && references.length > 0 && 's'}{' '}
111
94
  pointing to this video. Remove their references to this file or delete them before
@@ -122,7 +105,7 @@ export default function DeleteDialog({
122
105
  <>
123
106
  <Heading size={2}>Are you sure you want to delete this video?</Heading>
124
107
  <Text size={2}>This action is irreversible</Text>
125
- <Stack space={4} marginTop={4}>
108
+ <Stack space={4} marginY={4}>
126
109
  <Flex align="center" as="label">
127
110
  <Checkbox
128
111
  checked={deleteOnMux}
@@ -134,6 +117,19 @@ export default function DeleteDialog({
134
117
  <Checkbox disabled checked />
135
118
  <Text style={{margin: '0 10px'}}>Delete video from dataset</Text>
136
119
  </Flex>
120
+ <Box>
121
+ <Button
122
+ icon={TrashIcon}
123
+ fontSize={2}
124
+ padding={3}
125
+ text="Delete video"
126
+ tone="critical"
127
+ onClick={confirmDelete}
128
+ disabled={['processing_deletion', 'checkingReferences', 'cantDelete'].some(
129
+ (s) => s === state
130
+ )}
131
+ />
132
+ </Box>
137
133
  </Stack>
138
134
  </>
139
135
  )}
@@ -8,6 +8,7 @@ import {
8
8
  RevertIcon,
9
9
  SearchIcon,
10
10
  TrashIcon,
11
+ TagIcon,
11
12
  } from '@sanity/icons'
12
13
  import {
13
14
  Button,
@@ -221,7 +222,12 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
221
222
  selected={tab === 'references'}
222
223
  />
223
224
  </TabList>
224
- <TabPanel aria-labelledby="details-tab" id="details-panel" hidden={tab !== 'details'}>
225
+ <TabPanel
226
+ aria-labelledby="details-tab"
227
+ id="details-panel"
228
+ hidden={tab !== 'details'}
229
+ style={{wordBreak: 'break-word'}}
230
+ >
225
231
  <Stack space={4}>
226
232
  <AssetInput
227
233
  label="Video title or file name"
@@ -271,6 +277,7 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
271
277
  icon={CalendarIcon}
272
278
  size={2}
273
279
  />
280
+ <IconInfo text={`Mux ID: \n${displayInfo.id}`} icon={TagIcon} size={2} />
274
281
  </Stack>
275
282
  </Stack>
276
283
  </TabPanel>
@@ -1,4 +1,4 @@
1
- import {CalendarIcon, ClockIcon} from '@sanity/icons'
1
+ import {CalendarIcon, ClockIcon, TagIcon} from '@sanity/icons'
2
2
  import {Inline, Stack, Text} from '@sanity/ui'
3
3
  import React from 'react'
4
4
 
@@ -35,6 +35,9 @@ const VideoMetadata = (props: {asset: VideoAssetDocument}) => {
35
35
  size={1}
36
36
  muted
37
37
  />
38
+ {displayInfo.title != displayInfo.id.slice(0, 12) && (
39
+ <IconInfo text={displayInfo.id.slice(0, 12)} icon={TagIcon} size={1} muted />
40
+ )}
38
41
  </Inline>
39
42
  </Stack>
40
43
  )
@@ -1,6 +1,7 @@
1
- import MuxPlayer, {MuxPlayerProps} from '@mux/mux-player-react'
2
- import {Card} from '@sanity/ui'
3
- import React, {PropsWithChildren, useMemo} from 'react'
1
+ import MuxPlayer, {type MuxPlayerProps} from '@mux/mux-player-react'
2
+ import {ErrorOutlineIcon} from '@sanity/icons'
3
+ import {Card, Text} from '@sanity/ui'
4
+ import {type PropsWithChildren, useMemo} from 'react'
4
5
 
5
6
  import {useClient} from '../hooks/useClient'
6
7
  import {MIN_ASPECT_RATIO} from '../util/constants'
@@ -17,7 +18,17 @@ export default function VideoPlayer({
17
18
  >) {
18
19
  const client = useClient()
19
20
 
20
- const videoSrc = useMemo(() => asset?.playbackId && getVideoSrc({client, asset}), [asset, client])
21
+ const {src: videoSrc, error} = useMemo(() => {
22
+ try {
23
+ const src = asset?.playbackId && getVideoSrc({client, asset})
24
+ if (src) return {src: src}
25
+
26
+ return {error: new TypeError('Asset has no playback ID')}
27
+ // eslint-disable-next-line @typescript-eslint/no-shadow
28
+ } catch (error) {
29
+ return {error}
30
+ }
31
+ }, [asset, client])
21
32
 
22
33
  const signedToken = useMemo(() => {
23
34
  try {
@@ -46,7 +57,6 @@ export default function VideoPlayer({
46
57
  ? {playback: signedToken, thumbnail: signedToken, storyboard: signedToken}
47
58
  : undefined
48
59
  }
49
- streamType="on-demand"
50
60
  preload="metadata"
51
61
  crossOrigin="anonymous"
52
62
  metadata={{
@@ -64,6 +74,24 @@ export default function VideoPlayer({
64
74
  {children}
65
75
  </>
66
76
  )}
77
+ {error ? (
78
+ <div
79
+ style={{
80
+ position: 'absolute',
81
+ top: '50%',
82
+ left: '50%',
83
+ transform: 'translate(-50%, -50%)',
84
+ }}
85
+ >
86
+ <Text muted>
87
+ <ErrorOutlineIcon style={{marginRight: '0.15em'}} />
88
+ {typeof error === 'object' && 'message' in error && typeof error.message === 'string'
89
+ ? error.message
90
+ : 'Error loading video'}
91
+ </Text>
92
+ </div>
93
+ ) : null}
94
+ {children}
67
95
  </Card>
68
96
  )
69
97
  }
@@ -1,14 +1,13 @@
1
1
  import {ErrorOutlineIcon} from '@sanity/icons'
2
- import {Card, CardTone, Stack, Text} from '@sanity/ui'
2
+ import {Box, Card, CardTone, Spinner, Stack, Text} from '@sanity/ui'
3
3
  import React, {useMemo, useState} from 'react'
4
4
  import styled from 'styled-components'
5
5
 
6
6
  import {useClient} from '../hooks/useClient'
7
7
  import useInView from '../hooks/useInView'
8
8
  import {THUMBNAIL_ASPECT_RATIO} from '../util/constants'
9
- import {getAnimatedPosterSrc} from '../util/getAnimatedPosterSrc'
9
+ import {getAnimatedPosterSrc, type AnimatedPosterSrcOptions} from '../util/getAnimatedPosterSrc'
10
10
  import {VideoAssetDocument} from '../util/types'
11
- import SpinnerBox from './SpinnerBox'
12
11
 
13
12
  const Image = styled.img`
14
13
  transition: opacity 0.175s ease-out 0s;
@@ -29,19 +28,20 @@ const STATUS_TO_TONE: Record<ImageStatus, CardTone> = {
29
28
 
30
29
  export default function VideoThumbnail({
31
30
  asset,
32
- width = 250,
31
+ width,
33
32
  }: {
34
- asset: Partial<VideoAssetDocument>
33
+ asset: AnimatedPosterSrcOptions['asset'] & Pick<VideoAssetDocument, 'filename' | 'assetId'>
35
34
  width?: number
36
35
  }) {
37
36
  const {inView, ref} = useInView()
37
+ const posterWidth = width || 250
38
38
 
39
39
  const [status, setStatus] = useState<ImageStatus>('loading')
40
40
  const client = useClient()
41
41
 
42
42
  const animatedSrc = useMemo(() => {
43
43
  try {
44
- return getAnimatedPosterSrc({asset, client, width})
44
+ return getAnimatedPosterSrc({asset, client, width: posterWidth})
45
45
  } catch {
46
46
  if (status !== 'error') setStatus('error')
47
47
  return undefined
@@ -61,6 +61,9 @@ export default function VideoThumbnail({
61
61
  style={{
62
62
  aspectRatio: THUMBNAIL_ASPECT_RATIO,
63
63
  position: 'relative',
64
+ maxWidth: width ? `${width}px` : undefined,
65
+ width: '100%',
66
+ flex: 1,
64
67
  }}
65
68
  border
66
69
  radius={2}
@@ -69,7 +72,18 @@ export default function VideoThumbnail({
69
72
  >
70
73
  {inView ? (
71
74
  <>
72
- {status === 'loading' && <SpinnerBox />}
75
+ {status === 'loading' && (
76
+ <Box
77
+ style={{
78
+ position: 'absolute',
79
+ left: '50%',
80
+ top: '50%',
81
+ transform: 'translate(-50%, -50%)',
82
+ }}
83
+ >
84
+ <Spinner />
85
+ </Box>
86
+ )}
73
87
  {status === 'error' && (
74
88
  <Stack
75
89
  space={4}
@@ -4,10 +4,11 @@ import React from 'react'
4
4
 
5
5
  import useAssets from '../hooks/useAssets'
6
6
  import type {VideoAssetDocument} from '../util/types'
7
+ import ImportVideosFromMux from './ImportVideosFromMux'
7
8
  import {SelectSortOptions} from './SelectSortOptions'
8
9
  import SpinnerBox from './SpinnerBox'
9
- import {VideoDetailsProps} from './VideoDetails/useVideoDetails'
10
10
  import VideoDetails from './VideoDetails/VideoDetails'
11
+ import {VideoDetailsProps} from './VideoDetails/useVideoDetails'
11
12
  import VideoInBrowser from './VideoInBrowser'
12
13
 
13
14
  export interface VideosBrowserProps {
@@ -22,6 +23,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
22
23
  [editedAsset, assets]
23
24
  )
24
25
 
26
+ const placement = onSelect ? 'input' : 'tool'
25
27
  return (
26
28
  <>
27
29
  <Stack padding={4} space={4} style={{minHeight: '50vh'}}>
@@ -37,6 +39,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
37
39
  />
38
40
  <SelectSortOptions setSort={setSort} sort={sort} />
39
41
  </Flex>
42
+ {placement === 'tool' && <ImportVideosFromMux />}
40
43
  </Flex>
41
44
  <Stack space={3}>
42
45
  {assets?.length > 0 && (
@@ -64,7 +67,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
64
67
  {isLoading && <SpinnerBox />}
65
68
 
66
69
  {!isLoading && assets.length === 0 && (
67
- <Card padding={4} marginY={4} border radius={2} tone="transparent">
70
+ <Card marginY={4} paddingX={4} paddingY={6} border radius={2} tone="transparent">
68
71
  <Text align="center" muted size={3}>
69
72
  {searchQuery ? `No videos found for "${searchQuery}"` : 'No videos in this dataset'}
70
73
  </Text>
@@ -75,7 +78,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
75
78
  <VideoDetails
76
79
  closeDialog={() => setEditedAsset(null)}
77
80
  asset={freshEditedAsset}
78
- placement={onSelect ? 'input' : 'tool'}
81
+ placement={placement}
79
82
  />
80
83
  )}
81
84
  </>