PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / blocks / shared / tracks / TracksEditor.js
presto-player / src / admin / blocks / shared / tracks Last commit date
TracksEditor.js 2 weeks ago TracksEditor.scss 2 weeks ago
TracksEditor.js
672 lines
1 /**
2 * WordPress dependencies
3 */
4 const { __, sprintf } = wp.i18n;
5 import "./TracksEditor.scss";
6 const {
7 NavigableMenu,
8 MenuItem,
9 FormFileUpload,
10 MenuGroup,
11 ToolbarGroup,
12 ToolbarButton,
13 Dropdown,
14 Button,
15 TextControl,
16 Icon,
17 Modal,
18 } = wp.components;
19 const { MediaUpload, MediaUploadCheck } = wp.blockEditor;
20 const { useSelect } = wp.data;
21 const { useState, useRef } = wp.element;
22 import { TranscriptionLanguageTokenField } from "../components/TranscriptionLanguageSelect";
23
24 const ALLOWED_TYPES = ["text/vtt"];
25
26 const DEFAULT_KIND = "subtitles";
27
28 // const KIND_OPTIONS = [
29 // { label: __("Subtitles"), value: "subtitles" },
30 // { label: __("Captions"), value: "captions" },
31 // { label: __("Descriptions"), value: "descriptions" },
32 // { label: __("Chapters"), value: "chapters" },
33 // { label: __("Metadata"), value: "metadata" },
34 // ];
35
36 const captionIcon = (
37 <svg viewBox="0 0 29 25" fill="none" xmlns="http://www.w3.org/2000/svg">
38 <path
39 fillRule="evenodd"
40 clipRule="evenodd"
41 d="M17.5014 20.2854H28.6316V0.764648H0.110825V20.2854H11.241L14.3712 24.2854L17.5014 20.2854ZM14.3712 21.0401L16.5269 18.2854H26.6316V2.76465H2.11082V18.2854H12.2155L14.3712 21.0401Z"
42 />
43 <path d="M10.4503 14.9446C9.56226 14.9446 8.76226 14.7606 8.05026 14.3926C7.33826 14.0166 6.77826 13.4966 6.37026 12.8326C5.97026 12.1606 5.77026 11.4006 5.77026 10.5526C5.77026 9.70464 5.97026 8.94864 6.37026 8.28464C6.77826 7.61264 7.33826 7.09264 8.05026 6.72464C8.76226 6.34864 9.56226 6.16064 10.4503 6.16064C11.2663 6.16064 11.9943 6.30464 12.6343 6.59264C13.2743 6.88064 13.8023 7.29664 14.2183 7.84064L12.4303 9.43664C11.9103 8.78064 11.2983 8.45264 10.5943 8.45264C10.0023 8.45264 9.52626 8.64464 9.16626 9.02864C8.80626 9.40464 8.62626 9.91264 8.62626 10.5526C8.62626 11.1926 8.80626 11.7046 9.16626 12.0886C9.52626 12.4646 10.0023 12.6526 10.5943 12.6526C11.2983 12.6526 11.9103 12.3246 12.4303 11.6686L14.2183 13.2646C13.8023 13.8086 13.2743 14.2246 12.6343 14.5126C11.9943 14.8006 11.2663 14.9446 10.4503 14.9446Z" />
44 <path d="M19.2042 14.9446C18.3162 14.9446 17.5162 14.7606 16.8042 14.3926C16.0922 14.0166 15.5322 13.4966 15.1242 12.8326C14.7242 12.1606 14.5242 11.4006 14.5242 10.5526C14.5242 9.70464 14.7242 8.94864 15.1242 8.28464C15.5322 7.61264 16.0922 7.09264 16.8042 6.72464C17.5162 6.34864 18.3162 6.16064 19.2042 6.16064C20.0202 6.16064 20.7482 6.30464 21.3882 6.59264C22.0282 6.88064 22.5562 7.29664 22.9722 7.84064L21.1842 9.43664C20.6642 8.78064 20.0522 8.45264 19.3482 8.45264C18.7562 8.45264 18.2802 8.64464 17.9202 9.02864C17.5602 9.40464 17.3802 9.91264 17.3802 10.5526C17.3802 11.1926 17.5602 11.7046 17.9202 12.0886C18.2802 12.4646 18.7562 12.6526 19.3482 12.6526C20.0522 12.6526 20.6642 12.3246 21.1842 11.6686L22.9722 13.2646C22.5562 13.8086 22.0282 14.2246 21.3882 14.5126C20.7482 14.8006 20.0202 14.9446 19.2042 14.9446Z" />
45 </svg>
46 );
47
48 function TrackList({ tracks, onEditPress }) {
49 let content;
50 if (tracks.length === 0) {
51 content = (
52 <p className="block-library-video-tracks-editor__tracks-informative-message">
53 {__(
54 "Captions are .vtt files that help make your content more accesible to a wider range of users.",
55 "presto-player"
56 )}
57 </p>
58 );
59 } else {
60 content = tracks.map((track, index) => {
61 return (
62 <div
63 key={index}
64 className="block-library-video-tracks-editor__track-list-track"
65 >
66 <span>
67 {track.label}
68 {track.srcLang ? ` (${track.srcLang})` : ""}
69 </span>
70 <Button
71 isTertiary
72 onClick={() => onEditPress(index)}
73 aria-label={sprintf(
74 /* translators: %s: Label of the video text track e.g: "French subtitles" */
75 __("Edit %s", "presto-player"),
76 track.label
77 )}
78 >
79 {__("Edit", "presto-player")}
80 </Button>
81 </div>
82 );
83 });
84 }
85 return (
86 <MenuGroup
87 label={__("Captions", "presto-player")}
88 className="block-library-video-tracks-editor__track-list"
89 >
90 {content}
91 </MenuGroup>
92 );
93 }
94
95 function SingleTrackEditor({
96 track,
97 onChange,
98 onClose,
99 onRemove,
100 isBunnyNet = false,
101 }) {
102 const { src = "", label = "", srcLang = "", kind = DEFAULT_KIND } = track;
103 const fileName = src.startsWith("blob:")
104 ? ""
105 : src.substring(src.lastIndexOf("/") + 1);
106 return (
107 <NavigableMenu>
108 <div className="block-library-video-tracks-editor__single-track-editor">
109 <span className="block-library-video-tracks-editor__single-track-editor-edit-track-label">
110 {__("Edit caption track", "presto-player")}
111 </span>
112 <span>
113 {__("File", "presto-player")}: <b>{fileName}</b>
114 </span>
115 <div className="block-library-video-tracks-editor__single-track-editor-label-language">
116 <TextControl
117 /* eslint-disable jsx-a11y/no-autofocus */
118 autoFocus
119 /* eslint-enable jsx-a11y/no-autofocus */
120 onChange={(newLabel) =>
121 onChange({
122 ...track,
123 label: newLabel,
124 })
125 }
126 label={__("Label", "presto-player")}
127 value={label}
128 help={__("Title of track", "presto-player")}
129 disabled={isBunnyNet}
130 />
131 <TextControl
132 onChange={(newSrcLang) =>
133 onChange({
134 ...track,
135 srcLang: newSrcLang,
136 })
137 }
138 label={__("Source language", "presto-player")}
139 value={srcLang}
140 help={__("Language tag (en, fr, etc.)", "presto-player")}
141 disabled={isBunnyNet}
142 />
143 </div>
144 {/* <SelectControl
145 className="block-library-video-tracks-editor__single-track-editor-kind-select"
146 options={KIND_OPTIONS}
147 value={kind}
148 label={__("Kind")}
149 onChange={(newKind) => {
150 if (newKind === DEFAULT_KIND) {
151 newKind = undefined;
152 }
153 onChange({
154 ...track,
155 kind: newKind,
156 });
157 }}
158 /> */}
159 <div className="block-library-video-tracks-editor__single-track-editor-buttons-container">
160 <Button
161 isSecondary
162 onClick={() => {
163 const changes = {};
164 let hasChanges = false;
165 if (label === "") {
166 changes.label = __("English", "presto-player");
167 hasChanges = true;
168 }
169 if (srcLang === "") {
170 changes.srcLang = "en";
171 hasChanges = true;
172 }
173 if (hasChanges) {
174 onChange({
175 ...track,
176 ...changes,
177 });
178 }
179 onClose();
180 }}
181 >
182 {__("Close", "presto-player")}
183 </Button>
184 <Button isDestructive isLink onClick={onRemove}>
185 {__("Remove track", "presto-player")}
186 </Button>
187 </div>
188 </div>
189 </NavigableMenu>
190 );
191 }
192
193 export default function TracksEditor({
194 tracks = [],
195 onChange,
196 transcribeLanguages = [],
197 onTranscribeLanguagesChange,
198 videoId,
199 videoType = "public",
200 }) {
201 const mediaUpload = useSelect((select) => {
202 return select("core/block-editor").getSettings().mediaUpload;
203 }, []);
204 const [trackBeingEdited, setTrackBeingEdited] = useState(null);
205 const [isTranscribing, setIsTranscribing] = useState(false);
206 const [trackToRemove, setTrackToRemove] = useState(null);
207 const [isDeletingTrack, setIsDeletingTrack] = useState(false);
208 const [showTranscribeConfirm, setShowTranscribeConfirm] = useState(false);
209 const [transcribeSuccess, setTranscribeSuccess] = useState(false);
210 const [transcribeError, setTranscribeError] = useState(false);
211 const dropdownOnCloseRef = useRef(null);
212
213 /**
214 * Check if a track is from Bunny.net stream library
215 * Uses explicit source flag set by server-side code for robust detection
216 */
217 const isBunnyNetCaption = (track) => {
218 if (!track || !track.source) {
219 return false;
220 }
221 return track.source === "bunny";
222 };
223
224 /**
225 * Delete a caption from Bunny.net stream library
226 */
227 const deleteBunnyNetCaption = async (track) => {
228 if (!videoId || !track) {
229 return;
230 }
231
232 try {
233 // First, fetch the video data to get the Bunny.net GUID
234 const videoData = await wp.apiFetch({
235 path: `presto-player/v1/videos/${videoId}`,
236 });
237
238 const bunnyGuid = videoData?.external_id;
239 if (!bunnyGuid) {
240 console.warn(
241 "Could not find Bunny.net video GUID for caption deletion"
242 );
243 return;
244 }
245
246 // Get the language code from the track
247 const language =
248 track.srcLang || track.src?.match(/\/captions\/([^/]+)\.vtt/)?.[1];
249 if (!language) {
250 console.warn("Could not determine language code for caption deletion");
251 return;
252 }
253
254 // Delete the caption from Bunny.net
255 const deleteCaptionBase =
256 prestoPlayerAdmin?.transcriptionEndpoints?.deleteCaption;
257 const deletePath = `${deleteCaptionBase}/${bunnyGuid}/${language}`;
258 await wp.apiFetch({
259 path: wp.url.addQueryArgs(deletePath, {
260 type: videoType,
261 }),
262 method: "DELETE",
263 });
264 } catch (error) {
265 console.error("Error deleting caption from Bunny.net:", error);
266 // Show error notice
267 wp.data
268 .dispatch("core/notices")
269 .createNotice(
270 "error",
271 __(
272 "Failed to delete caption from Bunny.net. Please try again.",
273 "presto-player"
274 ),
275 {
276 type: "snackbar",
277 isDismissible: true,
278 }
279 );
280 }
281 };
282
283 /**
284 * Handle track removal - show confirmation dialog if Bunny.net caption
285 */
286 const handleTrackRemove = (trackIndex) => {
287 const track = tracks[trackIndex];
288
289 // Close the editor first to prevent accessing undefined track
290 setTrackBeingEdited(null);
291
292 // If it's a Bunny.net caption, show confirmation dialog
293 if (isBunnyNetCaption(track)) {
294 setTrackToRemove({ track, index: trackIndex });
295 } else {
296 // For non-Bunny.net tracks, remove directly
297 onChange(tracks.filter((_track, index) => index !== trackIndex));
298 }
299 };
300
301 /**
302 * Confirm track removal - delete from Bunny.net if applicable
303 */
304 const confirmTrackRemove = async () => {
305 if (!trackToRemove || isDeletingTrack) {
306 return;
307 }
308
309 const { track, index } = trackToRemove;
310
311 setIsDeletingTrack(true);
312 try {
313 // If it's a Bunny.net caption, delete it first
314 if (isBunnyNetCaption(track)) {
315 await deleteBunnyNetCaption(track);
316 }
317
318 // Remove from tracks array
319 onChange(tracks.filter((_track, i) => i !== index));
320
321 // Close the dialog
322 setTrackToRemove(null);
323 } finally {
324 setIsDeletingTrack(false);
325 }
326 };
327
328 /**
329 * Handle Bunny.net caption generation confirmation
330 */
331 const handleTranscribe = async () => {
332 if (!videoId || !transcribeLanguages.length) {
333 return;
334 }
335
336 setIsTranscribing(true);
337 try {
338 const videoData = await wp.apiFetch({
339 path: `presto-player/v1/videos/${videoId}`,
340 });
341 const bunnyGuid = videoData?.external_id;
342 if (!bunnyGuid) {
343 throw new Error("Could not find Bunny.net video GUID");
344 }
345 const transcribeEndpoint =
346 prestoPlayerAdmin?.transcriptionEndpoints?.transcribe;
347 await wp.apiFetch({
348 path: transcribeEndpoint,
349 method: "POST",
350 data: {
351 guid: bunnyGuid,
352 type: videoType,
353 targetLanguages: transcribeLanguages,
354 },
355 });
356 setTranscribeSuccess(true);
357 } catch (error) {
358 const rawErrorMessage =
359 error?.message ||
360 __("Failed to generate captions. Please try again.", "presto-player");
361 console.error("Bunny transcription API error:", {
362 message: rawErrorMessage,
363 error: error,
364 });
365 setTranscribeError(true);
366 } finally {
367 setIsTranscribing(false);
368 }
369 };
370
371 /**
372 * Get the appropriate modal title based on current state
373 */
374 const getTranscribeModalTitle = () => {
375 if (transcribeSuccess) {
376 return __("Caption Generation Started!", "presto-player");
377 }
378 if (transcribeError) {
379 return __("Caption Generation Failed", "presto-player");
380 }
381 return __("Caption Generation Charges", "presto-player");
382 };
383
384 if (!mediaUpload) {
385 return null;
386 }
387 return (
388 <>
389 {trackToRemove && (
390 <>
391 <Modal
392 className="presto-player__modal-confirm"
393 overlayClassName="presto-player__modal-confirm-overlay"
394 title={__("Remove Caption Track", "presto-player")}
395 onRequestClose={() => setTrackToRemove(null)}
396 >
397 <p>
398 {__(
399 "Removing this caption track will also delete it from the Bunny.net Stream Library. This action cannot be undone.",
400 "presto-player"
401 )}
402 </p>
403 <div className="presto-player__modal-actions">
404 <Button
405 onClick={() => setTrackToRemove(null)}
406 disabled={isDeletingTrack}
407 >
408 {__("Cancel", "presto-player")}
409 </Button>
410 <Button
411 isDestructive
412 onClick={confirmTrackRemove}
413 isBusy={isDeletingTrack}
414 disabled={isDeletingTrack}
415 >
416 {__("Remove Track", "presto-player")}
417 </Button>
418 </div>
419 </Modal>
420 </>
421 )}
422 {showTranscribeConfirm && (
423 <>
424 <Modal
425 className="presto-player__modal-transcribe"
426 overlayClassName="presto-player__modal-transcribe-overlay"
427 title={getTranscribeModalTitle()}
428 onRequestClose={() => {
429 setShowTranscribeConfirm(false);
430 setTranscribeSuccess(false);
431 setTranscribeError(false);
432 dropdownOnCloseRef.current?.();
433 }}
434 >
435 {transcribeSuccess ? (
436 <>
437 <p>
438 {__(
439 "Captions are being generated and will automatically appear on the player when ready. Depending on the length of the video, it may take few minutes. Please check back later.",
440 "presto-player"
441 )}
442 </p>
443 <div className="presto-player__modal-actions">
444 <Button
445 isPrimary
446 onClick={() => {
447 setShowTranscribeConfirm(false);
448 setTranscribeSuccess(false);
449 dropdownOnCloseRef.current?.();
450 }}
451 >
452 {__("Close", "presto-player")}
453 </Button>
454 </div>
455 </>
456 ) : transcribeError ? (
457 <>
458 <p>
459 {__(
460 "We couldn't process your caption generation request. Please try again. If the issue persists, please re-upload the video to Bunny.net Stream Library or contact support@prestomade.com.",
461 "presto-player"
462 )}
463 </p>
464 <div className="presto-player__modal-actions">
465 <Button
466 onClick={() => {
467 setShowTranscribeConfirm(false);
468 setTranscribeError(false);
469 dropdownOnCloseRef.current?.();
470 }}
471 >
472 {__("Cancel", "presto-player")}
473 </Button>
474 <Button
475 isPrimary
476 onClick={() => {
477 setTranscribeError(false);
478 handleTranscribe();
479 }}
480 >
481 {__("Try Again", "presto-player")}
482 </Button>
483 </div>
484 </>
485 ) : (
486 <>
487 <p>
488 {__(
489 "This will automatically generate captions for the selected languages. You will be charged $0.10 per minute for each language from your Bunny.net account balance.",
490 "presto-player"
491 )}
492 </p>
493 <p className="presto-player__modal-note">
494 <a
495 href="https://docs.bunny.net/docs/stream-pricing#transcribing"
496 target="_blank"
497 rel="noopener noreferrer"
498 className="presto-player__transcribe-pricing-link"
499 >
500 {__("View Bunny.net pricing details", "presto-player")}
501 </a>
502 </p>
503 <div className="presto-player__modal-actions">
504 <Button
505 onClick={() => {
506 setShowTranscribeConfirm(false);
507 setTranscribeSuccess(false);
508 setTranscribeError(false);
509 dropdownOnCloseRef.current?.();
510 }}
511 disabled={isTranscribing}
512 >
513 {__("Cancel", "presto-player")}
514 </Button>
515 <Button
516 isPrimary
517 onClick={handleTranscribe}
518 isBusy={isTranscribing}
519 disabled={isTranscribing}
520 >
521 {__("Confirm & Generate", "presto-player")}
522 </Button>
523 </div>
524 </>
525 )}
526 </Modal>
527 </>
528 )}
529 <Dropdown
530 contentClassName="block-library-video-tracks-editor"
531 renderToggle={({ isOpen, onToggle }) => (
532 <ToolbarGroup>
533 <ToolbarButton
534 label={__("Captions", "presto-player")}
535 showTooltip
536 aria-expanded={isOpen}
537 aria-haspopup="true"
538 onClick={onToggle}
539 icon={captionIcon}
540 />
541 </ToolbarGroup>
542 )}
543 renderContent={({ onClose: closeDropdown }) => {
544 dropdownOnCloseRef.current = closeDropdown;
545 if (trackBeingEdited !== null && tracks[trackBeingEdited]) {
546 const currentTrack = tracks[trackBeingEdited];
547 return (
548 <SingleTrackEditor
549 track={currentTrack}
550 onChange={(newTrack) => {
551 const newTracks = [...tracks];
552 newTracks[trackBeingEdited] = newTrack;
553 onChange(newTracks);
554 }}
555 onClose={() => setTrackBeingEdited(null)}
556 onRemove={() => {
557 handleTrackRemove(trackBeingEdited);
558 }}
559 isBunnyNet={isBunnyNetCaption(currentTrack)}
560 />
561 );
562 }
563 return (
564 <>
565 <NavigableMenu>
566 <TrackList tracks={tracks} onEditPress={setTrackBeingEdited} />
567 <MenuGroup
568 className="block-library-video-tracks-editor__add-tracks-container"
569 label={__("Add caption languages", "presto-player")}
570 >
571 <MediaUpload
572 onSelect={({ url }) => {
573 const trackIndex = tracks.length;
574 onChange([...tracks, { src: url }]);
575 setTrackBeingEdited(trackIndex);
576 }}
577 allowedTypes={ALLOWED_TYPES}
578 render={({ open }) => (
579 <MenuItem icon={"media"} onClick={open}>
580 {__("Open Media Library", "presto-player")}
581 </MenuItem>
582 )}
583 />
584 <MediaUploadCheck>
585 <FormFileUpload
586 onChange={(event) => {
587 const files = event.target.files;
588 const trackIndex = tracks.length;
589 mediaUpload({
590 allowedTypes: ALLOWED_TYPES,
591 filesList: files,
592 onFileChange: ([{ url }]) => {
593 const newTracks = [...tracks];
594 if (!newTracks[trackIndex]) {
595 newTracks[trackIndex] = {};
596 }
597 newTracks[trackIndex] = {
598 ...tracks[trackIndex],
599 src: url,
600 };
601 onChange(newTracks);
602 setTrackBeingEdited(trackIndex);
603 },
604 });
605 }}
606 accept=".vtt,text/vtt"
607 render={({ openFileDialog }) => {
608 return (
609 <MenuItem
610 icon={"upload"}
611 onClick={() => {
612 openFileDialog();
613 }}
614 >
615 {__("Upload", "presto-player")}
616 </MenuItem>
617 );
618 }}
619 />
620 </MediaUploadCheck>
621 </MenuGroup>
622
623 {/* Automatic Caption Settings - Only show for Bunny.net videos */}
624 {onTranscribeLanguagesChange && (
625 <MenuGroup
626 className="block-library-video-tracks-editor__transcription-container"
627 label={__("Automatic Captions", "presto-player")}
628 >
629 <div style={{ padding: "6px 12px" }}>
630 <TranscriptionLanguageTokenField
631 value={transcribeLanguages}
632 onChange={onTranscribeLanguagesChange}
633 showWarning={true}
634 />
635 <div
636 style={{
637 marginTop: "12px",
638 display: "flex",
639 gap: "8px",
640 }}
641 >
642 <Button
643 isPrimary
644 disabled={
645 !transcribeLanguages ||
646 transcribeLanguages.length === 0 ||
647 isTranscribing ||
648 !videoId
649 }
650 isBusy={isTranscribing}
651 onClick={() => {
652 if (!videoId || !transcribeLanguages.length) {
653 return;
654 }
655 setShowTranscribeConfirm(true);
656 }}
657 >
658 {__("Generate", "presto-player")}
659 </Button>
660 </div>
661 </div>
662 </MenuGroup>
663 )}
664 </NavigableMenu>
665 </>
666 );
667 }}
668 />
669 </>
670 );
671 }
672