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
@@ -0,0 +1,781 @@
1
+ import {
2
+ AddIcon,
3
+ ChevronDownIcon,
4
+ ChevronUpIcon,
5
+ DownloadIcon,
6
+ EditIcon,
7
+ ErrorOutlineIcon,
8
+ TrashIcon,
9
+ } from '@sanity/icons'
10
+ import {Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text, useToast} from '@sanity/ui'
11
+ import {useEffect, useId, useMemo, useState} from 'react'
12
+
13
+ import {deleteTextTrack, getAsset} from '../actions/assets'
14
+ import {useClient} from '../hooks/useClient'
15
+ import {downloadVttFile} from '../util/textTracks'
16
+ import type {MuxTextTrack, VideoAssetDocument} from '../util/types'
17
+ import AddCaptionDialog from './AddCaptionDialog'
18
+ import EditCaptionDialog from './EditCaptionDialog'
19
+
20
+ interface TrackCardProps {
21
+ track: MuxTextTrack
22
+ iconOnly: boolean
23
+ downloadingTrackId: string | null
24
+ deletingTrackId: string | null
25
+ trackToEdit: MuxTextTrack | null
26
+ getTrackSourceLabel: (track: MuxTextTrack) => string
27
+ handleDownload: (track: MuxTextTrack) => void
28
+ setTrackToEdit: (track: MuxTextTrack) => void
29
+ setTrackToDelete: (track: MuxTextTrack) => void
30
+ }
31
+
32
+ function TrackCard({
33
+ track,
34
+ iconOnly,
35
+ downloadingTrackId,
36
+ deletingTrackId,
37
+ trackToEdit,
38
+ getTrackSourceLabel,
39
+ handleDownload,
40
+ setTrackToEdit,
41
+ setTrackToDelete,
42
+ }: TrackCardProps) {
43
+ const isDisabled = (action: 'download' | 'edit' | 'delete') => {
44
+ if (action === 'download') {
45
+ return (
46
+ downloadingTrackId !== null || deletingTrackId === track.id || trackToEdit?.id === track.id
47
+ )
48
+ }
49
+ if (action === 'edit') {
50
+ return (
51
+ downloadingTrackId === track.id ||
52
+ deletingTrackId === track.id ||
53
+ trackToEdit?.id === track.id
54
+ )
55
+ }
56
+ return (
57
+ downloadingTrackId === track.id || deletingTrackId !== null || trackToEdit?.id === track.id
58
+ )
59
+ }
60
+
61
+ const renderActionButtons = () => {
62
+ if (track.status === 'preparing') {
63
+ return (
64
+ <Flex align="center" gap={2}>
65
+ <Spinner
66
+ muted
67
+ style={{
68
+ width: '0.75em',
69
+ height: '0.75em',
70
+ verticalAlign: 'middle',
71
+ display: 'inline-block',
72
+ marginBottom: '-2px',
73
+ }}
74
+ />
75
+ <Text size={1} muted>
76
+ Processing...
77
+ </Text>
78
+ </Flex>
79
+ )
80
+ }
81
+
82
+ return (
83
+ <Flex gap={2}>
84
+ {track.status !== 'errored' && (
85
+ <Button
86
+ icon={
87
+ downloadingTrackId === track.id ? (
88
+ <Spinner
89
+ style={{
90
+ verticalAlign: 'middle',
91
+ display: 'inline-block',
92
+ marginTop: '-2px',
93
+ width: '0.5em',
94
+ height: '0.5em',
95
+ }}
96
+ />
97
+ ) : (
98
+ <DownloadIcon />
99
+ )
100
+ }
101
+ text={iconOnly ? undefined : 'Download'}
102
+ mode="ghost"
103
+ tone="primary"
104
+ fontSize={1}
105
+ padding={2}
106
+ onClick={() => handleDownload(track)}
107
+ disabled={isDisabled('download')}
108
+ title="Download"
109
+ />
110
+ )}
111
+ <Button
112
+ icon={<EditIcon />}
113
+ text={iconOnly ? undefined : 'Edit'}
114
+ mode="ghost"
115
+ tone="primary"
116
+ fontSize={1}
117
+ padding={2}
118
+ disabled={isDisabled('edit')}
119
+ onClick={() => setTrackToEdit(track)}
120
+ title="Edit"
121
+ />
122
+ <Button
123
+ icon={
124
+ deletingTrackId === track.id ? (
125
+ <Spinner
126
+ style={{
127
+ verticalAlign: 'middle',
128
+ display: 'inline-block',
129
+ marginTop: '-2px',
130
+ width: '0.5em',
131
+ height: '0.5em',
132
+ }}
133
+ />
134
+ ) : (
135
+ <TrashIcon />
136
+ )
137
+ }
138
+ text={iconOnly ? undefined : 'Delete'}
139
+ mode="ghost"
140
+ tone="critical"
141
+ fontSize={1}
142
+ padding={2}
143
+ disabled={isDisabled('delete')}
144
+ onClick={() => setTrackToDelete(track)}
145
+ title="Delete"
146
+ />
147
+ </Flex>
148
+ )
149
+ }
150
+
151
+ return (
152
+ <Card
153
+ padding={3}
154
+ radius={2}
155
+ tone={track.status === 'errored' ? 'caution' : 'transparent'}
156
+ border
157
+ >
158
+ <Flex align="center" justify="space-between" gap={3}>
159
+ <Stack space={2} flex={1}>
160
+ <Flex align="center" gap={2}>
161
+ <Text weight="semibold">{track.name || 'Untitled'}</Text>
162
+ <Text size={1} muted>
163
+ ({getTrackSourceLabel(track)})
164
+ </Text>
165
+ {track.status === 'errored' && (
166
+ <ErrorOutlineIcon
167
+ style={{color: 'var(--card-critical-color)'}}
168
+ aria-label="Error"
169
+ fontSize={20}
170
+ />
171
+ )}
172
+ </Flex>
173
+ {track.language_code && (
174
+ <Text size={1} muted>
175
+ Language: {track.language_code}
176
+ </Text>
177
+ )}
178
+ {track.status === 'errored' && track.error && (
179
+ <Text size={1} style={{color: 'var(--card-critical-color)'}}>
180
+ {track.error.messages?.[0] || track.error.type || 'Failed to process track'}
181
+ </Text>
182
+ )}
183
+ </Stack>
184
+ {renderActionButtons()}
185
+ </Flex>
186
+ </Card>
187
+ )
188
+ }
189
+
190
+ interface TextTracksManagerProps {
191
+ asset: VideoAssetDocument
192
+ iconOnly?: boolean
193
+ tracks?: MuxTextTrack[]
194
+ collapseTracks?: boolean
195
+ }
196
+
197
+ export default function TextTracksManager({
198
+ asset,
199
+ iconOnly = false,
200
+ tracks: propTracks,
201
+ collapseTracks = false,
202
+ }: TextTracksManagerProps) {
203
+ const client = useClient()
204
+ const toast = useToast()
205
+ const dialogId = `DeleteCaptionDialog${useId()}`
206
+ const [downloadingTrackId, setDownloadingTrackId] = useState<string | null>(null)
207
+ const [deletingTrackId, setDeletingTrackId] = useState<string | null>(null)
208
+ const [addedTracks, setAddedTracks] = useState<MuxTextTrack[]>([])
209
+ const [updatedTracks, setUpdatedTracks] = useState<Map<string, MuxTextTrack>>(new Map())
210
+ const [trackActivityOrder, setTrackActivityOrder] = useState<Map<string, number>>(new Map())
211
+ const [autogeneratedTrackIds, setAutogeneratedTrackIds] = useState<Set<string>>(new Set())
212
+ const [trackToDelete, setTrackToDelete] = useState<MuxTextTrack | null>(null)
213
+ const [trackToEdit, setTrackToEdit] = useState<MuxTextTrack | null>(null)
214
+ const [showAddDialog, setShowAddDialog] = useState(false)
215
+ const [isExpanded, setIsExpanded] = useState(false)
216
+
217
+ const MAX_VISIBLE_TRACKS = 4
218
+
219
+ useEffect(() => {
220
+ if (!asset.assetId || !asset._id) return
221
+
222
+ const assetId = asset.assetId
223
+ const documentId = asset._id
224
+
225
+ const refreshAsset = async () => {
226
+ try {
227
+ const response = await getAsset(client, assetId)
228
+ await client
229
+ .patch(documentId)
230
+ .set({data: response.data, status: response.data.status})
231
+ .commit()
232
+ } catch (error) {
233
+ console.error('Failed to refresh asset data:', error)
234
+ }
235
+ }
236
+
237
+ refreshAsset()
238
+ }, [asset.assetId, asset._id, client])
239
+
240
+ const realTracks: MuxTextTrack[] = propTracks
241
+ ? propTracks
242
+ : asset.data?.tracks?.filter((track): track is MuxTextTrack => track.type === 'text') || []
243
+
244
+ const activeTracks = realTracks.filter(
245
+ (track) =>
246
+ track.id &&
247
+ (track.status === 'ready' || track.status === 'preparing' || track.status === 'errored')
248
+ )
249
+
250
+ const allTracks = useMemo(() => {
251
+ const tracksWithUpdates = activeTracks.map((track) => {
252
+ const updated = updatedTracks.get(track.id)
253
+ return updated || track
254
+ })
255
+
256
+ const isMockTrackReplaced = (mockTrack: MuxTextTrack, realTracksList: MuxTextTrack[]) => {
257
+ if (!mockTrack.id || !mockTrack.id.startsWith('generating-')) {
258
+ return false
259
+ }
260
+ return realTracksList.some((realTrack) => {
261
+ const nameMatches = realTrack.name === mockTrack.name
262
+ const languageMatches = realTrack.language_code === mockTrack.language_code
263
+ if (!nameMatches || !languageMatches) {
264
+ return false
265
+ }
266
+ if (realTrack.status === 'ready') {
267
+ const isGenerated =
268
+ realTrack.text_source === 'generated_live' ||
269
+ realTrack.text_source === 'generated_live_final' ||
270
+ realTrack.text_source === 'generated_vod'
271
+ return isGenerated
272
+ }
273
+ if (realTrack.status === 'preparing') {
274
+ return true
275
+ }
276
+ return false
277
+ })
278
+ }
279
+
280
+ const isTrackAlreadyInRealTracks = (
281
+ addedTrack: MuxTextTrack,
282
+ realTracksList: MuxTextTrack[]
283
+ ) => {
284
+ if (!addedTrack.id) return false
285
+ if (addedTrack.id.startsWith('generating-')) {
286
+ return isMockTrackReplaced(addedTrack, realTracksList)
287
+ }
288
+ return realTracksList.some((realTrack) => realTrack.id === addedTrack.id)
289
+ }
290
+
291
+ const tracksToKeep = addedTracks.filter((addedTrack) => {
292
+ if (addedTrack.id && addedTrack.id.startsWith('generating-')) {
293
+ return !isMockTrackReplaced(addedTrack, tracksWithUpdates)
294
+ }
295
+ return !isTrackAlreadyInRealTracks(addedTrack, tracksWithUpdates)
296
+ })
297
+
298
+ return [...tracksWithUpdates, ...tracksToKeep]
299
+ }, [activeTracks, addedTracks, updatedTracks])
300
+
301
+ useEffect(() => {
302
+ const newAutogeneratedIds = new Set<string>()
303
+
304
+ activeTracks.forEach((track) => {
305
+ if (
306
+ track.id &&
307
+ (track.text_source === 'generated_live' ||
308
+ track.text_source === 'generated_live_final' ||
309
+ track.text_source === 'generated_vod')
310
+ ) {
311
+ newAutogeneratedIds.add(track.id)
312
+ }
313
+ })
314
+
315
+ addedTracks.forEach((mockTrack) => {
316
+ if (mockTrack.id && mockTrack.id.startsWith('generating-')) {
317
+ const realTrack = activeTracks.find((rt) => {
318
+ const nameMatches = rt.name === mockTrack.name
319
+ const languageMatches = rt.language_code === mockTrack.language_code
320
+ return nameMatches && languageMatches
321
+ })
322
+ if (realTrack?.id) {
323
+ newAutogeneratedIds.add(realTrack.id)
324
+ }
325
+ }
326
+ })
327
+
328
+ setAutogeneratedTrackIds((prev) => {
329
+ let hasNew = false
330
+ const updated = new Set(prev)
331
+ newAutogeneratedIds.forEach((id) => {
332
+ if (!prev.has(id)) {
333
+ updated.add(id)
334
+ hasNew = true
335
+ }
336
+ })
337
+ return hasNew ? updated : prev
338
+ })
339
+ }, [activeTracks, addedTracks])
340
+
341
+ useEffect(() => {
342
+ const preparingTracks = allTracks.filter((track) => track.status === 'preparing')
343
+ if (preparingTracks.length === 0 || !asset.assetId || !asset._id) {
344
+ return undefined
345
+ }
346
+
347
+ const assetId = asset.assetId
348
+ const documentId = asset._id
349
+
350
+ const interval = setInterval(async () => {
351
+ try {
352
+ const response = await getAsset(client, assetId)
353
+ await client
354
+ .patch(documentId)
355
+ .set({data: response.data, status: response.data.status})
356
+ .commit()
357
+
358
+ const fetchedTracks =
359
+ response.data.tracks?.filter((track): track is MuxTextTrack => track.type === 'text') ||
360
+ []
361
+
362
+ const isMockTrackReplaced = (
363
+ mockTrack: MuxTextTrack,
364
+ fetchedTracksList: MuxTextTrack[]
365
+ ) => {
366
+ if (!mockTrack.id || !mockTrack.id.startsWith('generating-')) {
367
+ return false
368
+ }
369
+ return fetchedTracksList.some((realTrack) => {
370
+ const nameMatches = realTrack.name === mockTrack.name
371
+ const languageMatches = realTrack.language_code === mockTrack.language_code
372
+ if (!nameMatches || !languageMatches) {
373
+ return false
374
+ }
375
+ if (realTrack.status === 'ready') {
376
+ const isGenerated =
377
+ realTrack.text_source === 'generated_live' ||
378
+ realTrack.text_source === 'generated_live_final' ||
379
+ realTrack.text_source === 'generated_vod'
380
+ return isGenerated
381
+ }
382
+ if (realTrack.status === 'preparing') {
383
+ return true
384
+ }
385
+ return false
386
+ })
387
+ }
388
+
389
+ const newAutogeneratedIds = new Set<string>()
390
+ fetchedTracks.forEach((track) => {
391
+ if (
392
+ track.id &&
393
+ (track.text_source === 'generated_live' ||
394
+ track.text_source === 'generated_live_final' ||
395
+ track.text_source === 'generated_vod')
396
+ ) {
397
+ newAutogeneratedIds.add(track.id)
398
+ }
399
+ })
400
+
401
+ const findMatchingRealTrack = (mockTrack: MuxTextTrack, tracksList: MuxTextTrack[]) => {
402
+ return tracksList.find((rt) => {
403
+ const nameMatches = rt.name === mockTrack.name
404
+ const languageMatches = rt.language_code === mockTrack.language_code
405
+ return nameMatches && languageMatches
406
+ })
407
+ }
408
+
409
+ setAddedTracks((prev) => {
410
+ return prev.filter((mockTrack) => {
411
+ if (mockTrack.id && mockTrack.id.startsWith('generating-')) {
412
+ const replaced = isMockTrackReplaced(mockTrack, fetchedTracks)
413
+ if (replaced) {
414
+ const realTrack = findMatchingRealTrack(mockTrack, fetchedTracks)
415
+ if (realTrack?.id) {
416
+ newAutogeneratedIds.add(realTrack.id)
417
+ setTrackActivityOrder((prevOrder) => {
418
+ const mockOrder = prevOrder.get(mockTrack.id)
419
+ if (mockOrder) {
420
+ const newMap = new Map(prevOrder)
421
+ newMap.set(realTrack.id, mockOrder)
422
+ return newMap
423
+ }
424
+ return prevOrder
425
+ })
426
+ }
427
+ }
428
+ return !replaced
429
+ }
430
+ return true
431
+ })
432
+ })
433
+
434
+ if (newAutogeneratedIds.size > 0) {
435
+ setAutogeneratedTrackIds((prevIds) => {
436
+ const updated = new Set(prevIds)
437
+ newAutogeneratedIds.forEach((id) => updated.add(id))
438
+ return updated
439
+ })
440
+ }
441
+ } catch (error) {
442
+ console.error('Failed to refresh asset data:', error)
443
+ }
444
+ }, 3000) // Poll every 3 seconds
445
+
446
+ return () => clearInterval(interval)
447
+ }, [allTracks, asset.assetId, asset._id, client])
448
+
449
+ const visibleTracks = allTracks
450
+ .filter(
451
+ (track) =>
452
+ track.status === 'ready' || track.status === 'preparing' || track.status === 'errored'
453
+ )
454
+ .sort((a, b) => {
455
+ const orderA = trackActivityOrder.get(a.id) || 0
456
+ const orderB = trackActivityOrder.get(b.id) || 0
457
+
458
+ if (orderA > 0 && orderB > 0) {
459
+ return orderB - orderA
460
+ }
461
+
462
+ if (orderA > 0) return -1
463
+ if (orderB > 0) return 1
464
+
465
+ const aIsPreparing = a.status === 'preparing'
466
+ const bIsPreparing = b.status === 'preparing'
467
+ if (aIsPreparing && !bIsPreparing) return -1
468
+ if (!aIsPreparing && bIsPreparing) return 1
469
+
470
+ const aIsAutogenerated =
471
+ (a.id && a.id.startsWith('generating-')) || (a.id && autogeneratedTrackIds.has(a.id))
472
+ const bIsAutogenerated =
473
+ (b.id && b.id.startsWith('generating-')) || (b.id && autogeneratedTrackIds.has(b.id))
474
+ if (aIsAutogenerated && !bIsAutogenerated) return -1
475
+ if (!aIsAutogenerated && bIsAutogenerated) return 1
476
+
477
+ return 0
478
+ })
479
+
480
+ const handleDownload = async (track: MuxTextTrack) => {
481
+ if (!track.id) return
482
+
483
+ setDownloadingTrackId(track.id)
484
+ try {
485
+ await downloadVttFile(client, asset, track)
486
+ } catch (error) {
487
+ toast.push({
488
+ title: 'Failed to download VTT file',
489
+ status: 'error',
490
+ description: error instanceof Error ? error.message : 'Please try again',
491
+ })
492
+ } finally {
493
+ setDownloadingTrackId(null)
494
+ }
495
+ }
496
+
497
+ const confirmDelete = async () => {
498
+ if (!trackToDelete || !trackToDelete.id) return
499
+
500
+ const track = trackToDelete
501
+ setTrackToDelete(null)
502
+ setDeletingTrackId(track.id)
503
+ try {
504
+ if (!asset.assetId) {
505
+ throw new Error('Asset ID is required')
506
+ }
507
+ await deleteTextTrack(client, asset.assetId, track.id)
508
+
509
+ if (asset._id) {
510
+ try {
511
+ const response = await getAsset(client, asset.assetId)
512
+ await client
513
+ .patch(asset._id)
514
+ .set({data: response.data, status: response.data.status})
515
+ .commit()
516
+ } catch (refreshError) {
517
+ console.error('Failed to refresh asset data:', refreshError)
518
+ }
519
+ }
520
+
521
+ toast.push({
522
+ title: 'Successfully deleted caption track',
523
+ status: 'success',
524
+ })
525
+
526
+ setAddedTracks((prev) => prev.filter((t) => t.id !== track.id))
527
+ setUpdatedTracks((prev) => {
528
+ const newMap = new Map(prev)
529
+ newMap.delete(track.id)
530
+ return newMap
531
+ })
532
+ setTrackActivityOrder((prev) => {
533
+ const newMap = new Map(prev)
534
+ newMap.delete(track.id)
535
+ return newMap
536
+ })
537
+ setAutogeneratedTrackIds((prev) => {
538
+ const updated = new Set(prev)
539
+ updated.delete(track.id)
540
+ return updated
541
+ })
542
+ } catch (error) {
543
+ toast.push({
544
+ title: 'Failed to delete caption track',
545
+ status: 'error',
546
+ description: error instanceof Error ? error.message : 'Please try again',
547
+ })
548
+ } finally {
549
+ setDeletingTrackId(null)
550
+ }
551
+ }
552
+
553
+ const handleAddTrack = (track: MuxTextTrack) => {
554
+ setAddedTracks((prev) => [...prev, track])
555
+ setTrackActivityOrder((prev) => {
556
+ const newMap = new Map(prev)
557
+ newMap.set(track.id, prev.size + 1)
558
+ return newMap
559
+ })
560
+ setShowAddDialog(false)
561
+ }
562
+
563
+ const handleUpdateTrack = async (updatedTrack: MuxTextTrack, oldTrackId?: string) => {
564
+ if (oldTrackId) {
565
+ setAddedTracks((prev) => prev.filter((t) => t.id !== oldTrackId))
566
+ setUpdatedTracks((prev) => {
567
+ const newMap = new Map(prev)
568
+ newMap.delete(oldTrackId)
569
+ return newMap
570
+ })
571
+ setTrackActivityOrder((prev) => {
572
+ const newMap = new Map(prev)
573
+ newMap.delete(oldTrackId)
574
+ return newMap
575
+ })
576
+ setAutogeneratedTrackIds((prev) => {
577
+ const updated = new Set(prev)
578
+ updated.delete(oldTrackId)
579
+ return updated
580
+ })
581
+ }
582
+
583
+ const isAddedTrack = addedTracks.some((t) => t.id === updatedTrack.id)
584
+
585
+ if (isAddedTrack) {
586
+ setAddedTracks((prev) => prev.map((t) => (t.id === updatedTrack.id ? updatedTrack : t)))
587
+ } else {
588
+ setUpdatedTracks((prev) => {
589
+ const newMap = new Map(prev)
590
+ newMap.set(updatedTrack.id, updatedTrack)
591
+ return newMap
592
+ })
593
+ }
594
+
595
+ setTrackActivityOrder((prev) => {
596
+ const newMap = new Map(prev)
597
+ newMap.set(updatedTrack.id, prev.size + 1)
598
+ return newMap
599
+ })
600
+
601
+ setTrackToEdit(null)
602
+
603
+ if (asset._id && asset.assetId) {
604
+ try {
605
+ const response = await getAsset(client, asset.assetId)
606
+ await client
607
+ .patch(asset._id)
608
+ .set({data: response.data, status: response.data.status})
609
+ .commit()
610
+ } catch (refreshError) {
611
+ console.error('Failed to refresh asset data:', refreshError)
612
+ }
613
+ }
614
+ }
615
+
616
+ const getTrackSourceLabel = (track: MuxTextTrack) => {
617
+ if (track.id && track.id.startsWith('generating-')) {
618
+ return 'Auto-generated'
619
+ }
620
+ if (track.id && autogeneratedTrackIds.has(track.id)) {
621
+ return 'Auto-generated'
622
+ }
623
+ if (
624
+ track.text_source === 'generated_live_final' ||
625
+ track.text_source === 'generated_live' ||
626
+ track.text_source === 'generated_vod'
627
+ ) {
628
+ return 'Auto-generated'
629
+ }
630
+ if (track.text_source === 'uploaded') {
631
+ return 'Uploaded'
632
+ }
633
+ return 'Custom'
634
+ }
635
+
636
+ if (visibleTracks.length === 0 && !showAddDialog) {
637
+ return (
638
+ <Stack space={3}>
639
+ <Flex justify="flex-end">
640
+ <Button
641
+ icon={AddIcon}
642
+ text="Add Caption"
643
+ tone="primary"
644
+ onClick={() => setShowAddDialog(true)}
645
+ />
646
+ </Flex>
647
+ <Card padding={4} radius={2} tone="transparent" border>
648
+ <Text size={1} muted>
649
+ No captions available. Add captions when uploading a video or add them manually.
650
+ </Text>
651
+ </Card>
652
+ {showAddDialog && (
653
+ <AddCaptionDialog
654
+ asset={asset}
655
+ onAdd={handleAddTrack}
656
+ onClose={() => setShowAddDialog(false)}
657
+ />
658
+ )}
659
+ </Stack>
660
+ )
661
+ }
662
+
663
+ const displayedTracks =
664
+ collapseTracks && !isExpanded ? visibleTracks.slice(0, MAX_VISIBLE_TRACKS) : visibleTracks
665
+ const hasMoreTracks = collapseTracks && visibleTracks.length > MAX_VISIBLE_TRACKS
666
+
667
+ return (
668
+ <Stack space={3}>
669
+ <Flex justify="flex-end">
670
+ <Button
671
+ icon={AddIcon}
672
+ text="Add Caption"
673
+ tone="primary"
674
+ onClick={() => setShowAddDialog(true)}
675
+ />
676
+ </Flex>
677
+
678
+ {displayedTracks.map((track) => (
679
+ <TrackCard
680
+ key={track.id}
681
+ track={track}
682
+ iconOnly={iconOnly}
683
+ downloadingTrackId={downloadingTrackId}
684
+ deletingTrackId={deletingTrackId}
685
+ trackToEdit={trackToEdit}
686
+ getTrackSourceLabel={getTrackSourceLabel}
687
+ handleDownload={handleDownload}
688
+ setTrackToEdit={setTrackToEdit}
689
+ setTrackToDelete={setTrackToDelete}
690
+ />
691
+ ))}
692
+
693
+ {hasMoreTracks && (
694
+ <Flex justify="center">
695
+ <Button
696
+ icon={isExpanded ? ChevronUpIcon : ChevronDownIcon}
697
+ text={
698
+ isExpanded ? 'Show less' : `Show ${visibleTracks.length - MAX_VISIBLE_TRACKS} more`
699
+ }
700
+ mode="ghost"
701
+ tone="primary"
702
+ onClick={() => setIsExpanded(!isExpanded)}
703
+ />
704
+ </Flex>
705
+ )}
706
+
707
+ {trackToDelete && (
708
+ <Dialog
709
+ animate
710
+ id={dialogId}
711
+ header="Delete track"
712
+ onClose={() => setTrackToDelete(null)}
713
+ onClickOutside={() => setTrackToDelete(null)}
714
+ width={1}
715
+ >
716
+ <Card
717
+ padding={3}
718
+ style={{
719
+ minHeight: '150px',
720
+ display: 'flex',
721
+ alignItems: 'center',
722
+ justifyContent: 'center',
723
+ }}
724
+ >
725
+ <Stack space={3}>
726
+ <Heading size={2}>
727
+ Are you sure you want to delete &quot;
728
+ {trackToDelete.name || trackToDelete.language_code || 'Untitled'}&quot;?
729
+ </Heading>
730
+ <Text size={2}>This action is irreversible</Text>
731
+ <Stack space={4} marginY={4}>
732
+ <Box>
733
+ <Button
734
+ icon={
735
+ deletingTrackId === trackToDelete.id ? (
736
+ <Spinner
737
+ style={{
738
+ verticalAlign: 'middle',
739
+ display: 'inline-block',
740
+ marginTop: '-2px',
741
+ width: '0.5em',
742
+ height: '0.5em',
743
+ }}
744
+ />
745
+ ) : (
746
+ <TrashIcon />
747
+ )
748
+ }
749
+ fontSize={2}
750
+ padding={3}
751
+ text="Delete track"
752
+ tone="critical"
753
+ onClick={confirmDelete}
754
+ disabled={deletingTrackId !== null}
755
+ />
756
+ </Box>
757
+ </Stack>
758
+ </Stack>
759
+ </Card>
760
+ </Dialog>
761
+ )}
762
+
763
+ {showAddDialog && (
764
+ <AddCaptionDialog
765
+ asset={asset}
766
+ onAdd={handleAddTrack}
767
+ onClose={() => setShowAddDialog(false)}
768
+ />
769
+ )}
770
+
771
+ {trackToEdit && (
772
+ <EditCaptionDialog
773
+ asset={asset}
774
+ track={trackToEdit}
775
+ onUpdate={handleUpdateTrack}
776
+ onClose={() => setTrackToEdit(null)}
777
+ />
778
+ )}
779
+ </Stack>
780
+ )
781
+ }