/* * Copyright (C) 2025 - present Instructure, Inc. * * This file is part of Canvas. * * Canvas is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, version 3 of the License. * * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . */ import {Alert} from '@instructure/ui-alerts' import {Flex} from '@instructure/ui-flex' import {List} from '@instructure/ui-list' import {useMemo} from 'react' import {sortedAsrLanguageList} from '../asrClosedCaptionLanguages' import {sortedClosedCaptionLanguageList} from '../closedCaptionLanguages' import {trackPendoEvent} from '../utils/trackPendoEvent' import {AutoCaptioning} from './AutoCaptioning' import {CaptionCreationModePicker} from './CaptionCreationModePicker' import {CaptionRow} from './CaptionRow' import {doAsrRequest} from './hooks/doAsrRequest' import {useClosedCaptionState} from './hooks/useClosedCaptionState' import {useClosedCaptionUpload} from './hooks/useClosedCaptionUpload' import {useFocusManagement} from './hooks/useFocusManagement' import {useLanguageFiltering} from './hooks/useLanguageFiltering' import {ManualCaptionCreator} from './ManualCaptionCreator' import { type ClosedCaptionPanelTranslations, defaultClosedCaptionPanelTranslations, } from './translations' import type {CaptionUploadConfig, LanguageOption, Subtitle} from './types' function getCaptionName( subtitle: Subtitle, language: LanguageOption | undefined, translations: ClosedCaptionPanelTranslations, ): string { return subtitle.asr ? translations.automaticCaptionLabel({languageLabel: language?.label}) : (language?.label ?? '') } /** * Props for ClosedCaptionPanel component */ export interface ClosedCaptionPanelProps { liveRegion: () => HTMLElement | null mountNode?: HTMLElement | (() => HTMLElement | null) subtitles?: Subtitle[] onUpdateSubtitles: (subtitles: Subtitle[]) => void userLocale?: string uploadConfig?: CaptionUploadConfig onCaptionUploaded?: (subtitle: Subtitle) => void onCaptionDeleted?: (locale: string) => void onDirtyStateChanged?: (isDirty: boolean) => void translations?: ClosedCaptionPanelTranslations } export function ClosedCaptionPanelV2({ liveRegion, mountNode, subtitles: initialSubtitles = [], onUpdateSubtitles, userLocale = 'en', uploadConfig, onCaptionUploaded, onCaptionDeleted, onDirtyStateChanged, translations = defaultClosedCaptionPanelTranslations, }: ClosedCaptionPanelProps) { // Get sorted language lists based on user locale const closedCaptionLanguages = useMemo( () => sortedClosedCaptionLanguageList(userLocale), [userLocale], ) const asrLanguages = useMemo(() => sortedAsrLanguageList(userLocale), [userLocale]) // State management const state = useClosedCaptionState({ initialSubtitles, onUpdateSubtitles, closedCaptionLanguages, translations, }) // Filter available languages for manual upload (all caption languages) const {availableLanguages: availableManualLanguages} = useLanguageFiltering({ allLanguages: closedCaptionLanguages, subtitles: state.subtitles, }) // Filter available languages for auto-captioning (ASR-only languages) const {availableLanguages: availableAsrLanguages} = useLanguageFiltering({ allLanguages: asrLanguages, subtitles: state.subtitles, }) const {setAddNewButtonRef, setCreationFormRef, setDeleteButtonRef, queueFocus} = useFocusManagement() // Always use immediate upload hook (this component always uploads immediately) const upload = useClosedCaptionUpload({ uploadConfig, subtitles: state.subtitles, onUploadSuccess: subtitle => { trackPendoEvent('canvas_caption_result', { flow_type: 'upload_file', result: 'success', language: subtitle.locale, }) state.handleCaptionUploaded(subtitle) onCaptionUploaded?.(subtitle) }, onUploadError: (_error, locale) => { trackPendoEvent('canvas_caption_result', {flow_type: 'upload_file', result: 'failed'}) state.handleCaptionUploadFailed(locale, 'upload') }, onDeleteSuccess: locale => { const currentSubtitles = state.subtitles const deletedIndex = currentSubtitles.findIndex(s => s.locale === locale) const remaining = currentSubtitles.filter(s => s.locale !== locale) if (remaining.length > 0) { const targetIndex = Math.min(deletedIndex, remaining.length - 1) queueFocus({type: 'afterDelete', targetLocale: remaining[targetIndex].locale}) } else { queueFocus({type: 'addNew'}) } state.handleDeleteRow(locale) onCaptionDeleted?.(locale) }, onDeleteError: (_error, locale) => { trackPendoEvent('canvas_caption_validation_error', { flow_type: 'upload_file', error_type: 'delete_failed', }) state.handleCaptionUploadFailed(locale, 'delete') }, }) function getRetryHandler(subtitle: Subtitle): (() => void) | undefined { const {locale, failedOperation, rawFile} = subtitle let retryAction if (failedOperation === 'delete') { retryAction = () => upload.deleteCaption(locale) } else if (failedOperation === 'upload' && rawFile) { retryAction = () => upload.uploadCaption(locale, rawFile) } else if (failedOperation === 'asr') { retryAction = () => { doAsrRequest(uploadConfig, locale).catch(() => { state.handleCaptionUploadFailed(locale, 'asr') }) } } if (!retryAction) return undefined return () => { trackPendoEvent('canvas_caption_retry_clicked', { flow_type: failedOperation === 'upload' ? 'upload_file' : 'request_auto', }) state.handleCaptionRetrying(locale) retryAction() } } // Check if an auto-captioned subtitle already exists const hasAutoCaptionAlready = state.subtitles.some(subtitle => subtitle.asr === true) return ( {/* Screen reader announcement */} {state.announcement && ( {state.announcement} )} {/* Existing captions */} {state.subtitles.length > 0 && ( {state.subtitles.map(subtitle => { const language = closedCaptionLanguages.find(l => l.id === subtitle.locale) const captionName = getCaptionName(subtitle, language, translations) const deleteHandler = () => { trackPendoEvent('canvas_caption_item_action', { action: 'delete', caption_source: subtitle.asr ? 'automatic' : 'uploaded', language: subtitle.locale, }) state.setAnnouncement(translations.deletingCaption({captionName})) upload.deleteCaption(subtitle.locale) } // Client-side failures (failedOperation is set) get retry only. // Server-side failures (no failedOperation) get delete only. const showDelete = subtitle.workflow_state !== 'failed' || !subtitle.failedOperation return ( ) })} )} {state.creationMode === null && ( )} {state.creationMode === 'manual' && ( { queueFocus({type: 'addNew'}) state.handleCancelCreation() onDirtyStateChanged?.(false) }} onPrimary={(languageId: string, file: File) => { const language = closedCaptionLanguages.find(l => l.id === languageId) if (language) { trackPendoEvent('canvas_caption_submitted', { flow_type: 'upload_file', language: languageId, }) queueFocus({type: 'addNew'}) state.setAnnouncement(translations.uploadingCaption({lang: language.label})) state.handleCaptionProcessing({locale: languageId, file}) upload.uploadCaption(languageId, file) } onDirtyStateChanged?.(false) }} onDirtyStateChanged={onDirtyStateChanged} /> )} {state.creationMode === 'auto' && ( { queueFocus({type: 'addNew'}) state.handleCancelCreation() onDirtyStateChanged?.(false) }} liveRegion={liveRegion} mountNode={mountNode} languages={availableAsrLanguages} translations={translations} onPrimary={(languageId: string) => { const language = asrLanguages.find(l => l.id === languageId) if (language) { trackPendoEvent('canvas_caption_submitted', { flow_type: 'request_auto', language: languageId, }) queueFocus({type: 'addNew'}) state.setAnnouncement(translations.requestingCaption({lang: language.label})) state.handleCaptionProcessing({locale: languageId, isAsr: true}) doAsrRequest(uploadConfig, languageId).catch(() => { trackPendoEvent('canvas_caption_result', { flow_type: 'request_auto', result: 'failed', language: languageId, }) state.handleCaptionUploadFailed(languageId, 'asr') }) } onDirtyStateChanged?.(false) }} onDirtyStateChanged={onDirtyStateChanged} /> )} ) }