/*
* 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,
}
}