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.
- package/LICENSE +1 -1
- package/README.md +133 -0
- package/dist/index.d.mts +63 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +1564 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1565 -154
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/_exports/index.ts +1 -0
- package/src/actions/assets.ts +75 -0
- package/src/components/AddCaptionDialog.tsx +421 -0
- package/src/components/CaptionsDialog.tsx +23 -0
- package/src/components/EditCaptionDialog.tsx +508 -0
- package/src/components/FileInputButton.tsx +2 -2
- package/src/components/Onboard.tsx +2 -2
- package/src/components/PageSelector.tsx +57 -0
- package/src/components/Player.tsx +4 -3
- package/src/components/PlayerActionsMenu.tsx +17 -8
- package/src/components/TextTracksManager.tsx +781 -0
- package/src/components/UploadConfiguration.tsx +181 -4
- package/src/components/UploadPlaceholder.tsx +14 -6
- package/src/components/Uploader.styled.tsx +8 -15
- package/src/components/Uploader.tsx +61 -6
- package/src/components/VideoDetails/VideoDetails.tsx +16 -0
- package/src/components/VideoInBrowser.tsx +2 -7
- package/src/components/VideoPlayer.tsx +35 -6
- package/src/components/VideosBrowser.tsx +9 -1
- package/src/components/icons/Audio.tsx +13 -0
- package/src/hooks/useAccessControl.ts +1 -0
- package/src/hooks/useDialogState.ts +1 -1
- package/src/util/getVideoMetadata.ts +3 -1
- package/src/util/textTracks.ts +219 -0
- package/src/util/types.ts +56 -3
- 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 "
|
|
728
|
+
{trackToDelete.name || trackToDelete.language_code || 'Untitled'}"?
|
|
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
|
+
}
|