/* * 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 {useCallback, useEffect, useRef, useState} from 'react' import { type ClosedCaptionPanelTranslations, defaultClosedCaptionPanelTranslations, } from '../translations' import type {CaptionCreationMode, LanguageOption, Subtitle} from '../types' interface UseClosedCaptionStateParams { initialSubtitles?: Subtitle[] /** Will be used later to propagate subtitle changes to parents */ onUpdateSubtitles: (subtitles: Subtitle[]) => void closedCaptionLanguages: LanguageOption[] translations?: ClosedCaptionPanelTranslations } interface UseClosedCaptionStateReturn { subtitles: Subtitle[] creationMode: CaptionCreationMode | null announcement: string | null setAnnouncement: React.Dispatch> handleNewButtonClick: () => void handleCreationModeSelect: (mode: CaptionCreationMode) => void handleCancelCreation: () => void handleDeleteRow: (locale: string) => void handleCaptionProcessing: (params: {locale: string; file?: File; isAsr?: boolean}) => void handleCaptionUploaded: (subtitle: Subtitle) => void handleCaptionUploadFailed: (locale: string, failedOperation: 'upload' | 'delete' | 'asr') => void handleCaptionRetrying: (locale: string) => void } /** * Manages state for closed captions panel * Handles add/delete logic and screen reader announcements */ export function useClosedCaptionState({ initialSubtitles = [], onUpdateSubtitles, closedCaptionLanguages, translations = defaultClosedCaptionPanelTranslations, }: UseClosedCaptionStateParams): UseClosedCaptionStateReturn { const [subtitles, setSubtitles] = useState(initialSubtitles) const [creationMode, setCreationMode] = useState(null) const [announcement, setAnnouncement] = useState(null) // Always-current ref so async callbacks (e.g. inside .catch()) read the // latest subtitle list even after re-renders that changed state. const subtitlesRef = useRef(subtitles) subtitlesRef.current = subtitles // Sync internal state when parent passes updated subtitles. useEffect(() => { setSubtitles(initialSubtitles) }, [initialSubtitles]) const handleNewButtonClick = useCallback(() => { setAnnouncement(null) }, []) const handleCreationModeSelect = useCallback((mode: CaptionCreationMode) => { setCreationMode(mode) }, []) const handleCancelCreation = useCallback(() => { setCreationMode(null) }, []) const handleDeleteRow = useCallback( (locale: string) => { const deletedLanguage = closedCaptionLanguages.find(l => l.id === locale) const newSubtitles = subtitlesRef.current.filter(s => s.locale !== locale) setSubtitles(newSubtitles) onUpdateSubtitles(newSubtitles) setAnnouncement(translations.captionsDeletedFor({lang: deletedLanguage?.label || locale})) }, [closedCaptionLanguages, onUpdateSubtitles, translations], ) // Called when file is selected and upload starts // (handleLanguageSelected + handleFileSelected already add to list with isNew=true) // Just need to mark as processing const handleCaptionProcessing = useCallback( ({locale, file, isAsr}: {locale: string; file?: File; isAsr?: boolean}) => { const updatedSubtitles: Subtitle[] = [ ...subtitlesRef.current, { locale, ...(file && {file: {name: file.name}, rawFile: file}), workflow_state: 'processing' as const, ...(isAsr && {asr: true}), }, ] setSubtitles(updatedSubtitles) onUpdateSubtitles(updatedSubtitles) setCreationMode(null) }, [onUpdateSubtitles], ) // Called when upload succeeds const handleCaptionUploaded = useCallback( (subtitle: Subtitle) => { const language = closedCaptionLanguages.find(l => l.id === subtitle.locale) const updatedSubtitles = subtitlesRef.current.map(s => s.locale === subtitle.locale ? {...subtitle, workflow_state: 'ready' as const} : s, ) setSubtitles(updatedSubtitles) onUpdateSubtitles(updatedSubtitles) setAnnouncement(translations.captionsAddedFor({lang: language?.label || subtitle.locale})) handleCancelCreation() }, [closedCaptionLanguages, onUpdateSubtitles, handleCancelCreation, translations], ) // Called when upload or delete fails const handleCaptionUploadFailed = useCallback( (locale: string, failedOperation: 'upload' | 'delete' | 'asr') => { const language = closedCaptionLanguages.find(l => l.id === locale) const captionName = language?.label || locale const announcements = { upload: translations.captionUploadFailed({captionName}), delete: translations.captionDeleteFailed({captionName}), asr: translations.captionGenerationFailed({captionName}), } const updatedSubtitles = subtitlesRef.current.map(s => s.locale === locale ? { ...s, workflow_state: 'failed' as const, failedOperation, } : s, ) setSubtitles(updatedSubtitles) onUpdateSubtitles(updatedSubtitles) setAnnouncement(announcements[failedOperation]) handleCancelCreation() }, [closedCaptionLanguages, handleCancelCreation, onUpdateSubtitles, translations], ) // Called when retry is triggered for a failed caption. const handleCaptionRetrying = useCallback( (locale: string) => { const updatedSubtitles = subtitlesRef.current.map(s => s.locale === locale ? { ...s, workflow_state: 'processing' as const, failedOperation: undefined, } : s, ) setSubtitles(updatedSubtitles) onUpdateSubtitles(updatedSubtitles) }, [onUpdateSubtitles], ) return { subtitles, creationMode, announcement, setAnnouncement, handleNewButtonClick, handleCreationModeSelect, handleCancelCreation, handleDeleteRow, handleCaptionProcessing, handleCaptionUploaded, handleCaptionUploadFailed, handleCaptionRetrying, } }