@intellectif/lk-react 23.1.0 → 23.1.2
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/CHANGELOG.md +23 -0
- package/README.md +9 -5
- package/dist/ActivityErrorBoundary-B379hvtL.cjs +39 -0
- package/dist/ActivityErrorBoundary-B379hvtL.cjs.map +1 -0
- package/dist/ActivityErrorBoundary-Dw6SbWv3.js +34 -0
- package/dist/ActivityErrorBoundary-Dw6SbWv3.js.map +1 -0
- package/dist/ActivityMedia-CQQTRNl7.cjs +641 -0
- package/dist/ActivityMedia-CQQTRNl7.cjs.map +1 -0
- package/dist/ActivityMedia-vDiP-Psa.js +636 -0
- package/dist/ActivityMedia-vDiP-Psa.js.map +1 -0
- package/dist/ActivityPreview-1hhTkYuj.js +240 -0
- package/dist/ActivityPreview-1hhTkYuj.js.map +1 -0
- package/dist/ActivityPreview-BKr34exz.cjs +245 -0
- package/dist/ActivityPreview-BKr34exz.cjs.map +1 -0
- package/dist/ActivitySequence-CkX87pfB.js +839 -0
- package/dist/ActivitySequence-CkX87pfB.js.map +1 -0
- package/dist/ActivitySequence-CmoSyDnp.cjs +844 -0
- package/dist/ActivitySequence-CmoSyDnp.cjs.map +1 -0
- package/dist/Dictation-BlxRp-3k.js +926 -0
- package/dist/Dictation-BlxRp-3k.js.map +1 -0
- package/dist/Dictation-jECzL88E.cjs +931 -0
- package/dist/Dictation-jECzL88E.cjs.map +1 -0
- package/dist/FeedbackRegion-BNNMtTY-.cjs +108 -0
- package/dist/FeedbackRegion-BNNMtTY-.cjs.map +1 -0
- package/dist/FeedbackRegion-CyPSp8bA.js +85 -0
- package/dist/FeedbackRegion-CyPSp8bA.js.map +1 -0
- package/dist/FillInTheBlanks-B6xzqYXx.js +474 -0
- package/dist/FillInTheBlanks-B6xzqYXx.js.map +1 -0
- package/dist/FillInTheBlanks-BpKhdHg2.cjs +479 -0
- package/dist/FillInTheBlanks-BpKhdHg2.cjs.map +1 -0
- package/dist/GapSelect-DUSiaEQH.js +441 -0
- package/dist/GapSelect-DUSiaEQH.js.map +1 -0
- package/dist/GapSelect-payTq_Zn.cjs +446 -0
- package/dist/GapSelect-payTq_Zn.cjs.map +1 -0
- package/dist/InteractiveVideo-BguGv-HA.cjs +2955 -0
- package/dist/InteractiveVideo-BguGv-HA.cjs.map +1 -0
- package/dist/InteractiveVideo-Cg2jbU3j.js +2944 -0
- package/dist/InteractiveVideo-Cg2jbU3j.js.map +1 -0
- package/dist/LkAiProvider-DAPpA3PQ.d.cts +84 -0
- package/dist/LkAiProvider-DAPpA3PQ.d.cts.map +1 -0
- package/dist/LkAiProvider-QdEcIhoo.d.ts +84 -0
- package/dist/LkAiProvider-QdEcIhoo.d.ts.map +1 -0
- package/dist/LkIntlProvider-D_FdYXjQ.js +375 -0
- package/dist/LkIntlProvider-D_FdYXjQ.js.map +1 -0
- package/dist/LkIntlProvider-oi57lYB0.cjs +410 -0
- package/dist/LkIntlProvider-oi57lYB0.cjs.map +1 -0
- package/dist/MultipleChoice-COn5MtKY.cjs +433 -0
- package/dist/MultipleChoice-COn5MtKY.cjs.map +1 -0
- package/dist/MultipleChoice-Dra0_KYD.js +428 -0
- package/dist/MultipleChoice-Dra0_KYD.js.map +1 -0
- package/dist/PronunciationFeedback-DlMPGjkR.cjs +831 -0
- package/dist/PronunciationFeedback-DlMPGjkR.cjs.map +1 -0
- package/dist/PronunciationFeedback-Dxd6XPkO.js +772 -0
- package/dist/PronunciationFeedback-Dxd6XPkO.js.map +1 -0
- package/dist/ReadAloud-C8RhaUqV.js +1092 -0
- package/dist/{chunk-DC4CQCK5.js.map → ReadAloud-C8RhaUqV.js.map} +1 -1
- package/dist/ReadAloud-akDehitT.cjs +1103 -0
- package/dist/ReadAloud-akDehitT.cjs.map +1 -0
- package/dist/StimulusPanel-BgzByfkb.js +74 -0
- package/dist/StimulusPanel-BgzByfkb.js.map +1 -0
- package/dist/StimulusPanel-b66gaN3i.cjs +79 -0
- package/dist/StimulusPanel-b66gaN3i.cjs.map +1 -0
- package/dist/ThemeProvider-B6DyGqzh.d.cts +62 -0
- package/dist/ThemeProvider-B6DyGqzh.d.cts.map +1 -0
- package/dist/ThemeProvider-BCrxBaY-.d.ts +62 -0
- package/dist/ThemeProvider-BCrxBaY-.d.ts.map +1 -0
- package/dist/ThemeProvider-BRN1tj4n.cjs +187 -0
- package/dist/ThemeProvider-BRN1tj4n.cjs.map +1 -0
- package/dist/ThemeProvider-C7K9iMLX.js +158 -0
- package/dist/ThemeProvider-C7K9iMLX.js.map +1 -0
- package/dist/WrittenResponse-Bj3eFmGl.cjs +491 -0
- package/dist/WrittenResponse-Bj3eFmGl.cjs.map +1 -0
- package/dist/WrittenResponse-Cl8G6nO_.js +486 -0
- package/dist/WrittenResponse-Cl8G6nO_.js.map +1 -0
- package/dist/ai/LkAiProvider.cjs +34 -14
- package/dist/ai/LkAiProvider.cjs.map +1 -1
- package/dist/ai/LkAiProvider.d.cts +2 -83
- package/dist/ai/LkAiProvider.d.ts +2 -83
- package/dist/ai/LkAiProvider.js +33 -2
- package/dist/ai/LkAiProvider.js.map +1 -1
- package/dist/ai/useAiHelp.cjs +7 -25
- package/dist/ai/useAiHelp.d.cts +175 -177
- package/dist/ai/useAiHelp.d.cts.map +1 -0
- package/dist/ai/useAiHelp.d.ts +175 -177
- package/dist/ai/useAiHelp.d.ts.map +1 -0
- package/dist/ai/useAiHelp.js +3 -4
- package/dist/components/ActivityPreview.cjs +3 -29
- package/dist/components/ActivityPreview.d.cts +2 -81
- package/dist/components/ActivityPreview.d.ts +2 -81
- package/dist/components/ActivityPreview.js +2 -20
- package/dist/components/ActivitySequence.cjs +3 -30
- package/dist/components/ActivitySequence.d.cts +2 -432
- package/dist/components/ActivitySequence.d.ts +2 -432
- package/dist/components/ActivitySequence.js +2 -21
- package/dist/components/Dictation.cjs +4 -21
- package/dist/components/Dictation.d.cts +2 -19
- package/dist/components/Dictation.d.ts +2 -19
- package/dist/components/Dictation.js +3 -12
- package/dist/components/FillInTheBlanks.cjs +4 -20
- package/dist/components/FillInTheBlanks.d.cts +2 -30
- package/dist/components/FillInTheBlanks.d.ts +2 -30
- package/dist/components/FillInTheBlanks.js +3 -11
- package/dist/components/GapSelect.cjs +4 -20
- package/dist/components/GapSelect.d.cts +2 -27
- package/dist/components/GapSelect.d.ts +2 -27
- package/dist/components/GapSelect.js +3 -11
- package/dist/components/InteractiveVideo.cjs +5 -32
- package/dist/components/InteractiveVideo.d.cts +3 -291
- package/dist/components/InteractiveVideo.d.ts +3 -291
- package/dist/components/InteractiveVideo.js +3 -19
- package/dist/components/MultipleChoice.cjs +4 -20
- package/dist/components/MultipleChoice.d.cts +2 -26
- package/dist/components/MultipleChoice.d.ts +2 -26
- package/dist/components/MultipleChoice.js +3 -11
- package/dist/components/PronunciationFeedback.cjs +4 -17
- package/dist/components/PronunciationFeedback.d.cts +2 -90
- package/dist/components/PronunciationFeedback.d.ts +2 -90
- package/dist/components/PronunciationFeedback.js +3 -8
- package/dist/components/ReadAloud.cjs +4 -22
- package/dist/components/ReadAloud.d.cts +7 -11
- package/dist/components/ReadAloud.d.cts.map +1 -0
- package/dist/components/ReadAloud.d.ts +7 -11
- package/dist/components/ReadAloud.d.ts.map +1 -0
- package/dist/components/ReadAloud.js +3 -13
- package/dist/components/StimulusPanel.cjs +3 -14
- package/dist/components/StimulusPanel.d.cts +2 -51
- package/dist/components/StimulusPanel.d.ts +2 -51
- package/dist/components/StimulusPanel.js +2 -5
- package/dist/components/WrittenResponse.cjs +4 -19
- package/dist/components/WrittenResponse.d.cts +3 -18
- package/dist/components/WrittenResponse.d.ts +3 -18
- package/dist/components/WrittenResponse.js +3 -10
- package/dist/direction-Dq0_HtA5.js +183 -0
- package/dist/direction-Dq0_HtA5.js.map +1 -0
- package/dist/direction-K1pj0HCA.cjs +200 -0
- package/dist/direction-K1pj0HCA.cjs.map +1 -0
- package/dist/hooks/useActivityState.cjs +28 -10
- package/dist/hooks/useActivityState.cjs.map +1 -1
- package/dist/hooks/useActivityState.d.cts +25 -24
- package/dist/hooks/useActivityState.d.cts.map +1 -0
- package/dist/hooks/useActivityState.d.ts +25 -24
- package/dist/hooks/useActivityState.d.ts.map +1 -0
- package/dist/hooks/useActivityState.js +28 -2
- package/dist/hooks/useActivityState.js.map +1 -1
- package/dist/hooks/useSpeechRecorder.cjs +5 -16
- package/dist/hooks/useSpeechRecorder.d.cts +2 -127
- package/dist/hooks/useSpeechRecorder.d.ts +2 -127
- package/dist/hooks/useSpeechRecorder.js +3 -3
- package/dist/hooks/useXAPI.cjs +126 -10
- package/dist/hooks/useXAPI.cjs.map +1 -1
- package/dist/hooks/useXAPI.d.cts +14 -14
- package/dist/hooks/useXAPI.d.cts.map +1 -0
- package/dist/hooks/useXAPI.d.ts +14 -14
- package/dist/hooks/useXAPI.d.ts.map +1 -0
- package/dist/hooks/useXAPI.js +126 -2
- package/dist/hooks/useXAPI.js.map +1 -1
- package/dist/i18n/LkIntlProvider.cjs +9 -32
- package/dist/i18n/LkIntlProvider.d.cts +34 -38
- package/dist/i18n/LkIntlProvider.d.cts.map +1 -0
- package/dist/i18n/LkIntlProvider.d.ts +34 -38
- package/dist/i18n/LkIntlProvider.d.ts.map +1 -0
- package/dist/i18n/LkIntlProvider.js +3 -3
- package/dist/index-6P4rJHrJ.d.cts +432 -0
- package/dist/index-6P4rJHrJ.d.cts.map +1 -0
- package/dist/index-90CX504k.d.ts +90 -0
- package/dist/index-90CX504k.d.ts.map +1 -0
- package/dist/index-B_SgnjsC.d.cts +291 -0
- package/dist/index-B_SgnjsC.d.cts.map +1 -0
- package/dist/index-BavVW5z5.d.cts +25 -0
- package/dist/index-BavVW5z5.d.cts.map +1 -0
- package/dist/index-BhHv6Fiu.d.cts +28 -0
- package/dist/index-BhHv6Fiu.d.cts.map +1 -0
- package/dist/index-Bsu8VciA.d.ts +432 -0
- package/dist/index-Bsu8VciA.d.ts.map +1 -0
- package/dist/index-C1KGpD1k.d.ts +49 -0
- package/dist/index-C1KGpD1k.d.ts.map +1 -0
- package/dist/index-CKTpYTCC.d.cts +49 -0
- package/dist/index-CKTpYTCC.d.cts.map +1 -0
- package/dist/index-CO_SRFJj.d.ts +16 -0
- package/dist/index-CO_SRFJj.d.ts.map +1 -0
- package/dist/index-CSsuUsP7.d.cts +124 -0
- package/dist/index-CSsuUsP7.d.cts.map +1 -0
- package/dist/index-CunUyeuN.d.cts +78 -0
- package/dist/index-CunUyeuN.d.cts.map +1 -0
- package/dist/index-CzfRzks6.d.cts +90 -0
- package/dist/index-CzfRzks6.d.cts.map +1 -0
- package/dist/index-D14xtpBS.d.cts +24 -0
- package/dist/index-D14xtpBS.d.cts.map +1 -0
- package/dist/index-DHRPAPZf.d.ts +291 -0
- package/dist/index-DHRPAPZf.d.ts.map +1 -0
- package/dist/index-DLV6cK26.d.cts +16 -0
- package/dist/index-DLV6cK26.d.cts.map +1 -0
- package/dist/index-DbCCQ9cP.d.ts +124 -0
- package/dist/index-DbCCQ9cP.d.ts.map +1 -0
- package/dist/index-DeV5B2m5.d.ts +24 -0
- package/dist/index-DeV5B2m5.d.ts.map +1 -0
- package/dist/index-TZCJVSge.d.ts +28 -0
- package/dist/index-TZCJVSge.d.ts.map +1 -0
- package/dist/index-ZVvLHsd3.d.ts +25 -0
- package/dist/index-ZVvLHsd3.d.ts.map +1 -0
- package/dist/index-eQwBgpSw.d.ts +78 -0
- package/dist/index-eQwBgpSw.d.ts.map +1 -0
- package/dist/index.cjs +118 -163
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -24
- package/dist/index.d.ts +20 -24
- package/dist/index.js +84 -29
- package/dist/index.js.map +1 -1
- package/dist/theme/ThemeProvider.cjs +8 -28
- package/dist/theme/ThemeProvider.d.cts +2 -59
- package/dist/theme/ThemeProvider.d.ts +2 -59
- package/dist/theme/ThemeProvider.js +3 -3
- package/dist/tries-CbbtZkdG.cjs +363 -0
- package/dist/tries-CbbtZkdG.cjs.map +1 -0
- package/dist/tries-DTHSrLCm.js +310 -0
- package/dist/tries-DTHSrLCm.js.map +1 -0
- package/dist/types-BOQd93x3.d.cts +994 -0
- package/dist/types-BOQd93x3.d.cts.map +1 -0
- package/dist/types-HIrgF_mS.d.ts +994 -0
- package/dist/types-HIrgF_mS.d.ts.map +1 -0
- package/dist/useAiHelp-B-ccDL5Q.js +906 -0
- package/dist/useAiHelp-B-ccDL5Q.js.map +1 -0
- package/dist/useAiHelp-h9yOLFqt.cjs +995 -0
- package/dist/useAiHelp-h9yOLFqt.cjs.map +1 -0
- package/dist/useSpeechRecorder--O_f-xvt.cjs +720 -0
- package/dist/useSpeechRecorder--O_f-xvt.cjs.map +1 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.cts +154 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.cts.map +1 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.ts +154 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.ts.map +1 -0
- package/dist/useSpeechRecorder-zIi9Dlw8.js +709 -0
- package/dist/useSpeechRecorder-zIi9Dlw8.js.map +1 -0
- package/package.json +10 -11
- package/dist/WrittenResponse-B7A5gHMj.d.ts +0 -115
- package/dist/WrittenResponse-BpL9p2Hn.d.cts +0 -115
- package/dist/ai/useAiHelp.cjs.map +0 -1
- package/dist/ai/useAiHelp.js.map +0 -1
- package/dist/chunk-2PNSVBLQ.cjs +0 -54
- package/dist/chunk-2PNSVBLQ.cjs.map +0 -1
- package/dist/chunk-2TW6WLBC.js +0 -80
- package/dist/chunk-2TW6WLBC.js.map +0 -1
- package/dist/chunk-4ZZWZSWI.js +0 -61
- package/dist/chunk-4ZZWZSWI.js.map +0 -1
- package/dist/chunk-6N43WDVG.js +0 -29
- package/dist/chunk-6N43WDVG.js.map +0 -1
- package/dist/chunk-7ALBSPYR.cjs +0 -436
- package/dist/chunk-7ALBSPYR.cjs.map +0 -1
- package/dist/chunk-7NFY3M3W.js +0 -234
- package/dist/chunk-7NFY3M3W.js.map +0 -1
- package/dist/chunk-7SB2MJA4.js +0 -29
- package/dist/chunk-7SB2MJA4.js.map +0 -1
- package/dist/chunk-ABJJ3LII.js +0 -434
- package/dist/chunk-ABJJ3LII.js.map +0 -1
- package/dist/chunk-BIGGXHAW.cjs +0 -342
- package/dist/chunk-BIGGXHAW.cjs.map +0 -1
- package/dist/chunk-C7QZE5BI.js +0 -430
- package/dist/chunk-C7QZE5BI.js.map +0 -1
- package/dist/chunk-CWTUWNV3.cjs +0 -2937
- package/dist/chunk-CWTUWNV3.cjs.map +0 -1
- package/dist/chunk-DC4CQCK5.js +0 -915
- package/dist/chunk-DS6HN3YR.cjs +0 -496
- package/dist/chunk-DS6HN3YR.cjs.map +0 -1
- package/dist/chunk-FDQR4GAZ.js +0 -240
- package/dist/chunk-FDQR4GAZ.js.map +0 -1
- package/dist/chunk-FKGMSOJF.js +0 -394
- package/dist/chunk-FKGMSOJF.js.map +0 -1
- package/dist/chunk-G6PXJ2SA.js +0 -823
- package/dist/chunk-G6PXJ2SA.js.map +0 -1
- package/dist/chunk-HNLRVRQR.js +0 -335
- package/dist/chunk-HNLRVRQR.js.map +0 -1
- package/dist/chunk-J3C72RPK.cjs +0 -31
- package/dist/chunk-J3C72RPK.cjs.map +0 -1
- package/dist/chunk-K63VPFOV.cjs +0 -134
- package/dist/chunk-K63VPFOV.cjs.map +0 -1
- package/dist/chunk-KFLIDTDU.cjs +0 -825
- package/dist/chunk-KFLIDTDU.cjs.map +0 -1
- package/dist/chunk-KHKH5LCT.cjs +0 -250
- package/dist/chunk-KHKH5LCT.cjs.map +0 -1
- package/dist/chunk-LBEKRN6Y.cjs +0 -664
- package/dist/chunk-LBEKRN6Y.cjs.map +0 -1
- package/dist/chunk-LPUWXIPR.cjs +0 -578
- package/dist/chunk-LPUWXIPR.cjs.map +0 -1
- package/dist/chunk-MCO52PV5.js +0 -520
- package/dist/chunk-MCO52PV5.js.map +0 -1
- package/dist/chunk-MKXEM2C5.cjs +0 -396
- package/dist/chunk-MKXEM2C5.cjs.map +0 -1
- package/dist/chunk-MXNC5UNO.js +0 -563
- package/dist/chunk-MXNC5UNO.js.map +0 -1
- package/dist/chunk-NAK55BYU.cjs +0 -859
- package/dist/chunk-NAK55BYU.cjs.map +0 -1
- package/dist/chunk-NDBMKGQ5.cjs +0 -599
- package/dist/chunk-NDBMKGQ5.cjs.map +0 -1
- package/dist/chunk-OD65OZIW.cjs +0 -159
- package/dist/chunk-OD65OZIW.cjs.map +0 -1
- package/dist/chunk-ORT3KLGI.js +0 -50
- package/dist/chunk-ORT3KLGI.js.map +0 -1
- package/dist/chunk-PNNUPUH4.cjs +0 -82
- package/dist/chunk-PNNUPUH4.cjs.map +0 -1
- package/dist/chunk-PWHECW6N.js +0 -2934
- package/dist/chunk-PWHECW6N.js.map +0 -1
- package/dist/chunk-RBS5A2DF.js +0 -857
- package/dist/chunk-RBS5A2DF.js.map +0 -1
- package/dist/chunk-RPWUDXG5.cjs +0 -63
- package/dist/chunk-RPWUDXG5.cjs.map +0 -1
- package/dist/chunk-TCDJY3H6.js +0 -494
- package/dist/chunk-TCDJY3H6.js.map +0 -1
- package/dist/chunk-TP5UMRAU.cjs +0 -918
- package/dist/chunk-TP5UMRAU.cjs.map +0 -1
- package/dist/chunk-TS7J6DTY.js +0 -597
- package/dist/chunk-TS7J6DTY.js.map +0 -1
- package/dist/chunk-TUVENMXB.cjs +0 -20
- package/dist/chunk-TUVENMXB.cjs.map +0 -1
- package/dist/chunk-UDCQTOVU.cjs +0 -523
- package/dist/chunk-UDCQTOVU.cjs.map +0 -1
- package/dist/chunk-UHKZXOXD.js +0 -128
- package/dist/chunk-UHKZXOXD.js.map +0 -1
- package/dist/chunk-V6BA44DS.cjs +0 -236
- package/dist/chunk-V6BA44DS.cjs.map +0 -1
- package/dist/chunk-XO5R73Y4.js +0 -653
- package/dist/chunk-XO5R73Y4.js.map +0 -1
- package/dist/chunk-XOR4MPEN.cjs +0 -31
- package/dist/chunk-XOR4MPEN.cjs.map +0 -1
- package/dist/chunk-YORQQXGO.js +0 -155
- package/dist/chunk-YORQQXGO.js.map +0 -1
- package/dist/chunk-YY4PFR3Z.cjs +0 -432
- package/dist/chunk-YY4PFR3Z.cjs.map +0 -1
- package/dist/chunk-ZPI6Q42R.js +0 -17
- package/dist/chunk-ZPI6Q42R.js.map +0 -1
- package/dist/components/ActivityPreview.cjs.map +0 -1
- package/dist/components/ActivityPreview.js.map +0 -1
- package/dist/components/ActivitySequence.cjs.map +0 -1
- package/dist/components/ActivitySequence.js.map +0 -1
- package/dist/components/Dictation.cjs.map +0 -1
- package/dist/components/Dictation.js.map +0 -1
- package/dist/components/FillInTheBlanks.cjs.map +0 -1
- package/dist/components/FillInTheBlanks.js.map +0 -1
- package/dist/components/GapSelect.cjs.map +0 -1
- package/dist/components/GapSelect.js.map +0 -1
- package/dist/components/InteractiveVideo.cjs.map +0 -1
- package/dist/components/InteractiveVideo.js.map +0 -1
- package/dist/components/MultipleChoice.cjs.map +0 -1
- package/dist/components/MultipleChoice.js.map +0 -1
- package/dist/components/PronunciationFeedback.cjs.map +0 -1
- package/dist/components/PronunciationFeedback.js.map +0 -1
- package/dist/components/ReadAloud.cjs.map +0 -1
- package/dist/components/ReadAloud.js.map +0 -1
- package/dist/components/StimulusPanel.cjs.map +0 -1
- package/dist/components/StimulusPanel.js.map +0 -1
- package/dist/components/WrittenResponse.cjs.map +0 -1
- package/dist/components/WrittenResponse.js.map +0 -1
- package/dist/hooks/useSpeechRecorder.cjs.map +0 -1
- package/dist/hooks/useSpeechRecorder.js.map +0 -1
- package/dist/i18n/LkIntlProvider.cjs.map +0 -1
- package/dist/i18n/LkIntlProvider.js.map +0 -1
- package/dist/machine-CuwKqWXC.d.cts +0 -26
- package/dist/machine-CuwKqWXC.d.ts +0 -26
- package/dist/theme/ThemeProvider.cjs.map +0 -1
- package/dist/theme/ThemeProvider.js.map +0 -1
- package/dist/types-C4IsUQsK.d.ts +0 -989
- package/dist/types-CJgZWtl4.d.cts +0 -989
|
@@ -0,0 +1,2955 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
const require_ai_LkAiProvider = require("./ai/LkAiProvider.cjs");
|
|
3
|
+
const require_useAiHelp = require("./useAiHelp-h9yOLFqt.cjs");
|
|
4
|
+
const require_LkIntlProvider = require("./LkIntlProvider-oi57lYB0.cjs");
|
|
5
|
+
const require_ActivityErrorBoundary = require("./ActivityErrorBoundary-B379hvtL.cjs");
|
|
6
|
+
const require_Dictation = require("./Dictation-jECzL88E.cjs");
|
|
7
|
+
const require_FillInTheBlanks = require("./FillInTheBlanks-BpKhdHg2.cjs");
|
|
8
|
+
const require_GapSelect = require("./GapSelect-payTq_Zn.cjs");
|
|
9
|
+
const require_MultipleChoice = require("./MultipleChoice-COn5MtKY.cjs");
|
|
10
|
+
const require_ReadAloud = require("./ReadAloud-akDehitT.cjs");
|
|
11
|
+
let react = require("react");
|
|
12
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
13
|
+
let _intellectif_lk_core = require("@intellectif/lk-core");
|
|
14
|
+
//#region src/components/InteractiveVideo/vtt.ts
|
|
15
|
+
/** Bytes of WebVTT read at most. A caption file is text; this is several hours of it. */
|
|
16
|
+
const MAX_VTT_LENGTH = 2e6;
|
|
17
|
+
/** Cues kept at most, for the same reason: the transcript renders every one. */
|
|
18
|
+
const MAX_CUES = 1e4;
|
|
19
|
+
const BYTE_ORDER_MARK = String.fromCharCode(65279);
|
|
20
|
+
const TIMESTAMP = /^(?:(\d+):)?([0-5]\d):([0-5]\d)\.(\d{3})$/;
|
|
21
|
+
function seconds(stamp) {
|
|
22
|
+
const match = TIMESTAMP.exec(stamp.trim());
|
|
23
|
+
if (match === null) return;
|
|
24
|
+
const [, hours = "0", minutes = "0", secs = "0", millis = "0"] = match;
|
|
25
|
+
return Number(hours) * 3600 + Number(minutes) * 60 + Number(secs) + Number(millis) / 1e3;
|
|
26
|
+
}
|
|
27
|
+
const ENTITIES = {
|
|
28
|
+
amp: "&",
|
|
29
|
+
lt: "<",
|
|
30
|
+
gt: ">",
|
|
31
|
+
quot: "\"",
|
|
32
|
+
apos: "'",
|
|
33
|
+
nbsp: String.fromCharCode(160),
|
|
34
|
+
lrm: String.fromCharCode(8206),
|
|
35
|
+
rlm: String.fromCharCode(8207)
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* A cue payload as the text a learner reads: markup removed (`<v Speaker>`,
|
|
39
|
+
* `<i>`, `<c.class>`, inline timestamps), entities decoded, lines joined with
|
|
40
|
+
* a space.
|
|
41
|
+
*/
|
|
42
|
+
function plainText(payload) {
|
|
43
|
+
return payload.replace(/<[^>]*>/g, "").replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
|
|
44
|
+
if (name.startsWith("#")) {
|
|
45
|
+
const code = name[1] === "x" || name[1] === "X" ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
|
|
46
|
+
return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
|
|
47
|
+
}
|
|
48
|
+
return ENTITIES[name.toLowerCase()] ?? whole;
|
|
49
|
+
}).replace(/\s*\n\s*/g, " ").trim();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Reads a WebVTT file into cues, sorted by start. Never throws: a malformed cue
|
|
53
|
+
* is skipped, and text that is not WebVTT at all answers an empty list.
|
|
54
|
+
*
|
|
55
|
+
* The SDK parses captions itself rather than handing them to a `<track>`:
|
|
56
|
+
* the caption preview on the progress bar, the transcript and its search all
|
|
57
|
+
* need the cues as data, a host whose caption URL needs its credentials can
|
|
58
|
+
* pass the text in, and a test environment with no media stack can read them.
|
|
59
|
+
* Cue settings (position, alignment) are ignored — the player draws captions
|
|
60
|
+
* in one place, above its own controls.
|
|
61
|
+
*/
|
|
62
|
+
function parseWebVtt(input) {
|
|
63
|
+
if (typeof input !== "string") return [];
|
|
64
|
+
const raw = input.slice(0, MAX_VTT_LENGTH);
|
|
65
|
+
const text = (raw.startsWith(BYTE_ORDER_MARK) ? raw.slice(1) : raw).replace(/\r\n?/g, "\n");
|
|
66
|
+
if (!/^WEBVTT(?:[ \t].*)?(?:\n|$)/.test(text)) return [];
|
|
67
|
+
const cues = [];
|
|
68
|
+
for (const block of text.split(/\n{2,}/)) {
|
|
69
|
+
if (cues.length >= MAX_CUES) break;
|
|
70
|
+
const lines = block.split("\n");
|
|
71
|
+
const timingIndex = lines.findIndex((line) => line.includes("-->"));
|
|
72
|
+
if (timingIndex === -1) continue;
|
|
73
|
+
const [from = "", rest = ""] = lines[timingIndex].split("-->");
|
|
74
|
+
const start = seconds(from);
|
|
75
|
+
const end = seconds(rest.trim().split(/\s+/)[0] ?? "");
|
|
76
|
+
if (start === void 0 || end === void 0 || end <= start) continue;
|
|
77
|
+
const payload = plainText(lines.slice(timingIndex + 1).join("\n"));
|
|
78
|
+
if (payload === "") continue;
|
|
79
|
+
cues.push({
|
|
80
|
+
start,
|
|
81
|
+
end,
|
|
82
|
+
text: payload
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return cues.map((cue, index) => ({
|
|
86
|
+
cue,
|
|
87
|
+
index
|
|
88
|
+
})).sort((a, b) => a.cue.start - b.cue.start || a.index - b.index).map(({ cue }) => cue);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The cue showing at `time`, or -1. When cues overlap, the one that started
|
|
92
|
+
* last wins — the newest line is the one being spoken.
|
|
93
|
+
*/
|
|
94
|
+
function cueIndexAt(cues, time) {
|
|
95
|
+
let low = 0;
|
|
96
|
+
let high = cues.length - 1;
|
|
97
|
+
let found = -1;
|
|
98
|
+
while (low <= high) {
|
|
99
|
+
const middle = low + high >> 1;
|
|
100
|
+
if (cues[middle].start <= time) {
|
|
101
|
+
found = middle;
|
|
102
|
+
low = middle + 1;
|
|
103
|
+
} else high = middle - 1;
|
|
104
|
+
}
|
|
105
|
+
for (let index = found; index >= 0 && index > found - 8; index -= 1) if (time < cues[index].end) return index;
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/components/InteractiveVideo/caption-cues.ts
|
|
110
|
+
/** A track as the cue cache knows it. */
|
|
111
|
+
function trackKey(track) {
|
|
112
|
+
return `${track.kind}:${track.srclang}:${track.src}`;
|
|
113
|
+
}
|
|
114
|
+
/** No cues, as one shared array: a line without any keeps one identity. */
|
|
115
|
+
const NO_CUES = [];
|
|
116
|
+
/**
|
|
117
|
+
* One caption line's cues: loaded when its track changes, and never another
|
|
118
|
+
* track's. A late answer for a track the line has since left is dropped — a
|
|
119
|
+
* slow Spanish file must never land in a line that now shows Portuguese — and
|
|
120
|
+
* until the new track's cues arrive the line shows nothing rather than the old
|
|
121
|
+
* language, unless they are cached already.
|
|
122
|
+
*/
|
|
123
|
+
function useCaptionLine(track, load, peek) {
|
|
124
|
+
const key = track === void 0 ? void 0 : trackKey(track);
|
|
125
|
+
const [line, setLine] = (0, react.useState)({
|
|
126
|
+
cues: NO_CUES,
|
|
127
|
+
failed: false
|
|
128
|
+
});
|
|
129
|
+
const trackRef = (0, react.useRef)(track);
|
|
130
|
+
trackRef.current = track;
|
|
131
|
+
(0, react.useEffect)(() => {
|
|
132
|
+
const wanted = trackRef.current;
|
|
133
|
+
if (key === void 0 || wanted === void 0) return;
|
|
134
|
+
let live = true;
|
|
135
|
+
load(wanted).then((cues) => {
|
|
136
|
+
if (live) setLine({
|
|
137
|
+
key,
|
|
138
|
+
cues,
|
|
139
|
+
failed: false
|
|
140
|
+
});
|
|
141
|
+
}, () => {
|
|
142
|
+
if (live) setLine({
|
|
143
|
+
key,
|
|
144
|
+
cues: NO_CUES,
|
|
145
|
+
failed: true
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return () => {
|
|
149
|
+
live = false;
|
|
150
|
+
};
|
|
151
|
+
}, [key, load]);
|
|
152
|
+
if (key === void 0) return {
|
|
153
|
+
cues: NO_CUES,
|
|
154
|
+
failed: false
|
|
155
|
+
};
|
|
156
|
+
if (line.key === key) return line;
|
|
157
|
+
return {
|
|
158
|
+
cues: peek(key) ?? NO_CUES,
|
|
159
|
+
failed: false
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Parsed cues per track, for the life of the mount: switching languages back
|
|
164
|
+
* and forth never refetches. The promise while a file loads — so a swap, which
|
|
165
|
+
* asks for a file both lines want, makes one request — and the cues once it
|
|
166
|
+
* has. A failure is forgotten, so coming back to that language tries again.
|
|
167
|
+
*/
|
|
168
|
+
function useCueLoader(captionsLoader) {
|
|
169
|
+
const loaderRef = (0, react.useRef)(captionsLoader);
|
|
170
|
+
loaderRef.current = captionsLoader;
|
|
171
|
+
const loading = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
172
|
+
const loaded = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
173
|
+
return {
|
|
174
|
+
loadCues: (0, react.useCallback)((track) => {
|
|
175
|
+
const key = trackKey(track);
|
|
176
|
+
const pending = loading.current.get(key);
|
|
177
|
+
if (pending !== void 0) return pending;
|
|
178
|
+
const request = Promise.resolve().then(() => loaderRef.current ? loaderRef.current(track) : fetch(track.src, { credentials: "same-origin" }).then((response) => {
|
|
179
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
180
|
+
return response.text();
|
|
181
|
+
})).then((text) => {
|
|
182
|
+
const parsed = parseWebVtt(text);
|
|
183
|
+
if (parsed.length === 0) throw new Error("The caption file held no cues.");
|
|
184
|
+
loaded.current.set(key, parsed);
|
|
185
|
+
return parsed;
|
|
186
|
+
});
|
|
187
|
+
loading.current.set(key, request);
|
|
188
|
+
request.catch(() => {
|
|
189
|
+
loading.current.delete(key);
|
|
190
|
+
});
|
|
191
|
+
return request;
|
|
192
|
+
}, []),
|
|
193
|
+
peekCues: (0, react.useCallback)((key) => loaded.current.get(key), [])
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/components/InteractiveVideo/format.ts
|
|
198
|
+
/** The speeds a learner can choose, slowest first. */
|
|
199
|
+
const SPEEDS = [
|
|
200
|
+
.5,
|
|
201
|
+
.75,
|
|
202
|
+
1,
|
|
203
|
+
1.25,
|
|
204
|
+
1.5,
|
|
205
|
+
1.75,
|
|
206
|
+
2,
|
|
207
|
+
2.5
|
|
208
|
+
];
|
|
209
|
+
/**
|
|
210
|
+
* A time as a clock: `4:05`, or `1:02:03` past an hour. Whole seconds,
|
|
211
|
+
* rounded down, so the clock never shows a second that has not happened yet.
|
|
212
|
+
*/
|
|
213
|
+
function clock(value) {
|
|
214
|
+
const total = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
215
|
+
const hours = Math.floor(total / 3600);
|
|
216
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
217
|
+
const secs = total % 60;
|
|
218
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
219
|
+
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
|
220
|
+
}
|
|
221
|
+
/** The next speed in `direction` from `current`, held inside the list. */
|
|
222
|
+
function nudgeSpeed(current, direction) {
|
|
223
|
+
const at = SPEEDS.indexOf(current);
|
|
224
|
+
const from = at === -1 ? SPEEDS.indexOf(1) : at;
|
|
225
|
+
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, from + direction))];
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/components/InteractiveVideo/icons.tsx
|
|
229
|
+
/**
|
|
230
|
+
* The player's icons: inline SVG, one stroke weight, drawn in `currentColor`,
|
|
231
|
+
* so a theme recolours them and forced-colours mode repaints them. Inline
|
|
232
|
+
* because the package has no runtime dependencies, and a 24 × 24 grid keeps
|
|
233
|
+
* every icon on one optical size. Always decorative: the button that holds
|
|
234
|
+
* one carries the name.
|
|
235
|
+
*/
|
|
236
|
+
function Icon({ children, filled = false }) {
|
|
237
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
238
|
+
className: "lk-iv-icon",
|
|
239
|
+
viewBox: "0 0 24 24",
|
|
240
|
+
width: "20",
|
|
241
|
+
height: "20",
|
|
242
|
+
fill: filled ? "currentColor" : "none",
|
|
243
|
+
stroke: "currentColor",
|
|
244
|
+
strokeWidth: "1.8",
|
|
245
|
+
strokeLinecap: "round",
|
|
246
|
+
strokeLinejoin: "round",
|
|
247
|
+
"aria-hidden": "true",
|
|
248
|
+
focusable: "false",
|
|
249
|
+
children
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const PlayIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
|
|
253
|
+
filled: true,
|
|
254
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 5.5v13a1 1 0 0 0 1.5.86l10.2-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5Z" })
|
|
255
|
+
});
|
|
256
|
+
const PauseIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, {
|
|
257
|
+
filled: true,
|
|
258
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
259
|
+
x: "6.5",
|
|
260
|
+
y: "5",
|
|
261
|
+
width: "3.5",
|
|
262
|
+
height: "14",
|
|
263
|
+
rx: "1"
|
|
264
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
265
|
+
x: "14",
|
|
266
|
+
y: "5",
|
|
267
|
+
width: "3.5",
|
|
268
|
+
height: "14",
|
|
269
|
+
rx: "1"
|
|
270
|
+
})]
|
|
271
|
+
});
|
|
272
|
+
const ReplayIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 12a8 8 0 1 0 2.4-5.7" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 4v4h4" })] });
|
|
273
|
+
const BackIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M11 6.5 5.5 12l5.5 5.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18.5 6.5 13 12l5.5 5.5" })] });
|
|
274
|
+
const ForwardIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m13 6.5 5.5 5.5-5.5 5.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5.5 6.5 11 12l-5.5 5.5" })] });
|
|
275
|
+
const VolumeIcon = ({ level }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4Z" }), level === "muted" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m16 9.5 5 5m0-5-5 5" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15.5 9.5a3.5 3.5 0 0 1 0 5" }), level === "high" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 7a7 7 0 0 1 0 10" }) : null] })] });
|
|
276
|
+
const CaptionsIcon = ({ on }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [
|
|
277
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
278
|
+
x: "3",
|
|
279
|
+
y: "5.5",
|
|
280
|
+
width: "18",
|
|
281
|
+
height: "13",
|
|
282
|
+
rx: "2.5"
|
|
283
|
+
}),
|
|
284
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 10.5a2 2 0 1 0 0 3M16 10.5a2 2 0 1 0 0 3" }),
|
|
285
|
+
on ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m4 20 16-16" })
|
|
286
|
+
] });
|
|
287
|
+
const SettingsIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
288
|
+
cx: "12",
|
|
289
|
+
cy: "12",
|
|
290
|
+
r: "3"
|
|
291
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 3.5v2M12 18.5v2M20.5 12h-2M5.5 12h-2M18 6l-1.4 1.4M7.4 16.6 6 18M18 18l-1.4-1.4M7.4 7.4 6 6" })] });
|
|
292
|
+
const ContentsIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9 6.5h11M9 12h11M9 17.5h11" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4.5 6.5h.01M4.5 12h.01M4.5 17.5h.01" })] });
|
|
293
|
+
const PictureInPictureIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
294
|
+
x: "3",
|
|
295
|
+
y: "5",
|
|
296
|
+
width: "18",
|
|
297
|
+
height: "14",
|
|
298
|
+
rx: "2"
|
|
299
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
300
|
+
x: "12",
|
|
301
|
+
y: "11.5",
|
|
302
|
+
width: "6.5",
|
|
303
|
+
height: "5",
|
|
304
|
+
rx: "1"
|
|
305
|
+
})] });
|
|
306
|
+
const FullscreenIcon = ({ on }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { children: on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9 4v3a2 2 0 0 1-2 2H4M20 9h-3a2 2 0 0 1-2-2V4M4 15h3a2 2 0 0 1 2 2v3M15 20v-3a2 2 0 0 1 2-2h3" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 9V6a2 2 0 0 1 2-2h3M15 4h3a2 2 0 0 1 2 2v3M20 15v3a2 2 0 0 1-2 2h-3M9 20H6a2 2 0 0 1-2-2v-3" }) });
|
|
307
|
+
const CheckIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m5 12.5 4.5 4.5L19 7.5" }) });
|
|
308
|
+
const ChevronIcon = ({ direction }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: direction === "right" ? "m9.5 6 6 6-6 6" : "m14.5 6-6 6 6 6" }) });
|
|
309
|
+
const CloseIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6.5 6.5 11 11m0-11-11 11" }) });
|
|
310
|
+
const AlertIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Icon, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 4 2.8 19.5h18.4L12 4Z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 10v4M12 17h.01" })] });
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/components/InteractiveVideo/keyed-state.ts
|
|
313
|
+
/**
|
|
314
|
+
* A set held in state — what the player draws — beside a ref that always holds
|
|
315
|
+
* its latest value, for the callbacks that read it between renders, and a
|
|
316
|
+
* toggle that keeps one identity for the life of the player. Toggling a key to
|
|
317
|
+
* where it already is changes nothing, so no render follows.
|
|
318
|
+
*/
|
|
319
|
+
function useKeySet(initial) {
|
|
320
|
+
const [keys, setKeys] = (0, react.useState)(initial);
|
|
321
|
+
const latest = (0, react.useRef)(keys);
|
|
322
|
+
latest.current = keys;
|
|
323
|
+
return [
|
|
324
|
+
keys,
|
|
325
|
+
latest,
|
|
326
|
+
(0, react.useCallback)((key, on) => {
|
|
327
|
+
setKeys((previous) => {
|
|
328
|
+
if (previous.has(key) === on) return previous;
|
|
329
|
+
const next = new Set(previous);
|
|
330
|
+
if (on) next.add(key);
|
|
331
|
+
else next.delete(key);
|
|
332
|
+
latest.current = next;
|
|
333
|
+
return next;
|
|
334
|
+
});
|
|
335
|
+
}, [])
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* A map held in state, with a setter that keeps one identity: `undefined`
|
|
340
|
+
* removes the key, and setting a value it already has changes nothing.
|
|
341
|
+
*/
|
|
342
|
+
function useKeyMap() {
|
|
343
|
+
const [entries, setEntries] = (0, react.useState)(() => /* @__PURE__ */ new Map());
|
|
344
|
+
return [entries, (0, react.useCallback)((key, value) => {
|
|
345
|
+
setEntries((previous) => {
|
|
346
|
+
if (previous.get(key) === value) return previous;
|
|
347
|
+
const next = new Map(previous);
|
|
348
|
+
if (value === void 0) next.delete(key);
|
|
349
|
+
else next.set(key, value);
|
|
350
|
+
return next;
|
|
351
|
+
});
|
|
352
|
+
}, [])];
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/components/InteractiveVideo/shortcuts.ts
|
|
356
|
+
/** The keys of each row, as shown in the list. */
|
|
357
|
+
const SHORTCUT_KEYS = [
|
|
358
|
+
{
|
|
359
|
+
action: "play-pause",
|
|
360
|
+
keys: "Space / K"
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
action: "jump-10",
|
|
364
|
+
keys: "J / L"
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
action: "jump-5",
|
|
368
|
+
keys: "← / →"
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
action: "jump-1",
|
|
372
|
+
keys: "Shift + ← / →"
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
action: "frame",
|
|
376
|
+
keys: ", / ."
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
action: "speed",
|
|
380
|
+
keys: "< / >"
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
action: "percent",
|
|
384
|
+
keys: "0 – 9"
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
action: "start-end",
|
|
388
|
+
keys: "Home / End"
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
action: "chapter",
|
|
392
|
+
keys: "[ / ]"
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
action: "mute",
|
|
396
|
+
keys: "M"
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
action: "captions",
|
|
400
|
+
keys: "C"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
action: "secondary-captions",
|
|
404
|
+
keys: "Shift + C"
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
action: "transcript",
|
|
408
|
+
keys: "T"
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
action: "picture-in-picture",
|
|
412
|
+
keys: "P"
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
action: "fullscreen",
|
|
416
|
+
keys: "F"
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
action: "shortcut-list",
|
|
420
|
+
keys: "?"
|
|
421
|
+
}
|
|
422
|
+
];
|
|
423
|
+
/** Frame step: an approximation, since a video does not report its frame rate. */
|
|
424
|
+
const FRAME_SECONDS = 1 / 30;
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/components/InteractiveVideo/tracks.ts
|
|
427
|
+
const primarySubtag = (tag) => (tag.split("-")[0] ?? tag).toLowerCase();
|
|
428
|
+
/** Two tracks in one language: the `captions` one, which transcribes rather than translates. */
|
|
429
|
+
function preferCaptions(tracks) {
|
|
430
|
+
return tracks.find((track) => track.kind === "captions") ?? tracks[0];
|
|
431
|
+
}
|
|
432
|
+
/** The track for `language`: an exact match, else one of the same primary language (`pt` ↔ `pt-BR`). */
|
|
433
|
+
function matchLanguage(tracks, language) {
|
|
434
|
+
const wanted = language.toLowerCase();
|
|
435
|
+
const exact = tracks.filter((track) => track.srclang.toLowerCase() === wanted);
|
|
436
|
+
if (exact.length > 0) return preferCaptions(exact);
|
|
437
|
+
const family = primarySubtag(wanted);
|
|
438
|
+
return preferCaptions(tracks.filter((track) => primarySubtag(track.srclang) === family));
|
|
439
|
+
}
|
|
440
|
+
/** Tracks a player draws: the caption and subtitle kinds. */
|
|
441
|
+
function drawable(tracks) {
|
|
442
|
+
return tracks.filter((track) => track.kind === "captions" || track.kind === "subtitles");
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The tracks that can be a second line beside `primary`: every drawable track
|
|
446
|
+
* in a different language from it. A second track in the primary's own
|
|
447
|
+
* language — the `subtitles` beside its `captions` — would only print the same
|
|
448
|
+
* words twice, so it is never offered and never chosen.
|
|
449
|
+
*/
|
|
450
|
+
function secondaryCandidates(tracks, primary) {
|
|
451
|
+
const taken = primary?.srclang.toLowerCase();
|
|
452
|
+
return drawable(tracks).filter((track) => track.srclang.toLowerCase() !== taken);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Which track each caption line shows, from the learner's two choices.
|
|
456
|
+
*
|
|
457
|
+
* The primary line always shows something while there are tracks:
|
|
458
|
+
* 1. the track in `captionLanguage` (case-insensitive);
|
|
459
|
+
* 2. else one of the same primary language — `pt` finds `pt-BR`, `es` finds
|
|
460
|
+
* `es-419`;
|
|
461
|
+
* 3. else the `default` track;
|
|
462
|
+
* 4. else the first.
|
|
463
|
+
*
|
|
464
|
+
* Where two tracks share a language, the `captions` one wins.
|
|
465
|
+
*
|
|
466
|
+
* The secondary line is stricter: it appears only when `secondaryCaptionLanguage`
|
|
467
|
+
* is set and matches a track in another language, by steps 1 and 2 alone —
|
|
468
|
+
* never the default, never the first. A second line the learner did not choose
|
|
469
|
+
* must never appear.
|
|
470
|
+
*/
|
|
471
|
+
function resolveCaptionTracks(tracks, preferences) {
|
|
472
|
+
const usable = drawable(tracks);
|
|
473
|
+
if (usable.length === 0) return {};
|
|
474
|
+
const primary = (preferences.captionLanguage === null ? void 0 : matchLanguage(usable, preferences.captionLanguage)) ?? usable.find((track) => track.default === true) ?? usable[0];
|
|
475
|
+
const second = preferences.secondaryCaptionLanguage;
|
|
476
|
+
const secondary = second === null ? void 0 : matchLanguage(secondaryCandidates(usable, primary), second);
|
|
477
|
+
return secondary === void 0 ? { primary } : {
|
|
478
|
+
primary,
|
|
479
|
+
secondary
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/** How long two cues share, in seconds; zero or less when they do not meet. */
|
|
483
|
+
const overlap = (a, b) => Math.min(a.end, b.end) - Math.max(a.start, b.start);
|
|
484
|
+
/**
|
|
485
|
+
* How far back past the last cue that starts before a second-language cue to
|
|
486
|
+
* look for one still running. Captions overlap rarely and briefly; this is the
|
|
487
|
+
* bound `cueIndexAt` uses for the same reason.
|
|
488
|
+
*/
|
|
489
|
+
const LOOKBACK = 8;
|
|
490
|
+
/**
|
|
491
|
+
* The second-language cues that belong under each first-language line of the
|
|
492
|
+
* transcript, by index of the first-language cue.
|
|
493
|
+
*
|
|
494
|
+
* A second-language cue belongs to the line it overlaps MOST — the two tracks
|
|
495
|
+
* need not share timings, and a translation segmented differently still pairs
|
|
496
|
+
* sensibly — and to the earlier line on a tie. A cue that overlaps no line is
|
|
497
|
+
* left out: it is still drawn over the video at its own time, but a transcript
|
|
498
|
+
* row is a first-language line, and there is none for it to sit under.
|
|
499
|
+
*
|
|
500
|
+
* Both lists are sorted by start, as `parseWebVtt` returns them.
|
|
501
|
+
*/
|
|
502
|
+
function pairSecondaryCues(primary, secondary) {
|
|
503
|
+
const rows = primary.map(() => []);
|
|
504
|
+
for (const cue of secondary) {
|
|
505
|
+
let low = 0;
|
|
506
|
+
let high = primary.length;
|
|
507
|
+
while (low < high) {
|
|
508
|
+
const middle = low + high >> 1;
|
|
509
|
+
if (primary[middle].start < cue.end) low = middle + 1;
|
|
510
|
+
else high = middle;
|
|
511
|
+
}
|
|
512
|
+
let best = -1;
|
|
513
|
+
let bestShare = 0;
|
|
514
|
+
let misses = 0;
|
|
515
|
+
for (let index = low - 1; index >= 0 && misses <= LOOKBACK; index -= 1) {
|
|
516
|
+
const share = overlap(primary[index], cue);
|
|
517
|
+
if (share > 0) {
|
|
518
|
+
misses = 0;
|
|
519
|
+
if (share >= bestShare) {
|
|
520
|
+
best = index;
|
|
521
|
+
bestShare = share;
|
|
522
|
+
}
|
|
523
|
+
} else misses += 1;
|
|
524
|
+
}
|
|
525
|
+
if (best !== -1) rows[best].push(cue);
|
|
526
|
+
}
|
|
527
|
+
return rows;
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/components/InteractiveVideo/panels.tsx
|
|
531
|
+
/**
|
|
532
|
+
* A menu of items, with the arrow keys moving between them and Escape closing
|
|
533
|
+
* it — focus then goes back to the button that opened it. When the menu opens
|
|
534
|
+
* focus goes to the row a sub-page was left for, when coming back from one;
|
|
535
|
+
* else the checked item; else the first.
|
|
536
|
+
*/
|
|
537
|
+
function Menu({ label, onClose, children, returnTo }) {
|
|
538
|
+
const ref = (0, react.useRef)(null);
|
|
539
|
+
(0, react.useEffect)(() => {
|
|
540
|
+
const items = ref.current?.querySelectorAll("[role^=\"menuitem\"]");
|
|
541
|
+
const back = returnTo === void 0 ? null : ref.current?.querySelector(`[data-menu-key="${returnTo}"]`);
|
|
542
|
+
const checked = ref.current?.querySelector("[aria-checked=\"true\"]");
|
|
543
|
+
(back ?? checked ?? items?.[0])?.focus();
|
|
544
|
+
}, [returnTo]);
|
|
545
|
+
const onKeyDown = (event) => {
|
|
546
|
+
const items = Array.from(ref.current?.querySelectorAll("[role^=\"menuitem\"]") ?? []);
|
|
547
|
+
const at = items.indexOf(document.activeElement);
|
|
548
|
+
const move = (to) => {
|
|
549
|
+
event.preventDefault();
|
|
550
|
+
items[(to + items.length) % items.length]?.focus();
|
|
551
|
+
};
|
|
552
|
+
if (event.key === "ArrowDown") move(at + 1);
|
|
553
|
+
else if (event.key === "ArrowUp") move(at - 1);
|
|
554
|
+
else if (event.key === "Home") move(0);
|
|
555
|
+
else if (event.key === "End") move(items.length - 1);
|
|
556
|
+
else if (event.key === "Escape") {
|
|
557
|
+
event.preventDefault();
|
|
558
|
+
event.stopPropagation();
|
|
559
|
+
onClose();
|
|
560
|
+
}
|
|
561
|
+
event.stopPropagation();
|
|
562
|
+
};
|
|
563
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
564
|
+
ref,
|
|
565
|
+
className: "lk-iv-menu",
|
|
566
|
+
role: "menu",
|
|
567
|
+
"aria-label": label,
|
|
568
|
+
onKeyDown,
|
|
569
|
+
children
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
function SpeedMenu({ speed, strings, onChoose, onClose }) {
|
|
573
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Menu, {
|
|
574
|
+
label: strings.media.speed,
|
|
575
|
+
onClose,
|
|
576
|
+
children: SPEEDS.map((rate) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
577
|
+
type: "button",
|
|
578
|
+
role: "menuitemradio",
|
|
579
|
+
"aria-checked": rate === speed,
|
|
580
|
+
className: "lk-iv-menu-item",
|
|
581
|
+
onClick: () => onChoose(rate),
|
|
582
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoSpeedValue(rate) }), rate === speed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, {}) : null]
|
|
583
|
+
}, rate))
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function SettingsMenu({ preferences, tracks, primaryTrack, secondaryTrack, captionsFailed, strings, onChange, onCaptionTrack, onSecondaryTrack, onShowShortcuts, onClose }) {
|
|
587
|
+
const [page, setPage] = (0, react.useState)("main");
|
|
588
|
+
const [returnTo, setReturnTo] = (0, react.useState)(void 0);
|
|
589
|
+
const open = (next) => {
|
|
590
|
+
setReturnTo(void 0);
|
|
591
|
+
setPage(next);
|
|
592
|
+
};
|
|
593
|
+
const leave = () => {
|
|
594
|
+
setReturnTo(page);
|
|
595
|
+
setPage(page === "second" ? "captions" : "main");
|
|
596
|
+
};
|
|
597
|
+
const captionsValue = !preferences.captions || primaryTrack === void 0 ? strings.videoOff : secondaryTrack === void 0 ? primaryTrack.label : strings.videoCaptionPair(primaryTrack.label, secondaryTrack.label);
|
|
598
|
+
const partners = secondaryCandidates(tracks, primaryTrack);
|
|
599
|
+
const failedNotes = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [captionsFailed.primary && page === "captions" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
600
|
+
className: "lk-iv-menu-note",
|
|
601
|
+
role: "status",
|
|
602
|
+
children: strings.videoCaptionsFailed
|
|
603
|
+
}) : null, captionsFailed.secondary ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
604
|
+
className: "lk-iv-menu-note",
|
|
605
|
+
role: "status",
|
|
606
|
+
children: strings.videoSecondaryCaptionsFailed
|
|
607
|
+
}) : null] });
|
|
608
|
+
const back = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
609
|
+
type: "button",
|
|
610
|
+
role: "menuitem",
|
|
611
|
+
className: "lk-iv-menu-item lk-iv-menu-back",
|
|
612
|
+
onClick: leave,
|
|
613
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronIcon, { direction: "left" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: page === "captions" ? strings.videoCaptionLanguage : page === "second" ? strings.videoSecondCaptionLanguage : strings.videoCaptionSize })]
|
|
614
|
+
});
|
|
615
|
+
/** A radio naming a track in its own language and writing direction. */
|
|
616
|
+
const trackRadio = (track, on, choose) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
617
|
+
type: "button",
|
|
618
|
+
role: "menuitemradio",
|
|
619
|
+
"aria-checked": on,
|
|
620
|
+
className: "lk-iv-menu-item",
|
|
621
|
+
lang: track.srclang,
|
|
622
|
+
onClick: choose,
|
|
623
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
624
|
+
dir: "auto",
|
|
625
|
+
children: track.label
|
|
626
|
+
}), on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, {}) : null]
|
|
627
|
+
}, `${track.kind}:${track.srclang}`);
|
|
628
|
+
if (page === "captions") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Menu, {
|
|
629
|
+
label: strings.videoCaptionLanguage,
|
|
630
|
+
onClose,
|
|
631
|
+
returnTo,
|
|
632
|
+
children: [
|
|
633
|
+
back,
|
|
634
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
635
|
+
type: "button",
|
|
636
|
+
role: "menuitemradio",
|
|
637
|
+
"aria-checked": !preferences.captions,
|
|
638
|
+
className: "lk-iv-menu-item",
|
|
639
|
+
onClick: () => onChange({ captions: false }),
|
|
640
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoOff }), !preferences.captions ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, {}) : null]
|
|
641
|
+
}),
|
|
642
|
+
tracks.map((track) => trackRadio(track, preferences.captions && primaryTrack === track, () => onCaptionTrack(track))),
|
|
643
|
+
partners.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
644
|
+
type: "button",
|
|
645
|
+
role: "menuitem",
|
|
646
|
+
className: "lk-iv-menu-item",
|
|
647
|
+
"data-menu-key": "second",
|
|
648
|
+
onClick: () => open("second"),
|
|
649
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoSecondCaptionLanguage }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
650
|
+
className: "lk-iv-menu-value",
|
|
651
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
652
|
+
dir: "auto",
|
|
653
|
+
lang: secondaryTrack?.srclang,
|
|
654
|
+
children: secondaryTrack === void 0 ? strings.videoOff : secondaryTrack.label
|
|
655
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronIcon, { direction: "right" })]
|
|
656
|
+
})]
|
|
657
|
+
}) : null,
|
|
658
|
+
failedNotes
|
|
659
|
+
]
|
|
660
|
+
}, "captions");
|
|
661
|
+
if (page === "second") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Menu, {
|
|
662
|
+
label: strings.videoSecondCaptionLanguage,
|
|
663
|
+
onClose,
|
|
664
|
+
children: [
|
|
665
|
+
back,
|
|
666
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
667
|
+
type: "button",
|
|
668
|
+
role: "menuitemradio",
|
|
669
|
+
"aria-checked": secondaryTrack === void 0,
|
|
670
|
+
className: "lk-iv-menu-item",
|
|
671
|
+
onClick: () => onSecondaryTrack(null),
|
|
672
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoOff }), secondaryTrack === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, {}) : null]
|
|
673
|
+
}),
|
|
674
|
+
partners.map((track) => trackRadio(track, secondaryTrack === track, () => onSecondaryTrack(track))),
|
|
675
|
+
failedNotes
|
|
676
|
+
]
|
|
677
|
+
}, "second");
|
|
678
|
+
if (page === "size") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Menu, {
|
|
679
|
+
label: strings.videoCaptionSize,
|
|
680
|
+
onClose,
|
|
681
|
+
children: [back, [
|
|
682
|
+
"small",
|
|
683
|
+
"medium",
|
|
684
|
+
"large"
|
|
685
|
+
].map((size) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
686
|
+
type: "button",
|
|
687
|
+
role: "menuitemradio",
|
|
688
|
+
"aria-checked": preferences.captionSize === size,
|
|
689
|
+
className: "lk-iv-menu-item",
|
|
690
|
+
onClick: () => onChange({ captionSize: size }),
|
|
691
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoCaptionSizeValue(size) }), preferences.captionSize === size ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, {}) : null]
|
|
692
|
+
}, size))]
|
|
693
|
+
}, "size");
|
|
694
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Menu, {
|
|
695
|
+
label: strings.videoSettings,
|
|
696
|
+
onClose,
|
|
697
|
+
returnTo,
|
|
698
|
+
children: [
|
|
699
|
+
tracks.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
700
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
701
|
+
type: "button",
|
|
702
|
+
role: "menuitem",
|
|
703
|
+
className: "lk-iv-menu-item",
|
|
704
|
+
"data-menu-key": "captions",
|
|
705
|
+
onClick: () => open("captions"),
|
|
706
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoCaptionLanguage }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
707
|
+
className: "lk-iv-menu-value",
|
|
708
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
709
|
+
dir: "auto",
|
|
710
|
+
children: captionsValue
|
|
711
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronIcon, { direction: "right" })]
|
|
712
|
+
})]
|
|
713
|
+
}),
|
|
714
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
715
|
+
type: "button",
|
|
716
|
+
role: "menuitem",
|
|
717
|
+
className: "lk-iv-menu-item",
|
|
718
|
+
"data-menu-key": "size",
|
|
719
|
+
onClick: () => open("size"),
|
|
720
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoCaptionSize }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
721
|
+
className: "lk-iv-menu-value",
|
|
722
|
+
children: [strings.videoCaptionSizeValue(preferences.captionSize), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronIcon, { direction: "right" })]
|
|
723
|
+
})]
|
|
724
|
+
}),
|
|
725
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
726
|
+
type: "button",
|
|
727
|
+
role: "menuitemcheckbox",
|
|
728
|
+
"aria-checked": preferences.captionBackground,
|
|
729
|
+
className: "lk-iv-menu-item",
|
|
730
|
+
onClick: () => onChange({ captionBackground: !preferences.captionBackground }),
|
|
731
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoCaptionBackground }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
732
|
+
className: "lk-iv-menu-value",
|
|
733
|
+
children: preferences.captionBackground ? strings.videoOn : strings.videoOff
|
|
734
|
+
})]
|
|
735
|
+
})
|
|
736
|
+
] }) : null,
|
|
737
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
738
|
+
type: "button",
|
|
739
|
+
role: "menuitemcheckbox",
|
|
740
|
+
"aria-checked": preferences.shortcuts,
|
|
741
|
+
className: "lk-iv-menu-item",
|
|
742
|
+
onClick: () => onChange({ shortcuts: !preferences.shortcuts }),
|
|
743
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoKeyboardShortcuts }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
744
|
+
className: "lk-iv-menu-value",
|
|
745
|
+
children: preferences.shortcuts ? strings.videoOn : strings.videoOff
|
|
746
|
+
})]
|
|
747
|
+
}),
|
|
748
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
749
|
+
type: "button",
|
|
750
|
+
role: "menuitem",
|
|
751
|
+
className: "lk-iv-menu-item",
|
|
752
|
+
onClick: onShowShortcuts,
|
|
753
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: strings.videoShortcutList }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("kbd", {
|
|
754
|
+
className: "lk-iv-kbd",
|
|
755
|
+
children: "?"
|
|
756
|
+
})]
|
|
757
|
+
})
|
|
758
|
+
]
|
|
759
|
+
}, "main");
|
|
760
|
+
}
|
|
761
|
+
/** The shortcut list, over the video. The same table the key handler reads. */
|
|
762
|
+
function ShortcutSheet({ strings, onClose }) {
|
|
763
|
+
const closeRef = (0, react.useRef)(null);
|
|
764
|
+
(0, react.useEffect)(() => {
|
|
765
|
+
closeRef.current?.focus();
|
|
766
|
+
}, []);
|
|
767
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
768
|
+
className: "lk-iv-sheet",
|
|
769
|
+
role: "dialog",
|
|
770
|
+
"aria-modal": "false",
|
|
771
|
+
"aria-label": strings.videoKeyboardShortcuts,
|
|
772
|
+
onKeyDown: (event) => {
|
|
773
|
+
if (event.key === "Escape" || event.key === "?") {
|
|
774
|
+
event.preventDefault();
|
|
775
|
+
event.stopPropagation();
|
|
776
|
+
onClose();
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
780
|
+
className: "lk-iv-sheet-head",
|
|
781
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
782
|
+
className: "lk-iv-sheet-title",
|
|
783
|
+
children: strings.videoKeyboardShortcuts
|
|
784
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
785
|
+
ref: closeRef,
|
|
786
|
+
type: "button",
|
|
787
|
+
className: "lk-iv-button",
|
|
788
|
+
"aria-label": strings.videoClose,
|
|
789
|
+
onClick: onClose,
|
|
790
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CloseIcon, {})
|
|
791
|
+
})]
|
|
792
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dl", {
|
|
793
|
+
className: "lk-iv-shortcuts",
|
|
794
|
+
children: SHORTCUT_KEYS.map(({ action, keys }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
795
|
+
className: "lk-iv-shortcut",
|
|
796
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("kbd", {
|
|
797
|
+
className: "lk-iv-kbd",
|
|
798
|
+
children: keys
|
|
799
|
+
}) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: strings.videoShortcut(action) })]
|
|
800
|
+
}, action))
|
|
801
|
+
})]
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* The panel below the video: the chapters and quizzes in time order, and the
|
|
806
|
+
* transcript. Nothing here costs a request — the transcript is the captions,
|
|
807
|
+
* already parsed.
|
|
808
|
+
*/
|
|
809
|
+
function ContentsPanel({ idPrefix, tab, onTab, chapters, quizzes, cues, primaryLanguage, secondaryCues, secondaryLanguage, activeCue, limit, strings, onSeek, onOpenQuiz }) {
|
|
810
|
+
const [query, setQuery] = (0, react.useState)("");
|
|
811
|
+
const listRef = (0, react.useRef)(null);
|
|
812
|
+
const followUntil = (0, react.useRef)(0);
|
|
813
|
+
const hasTranscript = cues.length > 0;
|
|
814
|
+
const shownTab = hasTranscript ? tab : "contents";
|
|
815
|
+
const paired = (0, react.useMemo)(() => pairSecondaryCues(cues, secondaryCues), [cues, secondaryCues]);
|
|
816
|
+
const lines = (0, react.useMemo)(() => {
|
|
817
|
+
const trimmed = query.trim().toLowerCase();
|
|
818
|
+
return cues.map((cue, index) => ({
|
|
819
|
+
cue,
|
|
820
|
+
index,
|
|
821
|
+
second: (paired[index] ?? []).filter((line) => line.start <= limit).map((line) => line.text).join(" ")
|
|
822
|
+
})).filter(({ cue }) => cue.start <= limit).filter(({ cue, second }) => trimmed === "" || cue.text.toLowerCase().includes(trimmed) || second.toLowerCase().includes(trimmed));
|
|
823
|
+
}, [
|
|
824
|
+
cues,
|
|
825
|
+
paired,
|
|
826
|
+
limit,
|
|
827
|
+
query
|
|
828
|
+
]);
|
|
829
|
+
(0, react.useEffect)(() => {
|
|
830
|
+
if (shownTab !== "transcript" || query.trim() !== "" || Date.now() < followUntil.current) return;
|
|
831
|
+
const active = listRef.current?.querySelector("[data-active=\"true\"]");
|
|
832
|
+
if (active !== null && active !== void 0 && listRef.current !== null) {
|
|
833
|
+
const list = listRef.current;
|
|
834
|
+
const top = active.offsetTop - list.clientHeight / 3;
|
|
835
|
+
list.scrollTo?.({
|
|
836
|
+
top,
|
|
837
|
+
behavior: "smooth"
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}, [
|
|
841
|
+
activeCue,
|
|
842
|
+
shownTab,
|
|
843
|
+
query
|
|
844
|
+
]);
|
|
845
|
+
const rows = [...chapters.map((chapter) => ({
|
|
846
|
+
kind: "chapter",
|
|
847
|
+
at: chapter.at,
|
|
848
|
+
chapter
|
|
849
|
+
})), ...quizzes.map((quiz) => ({
|
|
850
|
+
kind: "quiz",
|
|
851
|
+
at: quiz.at,
|
|
852
|
+
quiz
|
|
853
|
+
}))].sort((a, b) => a.at - b.at || (a.kind === "chapter" ? -1 : 1));
|
|
854
|
+
const highlight = (text) => {
|
|
855
|
+
const trimmed = query.trim();
|
|
856
|
+
const at = trimmed === "" ? -1 : text.toLowerCase().indexOf(trimmed.toLowerCase());
|
|
857
|
+
if (at === -1) return text;
|
|
858
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
859
|
+
text.slice(0, at),
|
|
860
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("mark", { children: text.slice(at, at + trimmed.length) }),
|
|
861
|
+
text.slice(at + trimmed.length)
|
|
862
|
+
] });
|
|
863
|
+
};
|
|
864
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
865
|
+
className: "lk-iv-panel",
|
|
866
|
+
"aria-label": strings.videoPanel,
|
|
867
|
+
children: [hasTranscript ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
868
|
+
className: "lk-iv-tabs",
|
|
869
|
+
role: "tablist",
|
|
870
|
+
"aria-label": strings.videoPanel,
|
|
871
|
+
children: ["contents", "transcript"].map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
872
|
+
type: "button",
|
|
873
|
+
role: "tab",
|
|
874
|
+
id: `${idPrefix}-tab-${name}`,
|
|
875
|
+
"aria-controls": `${idPrefix}-tabpanel`,
|
|
876
|
+
"aria-selected": shownTab === name,
|
|
877
|
+
tabIndex: shownTab === name ? 0 : -1,
|
|
878
|
+
className: "lk-iv-tab",
|
|
879
|
+
onClick: () => onTab(name),
|
|
880
|
+
onKeyDown: (event) => {
|
|
881
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
882
|
+
event.preventDefault();
|
|
883
|
+
onTab(name === "contents" ? "transcript" : "contents");
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
children: name === "contents" ? strings.videoContents : strings.videoTranscript
|
|
887
|
+
}, name))
|
|
888
|
+
}) : null, shownTab === "contents" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
|
|
889
|
+
className: "lk-iv-contents",
|
|
890
|
+
...hasTranscript ? {
|
|
891
|
+
role: "tabpanel",
|
|
892
|
+
id: `${idPrefix}-tabpanel`,
|
|
893
|
+
"aria-labelledby": `${idPrefix}-tab-contents`
|
|
894
|
+
} : { "aria-label": strings.videoContents },
|
|
895
|
+
children: rows.map((row) => row.kind === "chapter" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
896
|
+
className: "lk-iv-contents-chapter",
|
|
897
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
898
|
+
type: "button",
|
|
899
|
+
className: "lk-iv-row",
|
|
900
|
+
onClick: () => onSeek(row.chapter.at),
|
|
901
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
902
|
+
className: "lk-iv-row-time",
|
|
903
|
+
children: clock(row.chapter.at)
|
|
904
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
905
|
+
className: "lk-iv-row-title",
|
|
906
|
+
children: row.chapter.title
|
|
907
|
+
})]
|
|
908
|
+
})
|
|
909
|
+
}, `c-${row.at}`) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
910
|
+
className: "lk-iv-contents-quiz",
|
|
911
|
+
"data-state": row.quiz.state,
|
|
912
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
913
|
+
type: "button",
|
|
914
|
+
className: "lk-iv-row",
|
|
915
|
+
onClick: () => onOpenQuiz(row.quiz.id),
|
|
916
|
+
children: [
|
|
917
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
918
|
+
className: "lk-iv-row-time",
|
|
919
|
+
children: clock(row.quiz.at)
|
|
920
|
+
}),
|
|
921
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
922
|
+
className: "lk-iv-row-title",
|
|
923
|
+
children: [
|
|
924
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
925
|
+
className: "lk-iv-marker-dot",
|
|
926
|
+
"data-state": row.quiz.state,
|
|
927
|
+
"aria-hidden": "true"
|
|
928
|
+
}),
|
|
929
|
+
row.quiz.title,
|
|
930
|
+
row.quiz.required ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
931
|
+
className: "lk-iv-tag",
|
|
932
|
+
children: strings.videoRequired
|
|
933
|
+
})] }) : null
|
|
934
|
+
]
|
|
935
|
+
}),
|
|
936
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
937
|
+
className: "lk-iv-row-status",
|
|
938
|
+
children: strings.videoQuizProgress(row.quiz.answered, row.quiz.total)
|
|
939
|
+
})
|
|
940
|
+
]
|
|
941
|
+
})
|
|
942
|
+
}, `q-${row.quiz.id}`))
|
|
943
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
944
|
+
className: "lk-iv-transcript",
|
|
945
|
+
role: "tabpanel",
|
|
946
|
+
id: `${idPrefix}-tabpanel`,
|
|
947
|
+
"aria-labelledby": `${idPrefix}-tab-transcript`,
|
|
948
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
949
|
+
id: `${idPrefix}-search`,
|
|
950
|
+
className: "lk-iv-search",
|
|
951
|
+
type: "search",
|
|
952
|
+
value: query,
|
|
953
|
+
placeholder: strings.videoTranscriptSearch,
|
|
954
|
+
"aria-label": strings.videoTranscriptSearch,
|
|
955
|
+
onChange: (event) => setQuery(event.target.value)
|
|
956
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
957
|
+
ref: listRef,
|
|
958
|
+
className: "lk-iv-lines",
|
|
959
|
+
onWheel: () => {
|
|
960
|
+
followUntil.current = Date.now() + 4e3;
|
|
961
|
+
},
|
|
962
|
+
onTouchMove: () => {
|
|
963
|
+
followUntil.current = Date.now() + 4e3;
|
|
964
|
+
},
|
|
965
|
+
children: lines.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
966
|
+
className: "lk-iv-empty",
|
|
967
|
+
children: strings.videoTranscriptNoMatch
|
|
968
|
+
}) : lines.map(({ cue, index, second }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
969
|
+
type: "button",
|
|
970
|
+
className: "lk-iv-line",
|
|
971
|
+
"data-active": index === activeCue || void 0,
|
|
972
|
+
onClick: () => onSeek(cue.start),
|
|
973
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
974
|
+
className: "lk-iv-row-time",
|
|
975
|
+
children: clock(cue.start)
|
|
976
|
+
}), secondaryCues.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
977
|
+
lang: primaryLanguage,
|
|
978
|
+
dir: "auto",
|
|
979
|
+
children: highlight(cue.text)
|
|
980
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
981
|
+
className: "lk-iv-line-text",
|
|
982
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
983
|
+
lang: primaryLanguage,
|
|
984
|
+
dir: "auto",
|
|
985
|
+
children: highlight(cue.text)
|
|
986
|
+
}), second === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
987
|
+
className: "lk-iv-line-secondary",
|
|
988
|
+
lang: secondaryLanguage,
|
|
989
|
+
dir: "auto",
|
|
990
|
+
children: highlight(second)
|
|
991
|
+
})]
|
|
992
|
+
})]
|
|
993
|
+
}, `${cue.start}-${index}`))
|
|
994
|
+
})]
|
|
995
|
+
})]
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
/** Over the last frame: what the learner did, and where to go from here. */
|
|
999
|
+
function EndScreen({ idPrefix, renderMode, quizzes, answered, total, percent, strings, onOpenQuiz, onWatchAgain, pending, onFinish }) {
|
|
1000
|
+
const pendingId = `${idPrefix}-end-pending`;
|
|
1001
|
+
const headingRef = (0, react.useRef)(null);
|
|
1002
|
+
(0, react.useEffect)(() => {
|
|
1003
|
+
headingRef.current?.focus();
|
|
1004
|
+
}, []);
|
|
1005
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
1006
|
+
className: "lk-iv-end",
|
|
1007
|
+
"aria-labelledby": `${idPrefix}-end-title`,
|
|
1008
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1009
|
+
className: "lk-iv-end-card",
|
|
1010
|
+
children: [
|
|
1011
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1012
|
+
id: `${idPrefix}-end-title`,
|
|
1013
|
+
ref: headingRef,
|
|
1014
|
+
tabIndex: -1,
|
|
1015
|
+
className: "lk-iv-end-title",
|
|
1016
|
+
children: strings.videoEnded
|
|
1017
|
+
}),
|
|
1018
|
+
total > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1019
|
+
className: "lk-iv-end-summary",
|
|
1020
|
+
children: percent !== null && renderMode !== "exam" ? strings.videoEndScore(answered, total, percent) : strings.videoEndAnswered(answered, total)
|
|
1021
|
+
}) : null,
|
|
1022
|
+
quizzes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
|
|
1023
|
+
className: "lk-iv-end-list",
|
|
1024
|
+
children: quizzes.map((quiz) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1025
|
+
type: "button",
|
|
1026
|
+
className: "lk-iv-row",
|
|
1027
|
+
"data-state": quiz.state,
|
|
1028
|
+
onClick: () => onOpenQuiz(quiz.id),
|
|
1029
|
+
children: [
|
|
1030
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1031
|
+
className: "lk-iv-row-time",
|
|
1032
|
+
children: clock(quiz.at)
|
|
1033
|
+
}),
|
|
1034
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1035
|
+
className: "lk-iv-row-title",
|
|
1036
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1037
|
+
className: "lk-iv-marker-dot",
|
|
1038
|
+
"data-state": quiz.state,
|
|
1039
|
+
"aria-hidden": "true"
|
|
1040
|
+
}), quiz.title]
|
|
1041
|
+
}),
|
|
1042
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1043
|
+
className: "lk-iv-row-status",
|
|
1044
|
+
children: strings.videoQuizProgress(quiz.answered, quiz.total)
|
|
1045
|
+
})
|
|
1046
|
+
]
|
|
1047
|
+
}) }, quiz.id))
|
|
1048
|
+
}) : null,
|
|
1049
|
+
onFinish !== void 0 && pending ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1050
|
+
id: pendingId,
|
|
1051
|
+
className: "lk-iv-end-pending",
|
|
1052
|
+
role: "status",
|
|
1053
|
+
children: strings.videoAnswerPending
|
|
1054
|
+
}) : null,
|
|
1055
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1056
|
+
className: "lk-iv-end-actions",
|
|
1057
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1058
|
+
type: "button",
|
|
1059
|
+
className: "lk-iv-action",
|
|
1060
|
+
onClick: onWatchAgain,
|
|
1061
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReplayIcon, {}), strings.videoWatchAgain]
|
|
1062
|
+
}), onFinish !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1063
|
+
type: "button",
|
|
1064
|
+
className: "lk-iv-action lk-iv-action-primary",
|
|
1065
|
+
"aria-disabled": pending || void 0,
|
|
1066
|
+
"aria-describedby": pending ? pendingId : void 0,
|
|
1067
|
+
onClick: onFinish,
|
|
1068
|
+
children: strings.videoFinish
|
|
1069
|
+
}) : null]
|
|
1070
|
+
})
|
|
1071
|
+
]
|
|
1072
|
+
})
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/components/InteractiveVideo/prefs.ts
|
|
1077
|
+
const DEFAULT_PREFERENCES = {
|
|
1078
|
+
speed: 1,
|
|
1079
|
+
volume: 1,
|
|
1080
|
+
muted: false,
|
|
1081
|
+
captions: true,
|
|
1082
|
+
captionLanguage: null,
|
|
1083
|
+
secondaryCaptionLanguage: null,
|
|
1084
|
+
captionSize: "medium",
|
|
1085
|
+
captionBackground: true,
|
|
1086
|
+
panel: false,
|
|
1087
|
+
shortcuts: true
|
|
1088
|
+
};
|
|
1089
|
+
const STORAGE_KEY = "lk.video.v1";
|
|
1090
|
+
/** A BCP 47 tag as a track's `srclang` carries one: `en`, `pt-BR`, `es-419`. */
|
|
1091
|
+
const LANGUAGE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
1092
|
+
const BOOLEAN_FIELDS = [
|
|
1093
|
+
"muted",
|
|
1094
|
+
"captions",
|
|
1095
|
+
"captionBackground",
|
|
1096
|
+
"panel",
|
|
1097
|
+
"shortcuts"
|
|
1098
|
+
];
|
|
1099
|
+
const LANGUAGE_FIELDS = ["captionLanguage", "secondaryCaptionLanguage"];
|
|
1100
|
+
/**
|
|
1101
|
+
* Every field of `raw` that is present and usable, clamped — and nothing at
|
|
1102
|
+
* all for a field that is absent or unusable, so a layer below it applies.
|
|
1103
|
+
*
|
|
1104
|
+
* Anything in storage is writable by the learner and survives a schema
|
|
1105
|
+
* change, so it is input from a stranger: one `volume: 50` must not mute every
|
|
1106
|
+
* video, and a `speed: null` must not pin playback at a rate the menu cannot
|
|
1107
|
+
* undo. A host's layers go through here too — a `speed: 3` is no more a rate
|
|
1108
|
+
* the menu can undo for having come from code.
|
|
1109
|
+
*
|
|
1110
|
+
* `null` is a value for the two language fields (the default track; no second
|
|
1111
|
+
* line), so it counts as present; an unparseable tag does not.
|
|
1112
|
+
*/
|
|
1113
|
+
function sanitizePartialPreferences(raw) {
|
|
1114
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
1115
|
+
const value = raw;
|
|
1116
|
+
const has = (key) => Object.hasOwn(value, key);
|
|
1117
|
+
const out = {};
|
|
1118
|
+
if (has("speed") && SPEEDS.includes(value.speed)) out.speed = value.speed;
|
|
1119
|
+
if (has("volume") && typeof value.volume === "number" && Number.isFinite(value.volume)) out.volume = Math.min(1, Math.max(0, value.volume));
|
|
1120
|
+
for (const field of BOOLEAN_FIELDS) if (has(field) && typeof value[field] === "boolean") out[field] = value[field];
|
|
1121
|
+
for (const field of LANGUAGE_FIELDS) {
|
|
1122
|
+
const tag = value[field];
|
|
1123
|
+
if (has(field) && (tag === null || typeof tag === "string" && LANGUAGE_TAG.test(tag))) out[field] = tag;
|
|
1124
|
+
}
|
|
1125
|
+
if (has("captionSize") && (value.captionSize === "small" || value.captionSize === "medium" || value.captionSize === "large")) out.captionSize = value.captionSize;
|
|
1126
|
+
return out;
|
|
1127
|
+
}
|
|
1128
|
+
/** A pair of one language is no pair: the second line is dropped, case-insensitively. */
|
|
1129
|
+
function normalised(preferences) {
|
|
1130
|
+
const { captionLanguage: first, secondaryCaptionLanguage: second } = preferences;
|
|
1131
|
+
return first !== null && second !== null && first.toLowerCase() === second.toLowerCase() ? {
|
|
1132
|
+
...preferences,
|
|
1133
|
+
secondaryCaptionLanguage: null
|
|
1134
|
+
} : preferences;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* The preferences in force, decided field by field, highest first:
|
|
1138
|
+
*
|
|
1139
|
+
* 1. `force` — the host's `preferences` prop, which overrides what the
|
|
1140
|
+
* learner chose, on every mount;
|
|
1141
|
+
* 2. `stored` — what the learner chose in this browser;
|
|
1142
|
+
* 3. `defaults` — the host's `defaultPreferences` prop: a starting point, such
|
|
1143
|
+
* as a language pair kept on the learner's account;
|
|
1144
|
+
* 4. the SDK's own defaults.
|
|
1145
|
+
*
|
|
1146
|
+
* Deciding per field is the point: a learner who has only ever changed the
|
|
1147
|
+
* volume has chosen no caption language, so the host's suggested pair applies
|
|
1148
|
+
* to them — where a whole stored object would have beaten it.
|
|
1149
|
+
*/
|
|
1150
|
+
function resolvePreferences(layers) {
|
|
1151
|
+
return normalised({
|
|
1152
|
+
...DEFAULT_PREFERENCES,
|
|
1153
|
+
...sanitizePartialPreferences(layers.defaults),
|
|
1154
|
+
...sanitizePartialPreferences(layers.stored),
|
|
1155
|
+
...sanitizePartialPreferences(layers.force),
|
|
1156
|
+
...sanitizePartialPreferences(layers.chosen)
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
/** `previous` with a learner's change applied, clamped and normalised like everything else. */
|
|
1160
|
+
function applyPreferenceChange(previous, change) {
|
|
1161
|
+
return normalised({
|
|
1162
|
+
...previous,
|
|
1163
|
+
...sanitizePartialPreferences(change)
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* What the learner chose in this browser: only the fields present in storage.
|
|
1168
|
+
* Every read goes through a `try`: a private window, blocked site data or a
|
|
1169
|
+
* sandboxed frame throws on `localStorage` itself.
|
|
1170
|
+
*/
|
|
1171
|
+
function readStoredPreferences() {
|
|
1172
|
+
try {
|
|
1173
|
+
const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
|
|
1174
|
+
return stored ? sanitizePartialPreferences(JSON.parse(stored)) : {};
|
|
1175
|
+
} catch {
|
|
1176
|
+
return {};
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Remembers a learner's change: the fields they changed, merged into what they
|
|
1181
|
+
* chose before. Only what the learner chose is stored — never a value the host
|
|
1182
|
+
* forced or suggested — so a host that later suggests a different pair still
|
|
1183
|
+
* reaches a learner who never picked one. A payload written by 15.0, which
|
|
1184
|
+
* stored every field, reads as every field chosen: the behaviour it had.
|
|
1185
|
+
*
|
|
1186
|
+
* A browser that will not store them loses nothing but the memory.
|
|
1187
|
+
*/
|
|
1188
|
+
function rememberPreferences(change) {
|
|
1189
|
+
try {
|
|
1190
|
+
const next = {
|
|
1191
|
+
...readStoredPreferences(),
|
|
1192
|
+
...sanitizePartialPreferences(change)
|
|
1193
|
+
};
|
|
1194
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
1195
|
+
} catch {}
|
|
1196
|
+
}
|
|
1197
|
+
//#endregion
|
|
1198
|
+
//#region src/components/InteractiveVideo/quiz-engine.ts
|
|
1199
|
+
/**
|
|
1200
|
+
* The quiz playback has just reached, or undefined: the EARLIEST quiz with
|
|
1201
|
+
* `previous < at ≤ now` that has not been handled in this pass.
|
|
1202
|
+
*
|
|
1203
|
+
* Crossing, never equality: at 2.5× speed about 0.6 s of video passes between
|
|
1204
|
+
* two `timeupdate` events, so a quiz checked for `now === at` is missed. And
|
|
1205
|
+
* the earliest wins, so when a janky frame carries playback past two quizzes
|
|
1206
|
+
* at once, the player opens the first, stops there, and meets the second as
|
|
1207
|
+
* playback continues — no quiz is ever skipped by speed.
|
|
1208
|
+
*/
|
|
1209
|
+
function crossedQuiz(cues, previous, now, handled) {
|
|
1210
|
+
let earliest;
|
|
1211
|
+
for (const cue of cues) {
|
|
1212
|
+
if (handled.has(cue.id) || !(previous < cue.at && cue.at <= now)) continue;
|
|
1213
|
+
if (earliest === void 0 || cue.at < earliest.at) earliest = cue;
|
|
1214
|
+
}
|
|
1215
|
+
return earliest;
|
|
1216
|
+
}
|
|
1217
|
+
/** The quizzes that open when the video ends: every one at or past its duration. */
|
|
1218
|
+
function quizzesAtEnd(cues, duration, handled) {
|
|
1219
|
+
return cues.filter((cue) => !handled.has(cue.id) && cue.at >= duration - .05).sort((a, b) => a.at - b.at);
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Where a seek from `from` to `to` may actually land.
|
|
1223
|
+
*
|
|
1224
|
+
* Backward is always free. Forward stops at the first `required` quiz whose
|
|
1225
|
+
* questions are not all answered — landing ON it, so it opens — and, under
|
|
1226
|
+
* `no-skip-ahead`, at the furthest point the learner has reached. The earlier
|
|
1227
|
+
* of the two limits wins, and the answer says which, so the player can tell
|
|
1228
|
+
* the learner why the seek stopped short.
|
|
1229
|
+
*/
|
|
1230
|
+
function limitSeek(timeline, from, to, furthest, finished) {
|
|
1231
|
+
if (to <= from) return { at: to };
|
|
1232
|
+
let at = to;
|
|
1233
|
+
let hold;
|
|
1234
|
+
if (timeline.navigation === "no-skip-ahead" && to > furthest) {
|
|
1235
|
+
at = Math.max(from, furthest);
|
|
1236
|
+
hold = {
|
|
1237
|
+
kind: "no-skip-ahead",
|
|
1238
|
+
furthest
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
const blocking = timeline.cues.filter((cue) => cue.required === true && cue.at > from && cue.at <= to && !finished(cue.id)).sort((a, b) => a.at - b.at)[0];
|
|
1242
|
+
if (blocking !== void 0 && blocking.at <= at) return {
|
|
1243
|
+
at: blocking.at,
|
|
1244
|
+
hold: {
|
|
1245
|
+
kind: "required",
|
|
1246
|
+
cue: blocking
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
return hold === void 0 ? { at } : {
|
|
1250
|
+
at,
|
|
1251
|
+
hold
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* The earliest point a resumed learner may start from: never past the first
|
|
1256
|
+
* `required` quiz with a question still unanswered, whatever the stored
|
|
1257
|
+
* position says — a resume is never a way past a required quiz.
|
|
1258
|
+
*/
|
|
1259
|
+
function limitResume(timeline, at, finished) {
|
|
1260
|
+
const blocking = timeline.cues.filter((cue) => cue.required === true && cue.at < at && !finished(cue.id)).sort((a, b) => a.at - b.at)[0];
|
|
1261
|
+
return blocking === void 0 ? at : Math.max(0, blocking.at - .25);
|
|
1262
|
+
}
|
|
1263
|
+
//#endregion
|
|
1264
|
+
//#region src/components/InteractiveVideo/Scrubber.tsx
|
|
1265
|
+
/** Half the widest the bubble may grow, in px: keeps it inside the track at the ends. */
|
|
1266
|
+
const BUBBLE_HALF = 120;
|
|
1267
|
+
/** How near the pointer must be to a marker, in px, for the bubble to name its quiz. */
|
|
1268
|
+
const MARKER_REACH = 8;
|
|
1269
|
+
/**
|
|
1270
|
+
* The progress bar.
|
|
1271
|
+
*
|
|
1272
|
+
* A thin line at rest and a thicker one under the pointer — the line, not the
|
|
1273
|
+
* control, is what should be visible — over a generous hit strip, so a thin bar
|
|
1274
|
+
* is still easy to grab. Pointer events with capture, so a drag that leaves the
|
|
1275
|
+
* bar keeps scrubbing. It is one slider for assistive technology; its markers
|
|
1276
|
+
* are drawn, not focusable, and a keyboard learner reaches quizzes through
|
|
1277
|
+
* `[` / `]` and the contents panel instead.
|
|
1278
|
+
*/
|
|
1279
|
+
function Scrubber({ current, duration, buffered, chapters, markers, cues, previewLimit, strings, onSeek, onScrubChange }) {
|
|
1280
|
+
const trackRef = (0, react.useRef)(null);
|
|
1281
|
+
const [hover, setHover] = (0, react.useState)(null);
|
|
1282
|
+
const [dragging, setDragging] = (0, react.useState)(false);
|
|
1283
|
+
const ready = duration > 0;
|
|
1284
|
+
const percent = (value) => `${ready ? Math.min(100, Math.max(0, value / duration * 100)) : 0}%`;
|
|
1285
|
+
const measure = (0, react.useCallback)((clientX) => {
|
|
1286
|
+
const rect = trackRef.current?.getBoundingClientRect();
|
|
1287
|
+
if (rect === void 0 || rect.width === 0 || !ready) return null;
|
|
1288
|
+
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
|
1289
|
+
return {
|
|
1290
|
+
x: ratio * rect.width,
|
|
1291
|
+
width: rect.width,
|
|
1292
|
+
time: ratio * duration
|
|
1293
|
+
};
|
|
1294
|
+
}, [duration, ready]);
|
|
1295
|
+
const onPointerDown = (event) => {
|
|
1296
|
+
const at = measure(event.clientX);
|
|
1297
|
+
if (at === null) return;
|
|
1298
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1299
|
+
setDragging(true);
|
|
1300
|
+
onScrubChange(true);
|
|
1301
|
+
setHover(at);
|
|
1302
|
+
onSeek(at.time);
|
|
1303
|
+
};
|
|
1304
|
+
const onPointerMove = (event) => {
|
|
1305
|
+
const at = measure(event.clientX);
|
|
1306
|
+
if (at === null) return;
|
|
1307
|
+
setHover(at);
|
|
1308
|
+
if (dragging) onSeek(at.time);
|
|
1309
|
+
};
|
|
1310
|
+
const endDrag = (event) => {
|
|
1311
|
+
if (!dragging) return;
|
|
1312
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
1313
|
+
setDragging(false);
|
|
1314
|
+
onScrubChange(false);
|
|
1315
|
+
};
|
|
1316
|
+
const onKeyDown = (event) => {
|
|
1317
|
+
if (!ready) return;
|
|
1318
|
+
const step = event.shiftKey ? 1 : 5;
|
|
1319
|
+
const move = {
|
|
1320
|
+
ArrowLeft: -step,
|
|
1321
|
+
ArrowRight: step,
|
|
1322
|
+
ArrowDown: -step,
|
|
1323
|
+
ArrowUp: step,
|
|
1324
|
+
PageDown: -30,
|
|
1325
|
+
PageUp: 30
|
|
1326
|
+
}[event.key];
|
|
1327
|
+
if (move !== void 0) {
|
|
1328
|
+
event.preventDefault();
|
|
1329
|
+
event.stopPropagation();
|
|
1330
|
+
onSeek(current + move);
|
|
1331
|
+
} else if (event.key === "Home" || event.key === "End") {
|
|
1332
|
+
event.preventDefault();
|
|
1333
|
+
event.stopPropagation();
|
|
1334
|
+
onSeek(event.key === "Home" ? 0 : duration);
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
const chapterAt = (time) => [...chapters].reverse().find((chapter) => chapter.at <= time);
|
|
1338
|
+
const hoverCue = hover !== null && hover.time <= previewLimit ? cues[cueIndexAt(cues, hover.time)] : void 0;
|
|
1339
|
+
const hoverMarker = hover === null ? void 0 : markers.find((marker) => Math.abs(marker.at / duration * hover.width - hover.x) <= MARKER_REACH);
|
|
1340
|
+
const hoverChapter = hover === null ? void 0 : chapterAt(hover.time);
|
|
1341
|
+
const bubbleLeft = hover === null ? 0 : hover.width > 240 ? Math.min(hover.width - BUBBLE_HALF, Math.max(BUBBLE_HALF, hover.x)) : hover.width / 2;
|
|
1342
|
+
const currentChapter = chapterAt(current);
|
|
1343
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1344
|
+
ref: trackRef,
|
|
1345
|
+
className: "lk-iv-scrubber",
|
|
1346
|
+
role: "slider",
|
|
1347
|
+
tabIndex: ready ? 0 : -1,
|
|
1348
|
+
"aria-label": strings.media.seek,
|
|
1349
|
+
"aria-valuemin": 0,
|
|
1350
|
+
"aria-valuemax": Math.round(duration),
|
|
1351
|
+
"aria-valuenow": Math.round(current),
|
|
1352
|
+
"aria-valuetext": currentChapter === void 0 ? strings.media.timeValue(clock(current), clock(duration)) : `${strings.media.timeValue(clock(current), clock(duration))}, ${currentChapter.title}`,
|
|
1353
|
+
"data-dragging": dragging || void 0,
|
|
1354
|
+
onPointerDown,
|
|
1355
|
+
onPointerMove,
|
|
1356
|
+
onPointerUp: endDrag,
|
|
1357
|
+
onPointerCancel: endDrag,
|
|
1358
|
+
onPointerLeave: () => {
|
|
1359
|
+
if (!dragging) setHover(null);
|
|
1360
|
+
},
|
|
1361
|
+
onKeyDown,
|
|
1362
|
+
children: [
|
|
1363
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1364
|
+
className: "lk-iv-scrubber-track",
|
|
1365
|
+
"aria-hidden": "true",
|
|
1366
|
+
children: [
|
|
1367
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1368
|
+
className: "lk-iv-scrubber-buffered",
|
|
1369
|
+
style: { width: percent(buffered) }
|
|
1370
|
+
}),
|
|
1371
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1372
|
+
className: "lk-iv-scrubber-played",
|
|
1373
|
+
style: { width: percent(current) }
|
|
1374
|
+
}),
|
|
1375
|
+
chapters.filter((chapter) => chapter.at > 0 && chapter.at < duration).map((chapter) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1376
|
+
className: "lk-iv-scrubber-gap",
|
|
1377
|
+
style: { insetInlineStart: percent(chapter.at) }
|
|
1378
|
+
}, `chapter-${chapter.at}`))
|
|
1379
|
+
]
|
|
1380
|
+
}),
|
|
1381
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1382
|
+
className: "lk-iv-scrubber-thumb",
|
|
1383
|
+
"aria-hidden": "true",
|
|
1384
|
+
style: { insetInlineStart: percent(current) }
|
|
1385
|
+
}),
|
|
1386
|
+
markers.map((marker) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1387
|
+
className: "lk-iv-marker",
|
|
1388
|
+
"data-state": marker.state,
|
|
1389
|
+
"data-required": marker.required || void 0,
|
|
1390
|
+
"aria-hidden": "true",
|
|
1391
|
+
style: { insetInlineStart: percent(Math.min(marker.at, duration)) }
|
|
1392
|
+
}, marker.id)),
|
|
1393
|
+
hover !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1394
|
+
className: "lk-iv-bubble",
|
|
1395
|
+
"aria-hidden": "true",
|
|
1396
|
+
style: { insetInlineStart: `${bubbleLeft}px` },
|
|
1397
|
+
children: [
|
|
1398
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1399
|
+
className: "lk-iv-bubble-time",
|
|
1400
|
+
children: [clock(hover.time), hoverChapter !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1401
|
+
className: "lk-iv-bubble-chapter",
|
|
1402
|
+
children: [" · ", hoverChapter.title]
|
|
1403
|
+
}) : null]
|
|
1404
|
+
}),
|
|
1405
|
+
hoverMarker !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1406
|
+
className: "lk-iv-bubble-quiz",
|
|
1407
|
+
children: hoverMarker.label
|
|
1408
|
+
}) : null,
|
|
1409
|
+
hoverCue !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1410
|
+
className: "lk-iv-bubble-caption",
|
|
1411
|
+
children: hoverCue.text
|
|
1412
|
+
}) : null
|
|
1413
|
+
]
|
|
1414
|
+
}) : null
|
|
1415
|
+
]
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
//#endregion
|
|
1419
|
+
//#region src/components/InteractiveVideo/InteractiveVideo.tsx
|
|
1420
|
+
/** What distinguishes one video's content from another's: when it changes, the player starts over. */
|
|
1421
|
+
function contentKey(group) {
|
|
1422
|
+
try {
|
|
1423
|
+
return JSON.stringify([
|
|
1424
|
+
group.id,
|
|
1425
|
+
group.stimulus,
|
|
1426
|
+
group.items,
|
|
1427
|
+
group.timeline
|
|
1428
|
+
]);
|
|
1429
|
+
} catch {
|
|
1430
|
+
return String(group?.id);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* An interactive video: a video that pauses at each quiz so the learner can
|
|
1435
|
+
* answer it inside the player, then carries on.
|
|
1436
|
+
*
|
|
1437
|
+
* A formative tool, for lessons and practice — not for official summative
|
|
1438
|
+
* exams, which `<ActivitySequence>` delivers. See `renderMode`.
|
|
1439
|
+
*
|
|
1440
|
+
* The player state is keyed on the CONTENT, not on the object: a host that
|
|
1441
|
+
* rebuilds its activity objects on every render — `redactItemGroup` returns a
|
|
1442
|
+
* new one each call — must not restart the video or lose an answer. Only a
|
|
1443
|
+
* different video, different questions or a moved quiz starts it over.
|
|
1444
|
+
*/
|
|
1445
|
+
function InteractiveVideo$1(props) {
|
|
1446
|
+
const key = (0, react.useMemo)(() => contentKey(props.group), [props.group]);
|
|
1447
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Player, { ...props }, key);
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* The next frame, or a timer where there are no frames: a page rendered in a
|
|
1451
|
+
* document that never paints — a test environment, a hidden iframe — must
|
|
1452
|
+
* still track the playhead and still announce.
|
|
1453
|
+
*/
|
|
1454
|
+
function onNextFrame(run) {
|
|
1455
|
+
if (typeof requestAnimationFrame === "function") {
|
|
1456
|
+
const frame = requestAnimationFrame(run);
|
|
1457
|
+
return () => cancelAnimationFrame(frame);
|
|
1458
|
+
}
|
|
1459
|
+
const timer = setTimeout(run, 33);
|
|
1460
|
+
return () => clearTimeout(timer);
|
|
1461
|
+
}
|
|
1462
|
+
/** What `video-captions-changed` reports: the language of each line on screen, or `null`. */
|
|
1463
|
+
function captionsShown(tracks, preferences) {
|
|
1464
|
+
if (!preferences.captions) return {
|
|
1465
|
+
srclang: null,
|
|
1466
|
+
secondary: null
|
|
1467
|
+
};
|
|
1468
|
+
const shown = resolveCaptionTracks(tracks, preferences);
|
|
1469
|
+
return {
|
|
1470
|
+
srclang: shown.primary?.srclang ?? null,
|
|
1471
|
+
secondary: shown.secondary?.srclang ?? null
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
/** One id per mount, for the quiz capture groups. Module-level: never a render-time `useId`. */
|
|
1475
|
+
let playerCount = 0;
|
|
1476
|
+
function Player(props) {
|
|
1477
|
+
const { renderMode = "practice", onProgress, onSubmit, onActivityComplete, onFinished, onInteraction, recordingBinding, workletUrl, captionsLoader, label, shuffleSeed, locale, theme, sanitizeHtml } = props;
|
|
1478
|
+
const strings = require_LkIntlProvider.useLkStrings(props.strings);
|
|
1479
|
+
const aiInForce = require_ai_LkAiProvider.useLearnerAi(props.ai);
|
|
1480
|
+
const delivery = (0, _intellectif_lk_core.resolveDeliveryPolicy)(props.delivery);
|
|
1481
|
+
const scoringKey = JSON.stringify(props.scoring ?? null);
|
|
1482
|
+
const scoring = (0, react.useMemo)(() => (0, _intellectif_lk_core.resolveItemScoringPolicy)(props.scoring), [scoringKey]);
|
|
1483
|
+
const scoringGiven = props.scoring !== void 0 && props.scoring !== null;
|
|
1484
|
+
const group = (0, react.useRef)(props.group).current;
|
|
1485
|
+
const timeline = group.timeline ?? { cues: [] };
|
|
1486
|
+
const media = group.stimulus.media;
|
|
1487
|
+
const chapters = timeline.chapters ?? [];
|
|
1488
|
+
const devError = (0, react.useMemo)(() => {
|
|
1489
|
+
if (!require_useAiHelp.isDevelopment()) return null;
|
|
1490
|
+
if (group.timeline === void 0) return /* @__PURE__ */ new Error(`InteractiveVideo "${group.id}" was given an item group with no timeline. Give it a timeline — a list of quizzes, each at a moment of the video — or render it with <ActivitySequence>.`);
|
|
1491
|
+
if (group.redacted === true) {
|
|
1492
|
+
try {
|
|
1493
|
+
(0, _intellectif_lk_core.assertRedactedItemGroup)(group);
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
return error;
|
|
1496
|
+
}
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
const result = (0, _intellectif_lk_core.validateItemGroup)(group);
|
|
1500
|
+
return result.success ? null : new _intellectif_lk_core.ActivitySchemaError("item-group", result.errors);
|
|
1501
|
+
}, [group]);
|
|
1502
|
+
const slots = (0, react.useMemo)(() => {
|
|
1503
|
+
try {
|
|
1504
|
+
return (0, _intellectif_lk_core.flattenSequence)([group]);
|
|
1505
|
+
} catch {
|
|
1506
|
+
return [];
|
|
1507
|
+
}
|
|
1508
|
+
}, [group]);
|
|
1509
|
+
const cues = (0, react.useMemo)(() => timeline.cues.filter((cue) => slots.some((slot) => slot.group?.cue?.id === cue.id && (0, _intellectif_lk_core.isInteractiveVideoItemType)(slot.activity.type))), [timeline.cues, slots]);
|
|
1510
|
+
const slotsByCue = (0, react.useMemo)(() => {
|
|
1511
|
+
const byCue = /* @__PURE__ */ new Map();
|
|
1512
|
+
for (const slot of slots) {
|
|
1513
|
+
const cueId = slot.group?.cue?.id;
|
|
1514
|
+
if (cueId !== void 0 && (0, _intellectif_lk_core.isInteractiveVideoItemType)(slot.activity.type)) byCue.set(cueId, [...byCue.get(cueId) ?? [], slot]);
|
|
1515
|
+
}
|
|
1516
|
+
return byCue;
|
|
1517
|
+
}, [slots]);
|
|
1518
|
+
const [playerId] = (0, react.useState)(() => {
|
|
1519
|
+
playerCount += 1;
|
|
1520
|
+
return `lk-iv-${playerCount}`;
|
|
1521
|
+
});
|
|
1522
|
+
const forced = (0, react.useRef)(props.preferences).current;
|
|
1523
|
+
const defaultsKey = JSON.stringify(props.defaultPreferences ?? null);
|
|
1524
|
+
const defaults = (0, react.useMemo)(() => JSON.parse(defaultsKey), [defaultsKey]);
|
|
1525
|
+
const chosen = (0, react.useRef)({});
|
|
1526
|
+
const [preferences, setPreferences] = (0, react.useState)(() => resolvePreferences({
|
|
1527
|
+
force: forced,
|
|
1528
|
+
defaults: props.defaultPreferences
|
|
1529
|
+
}));
|
|
1530
|
+
const preferencesRef = (0, react.useRef)(preferences);
|
|
1531
|
+
preferencesRef.current = preferences;
|
|
1532
|
+
(0, react.useEffect)(() => {
|
|
1533
|
+
setPreferences(resolvePreferences({
|
|
1534
|
+
force: forced,
|
|
1535
|
+
stored: readStoredPreferences(),
|
|
1536
|
+
defaults,
|
|
1537
|
+
chosen: chosen.current
|
|
1538
|
+
}));
|
|
1539
|
+
}, [forced, defaults]);
|
|
1540
|
+
const [submitted] = (0, react.useState)(() => new Set(props.submittedSlotIds ?? []));
|
|
1541
|
+
const [answered, answeredRef, setSlotAnswered] = useKeySet(() => {
|
|
1542
|
+
const seeded = /* @__PURE__ */ new Set();
|
|
1543
|
+
for (const slot of slots) if (submitted.has(slot.slotId) || props.outcomes !== void 0 && Object.hasOwn(props.outcomes, slot.slotId)) seeded.add(slot.slotId);
|
|
1544
|
+
return seeded;
|
|
1545
|
+
});
|
|
1546
|
+
const [scores, setSlotScore] = useKeyMap();
|
|
1547
|
+
const [pending, pendingRef, setPendingKey] = useKeySet(() => /* @__PURE__ */ new Set());
|
|
1548
|
+
const latestTakes = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
1549
|
+
const alive = (0, react.useRef)(true);
|
|
1550
|
+
(0, react.useEffect)(() => {
|
|
1551
|
+
alive.current = true;
|
|
1552
|
+
return () => {
|
|
1553
|
+
alive.current = false;
|
|
1554
|
+
};
|
|
1555
|
+
}, []);
|
|
1556
|
+
const finished = (0, react.useCallback)((cueId) => (slotsByCue.get(cueId) ?? []).every((slot) => answeredRef.current.has(slot.slotId)), [slotsByCue]);
|
|
1557
|
+
const shellRef = (0, react.useRef)(null);
|
|
1558
|
+
const videoRef = (0, react.useRef)(null);
|
|
1559
|
+
const playButtonRef = (0, react.useRef)(null);
|
|
1560
|
+
const quizHeadingRef = (0, react.useRef)(null);
|
|
1561
|
+
const [portalContainer, setPortalContainer] = (0, react.useState)(null);
|
|
1562
|
+
const [playing, setPlaying] = (0, react.useState)(false);
|
|
1563
|
+
const [waiting, setWaiting] = (0, react.useState)(false);
|
|
1564
|
+
const [errorCode, setErrorCode] = (0, react.useState)(null);
|
|
1565
|
+
const [current, setCurrent] = (0, react.useState)(0);
|
|
1566
|
+
const [duration, setDuration] = (0, react.useState)(0);
|
|
1567
|
+
const [buffered, setBuffered] = (0, react.useState)(0);
|
|
1568
|
+
const [scrubbing, setScrubbing] = (0, react.useState)(false);
|
|
1569
|
+
const [ended, setEnded] = (0, react.useState)(false);
|
|
1570
|
+
const [fullscreen, setFullscreen] = (0, react.useState)(false);
|
|
1571
|
+
const [pictureInPicture, setPictureInPicture] = (0, react.useState)(false);
|
|
1572
|
+
const [countdown, setCountdown] = (0, react.useState)(false);
|
|
1573
|
+
const [menu, setMenu] = (0, react.useState)(null);
|
|
1574
|
+
const [sheet, setSheet] = (0, react.useState)(false);
|
|
1575
|
+
const [panelTab, setPanelTab] = (0, react.useState)("contents");
|
|
1576
|
+
const [chromeShown, setChromeShown] = (0, react.useState)(true);
|
|
1577
|
+
const [focusInChrome, setFocusInChrome] = (0, react.useState)(false);
|
|
1578
|
+
const [announcement, setAnnouncement] = (0, react.useState)("");
|
|
1579
|
+
const previousTime = (0, react.useRef)(0);
|
|
1580
|
+
const furthest = (0, react.useRef)(0);
|
|
1581
|
+
const [furthestShown, setFurthestShown] = (0, react.useState)(0);
|
|
1582
|
+
const lastProgressAt = (0, react.useRef)(0);
|
|
1583
|
+
const hideTimer = (0, react.useRef)(null);
|
|
1584
|
+
const lastPointer = (0, react.useRef)("mouse");
|
|
1585
|
+
const finishedOnce = (0, react.useRef)(false);
|
|
1586
|
+
const [, setFinishedState] = (0, react.useState)(false);
|
|
1587
|
+
const [openQuiz, setOpenQuiz] = (0, react.useState)(null);
|
|
1588
|
+
const openQuizRef = (0, react.useRef)(openQuiz);
|
|
1589
|
+
openQuizRef.current = openQuiz;
|
|
1590
|
+
const [mounted, setMounted] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
1591
|
+
const handled = (0, react.useRef)(/* @__PURE__ */ new Set());
|
|
1592
|
+
const announce = (0, react.useCallback)((text) => {
|
|
1593
|
+
setAnnouncement("");
|
|
1594
|
+
onNextFrame(() => setAnnouncement(text));
|
|
1595
|
+
}, []);
|
|
1596
|
+
const emit = (0, react.useCallback)((type, payload) => {
|
|
1597
|
+
onInteraction?.({
|
|
1598
|
+
type,
|
|
1599
|
+
activityId: group.id,
|
|
1600
|
+
timestamp: Date.now(),
|
|
1601
|
+
...payload !== void 0 ? { payload } : {}
|
|
1602
|
+
});
|
|
1603
|
+
}, [onInteraction, group.id]);
|
|
1604
|
+
const reportProgress = (0, react.useCallback)((force) => {
|
|
1605
|
+
const video = videoRef.current;
|
|
1606
|
+
if (onProgress === void 0 || video === null) return;
|
|
1607
|
+
const now = Date.now();
|
|
1608
|
+
if (!force && now - lastProgressAt.current < 5e3) return;
|
|
1609
|
+
lastProgressAt.current = now;
|
|
1610
|
+
onProgress({
|
|
1611
|
+
progressVersion: "1.0",
|
|
1612
|
+
at: video.currentTime,
|
|
1613
|
+
furthest: furthest.current
|
|
1614
|
+
});
|
|
1615
|
+
}, [onProgress]);
|
|
1616
|
+
const quizTitle = (0, react.useCallback)((cue) => cue.title ?? strings.videoQuiz, [strings.videoQuiz]);
|
|
1617
|
+
const openAt = (0, react.useCallback)((cue) => {
|
|
1618
|
+
const video = videoRef.current;
|
|
1619
|
+
const quizSlots = slotsByCue.get(cue.id) ?? [];
|
|
1620
|
+
if (video !== null) {
|
|
1621
|
+
video.pause();
|
|
1622
|
+
if (Math.abs(video.currentTime - cue.at) > .25 && cue.at <= (video.duration || cue.at)) video.currentTime = Math.min(cue.at, video.duration || cue.at);
|
|
1623
|
+
previousTime.current = cue.at;
|
|
1624
|
+
const webkit = video;
|
|
1625
|
+
if (webkit.webkitDisplayingFullscreen) webkit.webkitExitFullscreen?.();
|
|
1626
|
+
if (typeof document !== "undefined" && document.pictureInPictureElement === video) document.exitPictureInPicture?.().catch(() => void 0);
|
|
1627
|
+
}
|
|
1628
|
+
const firstOpen = quizSlots.findIndex((slot) => !answeredRef.current.has(slot.slotId));
|
|
1629
|
+
setMenu(null);
|
|
1630
|
+
setSheet(false);
|
|
1631
|
+
setEnded(false);
|
|
1632
|
+
setMounted((previous) => previous.has(cue.id) ? previous : /* @__PURE__ */ new Set([...previous, cue.id]));
|
|
1633
|
+
setOpenQuiz({
|
|
1634
|
+
cueId: cue.id,
|
|
1635
|
+
step: firstOpen === -1 ? 0 : firstOpen
|
|
1636
|
+
});
|
|
1637
|
+
announce(strings.videoQuizOpened(clock(cue.at), quizSlots.length));
|
|
1638
|
+
emit("video-quiz-opened", {
|
|
1639
|
+
cueId: cue.id,
|
|
1640
|
+
at: cue.at
|
|
1641
|
+
});
|
|
1642
|
+
reportProgress(true);
|
|
1643
|
+
}, [
|
|
1644
|
+
slotsByCue,
|
|
1645
|
+
announce,
|
|
1646
|
+
strings,
|
|
1647
|
+
emit,
|
|
1648
|
+
reportProgress
|
|
1649
|
+
]);
|
|
1650
|
+
(0, react.useEffect)(() => {
|
|
1651
|
+
if (openQuiz !== null) quizHeadingRef.current?.focus();
|
|
1652
|
+
}, [openQuiz?.cueId, openQuiz]);
|
|
1653
|
+
const closeQuiz = (0, react.useCallback)((how) => {
|
|
1654
|
+
const open = openQuizRef.current;
|
|
1655
|
+
if (open === null) return;
|
|
1656
|
+
const cue = cues.find((candidate) => candidate.id === open.cueId);
|
|
1657
|
+
setOpenQuiz(null);
|
|
1658
|
+
const video = videoRef.current;
|
|
1659
|
+
if (how === "rewatch" && cue !== void 0 && video !== null) {
|
|
1660
|
+
const to = [...cues.map((c) => c.at), ...chapters.map((c) => c.at)].filter((at) => at < cue.at - .5).sort((a, b) => b - a)[0] ?? 0;
|
|
1661
|
+
video.currentTime = to;
|
|
1662
|
+
previousTime.current = to;
|
|
1663
|
+
setCurrent(to);
|
|
1664
|
+
} else if (cue !== void 0) {
|
|
1665
|
+
handled.current.add(cue.id);
|
|
1666
|
+
emit(how === "skip" ? "video-quiz-skipped" : "video-quiz-closed", { cueId: cue.id });
|
|
1667
|
+
}
|
|
1668
|
+
playButtonRef.current?.focus();
|
|
1669
|
+
if (video !== null && !(video.ended || duration > 0 && video.currentTime >= duration - .05)) video.play().catch(() => setPlaying(false));
|
|
1670
|
+
else if (video !== null) {
|
|
1671
|
+
const next = quizzesAtEnd(cues, duration, handled.current)[0];
|
|
1672
|
+
if (next !== void 0) openAt(next);
|
|
1673
|
+
else setEnded(true);
|
|
1674
|
+
}
|
|
1675
|
+
}, [
|
|
1676
|
+
cues,
|
|
1677
|
+
chapters,
|
|
1678
|
+
emit,
|
|
1679
|
+
duration,
|
|
1680
|
+
openAt
|
|
1681
|
+
]);
|
|
1682
|
+
const onTick = (0, react.useCallback)((now) => {
|
|
1683
|
+
const before = previousTime.current;
|
|
1684
|
+
previousTime.current = now;
|
|
1685
|
+
if (now > furthest.current) furthest.current = now;
|
|
1686
|
+
if (openQuizRef.current !== null || scrubbing) return;
|
|
1687
|
+
const crossed = crossedQuiz(cues, before, now, handled.current);
|
|
1688
|
+
if (crossed !== void 0) openAt(crossed);
|
|
1689
|
+
}, [
|
|
1690
|
+
cues,
|
|
1691
|
+
scrubbing,
|
|
1692
|
+
openAt
|
|
1693
|
+
]);
|
|
1694
|
+
(0, react.useEffect)(() => {
|
|
1695
|
+
if (!playing || scrubbing) return;
|
|
1696
|
+
let cancel = () => {};
|
|
1697
|
+
const tick = () => {
|
|
1698
|
+
const video = videoRef.current;
|
|
1699
|
+
if (video !== null) {
|
|
1700
|
+
setCurrent(video.currentTime);
|
|
1701
|
+
setBuffered(bufferedAhead(video));
|
|
1702
|
+
onTick(video.currentTime);
|
|
1703
|
+
if (furthest.current > furthestShown + 1) setFurthestShown(furthest.current);
|
|
1704
|
+
reportProgress(false);
|
|
1705
|
+
}
|
|
1706
|
+
cancel = onNextFrame(tick);
|
|
1707
|
+
};
|
|
1708
|
+
cancel = onNextFrame(tick);
|
|
1709
|
+
return () => cancel();
|
|
1710
|
+
}, [
|
|
1711
|
+
playing,
|
|
1712
|
+
scrubbing,
|
|
1713
|
+
onTick,
|
|
1714
|
+
reportProgress,
|
|
1715
|
+
furthestShown
|
|
1716
|
+
]);
|
|
1717
|
+
const seek = (0, react.useCallback)((to) => {
|
|
1718
|
+
const video = videoRef.current;
|
|
1719
|
+
if (video === null || duration <= 0) return;
|
|
1720
|
+
const from = video.currentTime;
|
|
1721
|
+
const target = Math.min(Math.max(0, to), duration);
|
|
1722
|
+
const limited = renderMode === "review" ? { at: target } : limitSeek(timeline, from, target, furthest.current, finished);
|
|
1723
|
+
video.currentTime = limited.at;
|
|
1724
|
+
previousTime.current = limited.at;
|
|
1725
|
+
setCurrent(limited.at);
|
|
1726
|
+
setEnded(false);
|
|
1727
|
+
emit("video-seeked", {
|
|
1728
|
+
from,
|
|
1729
|
+
to: limited.at,
|
|
1730
|
+
clamped: limited.hold !== void 0
|
|
1731
|
+
});
|
|
1732
|
+
if (limited.hold?.kind === "required") {
|
|
1733
|
+
openAt(limited.hold.cue);
|
|
1734
|
+
announce(strings.videoHeldAtQuiz(clock(limited.hold.cue.at)));
|
|
1735
|
+
} else if (limited.hold?.kind === "no-skip-ahead") announce(strings.videoHeldAhead);
|
|
1736
|
+
reportProgress(true);
|
|
1737
|
+
}, [
|
|
1738
|
+
duration,
|
|
1739
|
+
renderMode,
|
|
1740
|
+
timeline,
|
|
1741
|
+
finished,
|
|
1742
|
+
emit,
|
|
1743
|
+
announce,
|
|
1744
|
+
strings,
|
|
1745
|
+
openAt,
|
|
1746
|
+
reportProgress
|
|
1747
|
+
]);
|
|
1748
|
+
const togglePlay = (0, react.useCallback)(() => {
|
|
1749
|
+
const video = videoRef.current;
|
|
1750
|
+
if (video === null) return;
|
|
1751
|
+
if (ended) {
|
|
1752
|
+
setEnded(false);
|
|
1753
|
+
handled.current.clear();
|
|
1754
|
+
video.currentTime = 0;
|
|
1755
|
+
previousTime.current = -1;
|
|
1756
|
+
video.play().catch(() => setPlaying(false));
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (video.paused) {
|
|
1760
|
+
if (video.currentTime === 0 && previousTime.current <= 0) {
|
|
1761
|
+
const atStart = crossedQuiz(cues, -1, 0, handled.current);
|
|
1762
|
+
if (atStart !== void 0) {
|
|
1763
|
+
previousTime.current = 0;
|
|
1764
|
+
openAt(atStart);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
video.play().catch(() => setPlaying(false));
|
|
1769
|
+
} else video.pause();
|
|
1770
|
+
}, [
|
|
1771
|
+
ended,
|
|
1772
|
+
cues,
|
|
1773
|
+
openAt
|
|
1774
|
+
]);
|
|
1775
|
+
const tracks = (0, react.useMemo)(() => {
|
|
1776
|
+
if (media?.tracks !== void 0 && media.tracks.length > 0) return media.tracks.filter((track) => track.kind === "captions" || track.kind === "subtitles");
|
|
1777
|
+
return media?.captionsUrl !== void 0 ? [{
|
|
1778
|
+
kind: "captions",
|
|
1779
|
+
src: media.captionsUrl,
|
|
1780
|
+
srclang: locale ?? "en",
|
|
1781
|
+
label: strings.videoCaptionLanguage
|
|
1782
|
+
}] : [];
|
|
1783
|
+
}, [
|
|
1784
|
+
media,
|
|
1785
|
+
locale,
|
|
1786
|
+
strings.videoCaptionLanguage
|
|
1787
|
+
]);
|
|
1788
|
+
const { primary: primaryTrack, secondary: secondaryTrack } = (0, react.useMemo)(() => resolveCaptionTracks(tracks, {
|
|
1789
|
+
captionLanguage: preferences.captionLanguage,
|
|
1790
|
+
secondaryCaptionLanguage: preferences.secondaryCaptionLanguage
|
|
1791
|
+
}), [
|
|
1792
|
+
tracks,
|
|
1793
|
+
preferences.captionLanguage,
|
|
1794
|
+
preferences.secondaryCaptionLanguage
|
|
1795
|
+
]);
|
|
1796
|
+
const { loadCues, peekCues } = useCueLoader(captionsLoader);
|
|
1797
|
+
const primaryLine = useCaptionLine(primaryTrack, loadCues, peekCues);
|
|
1798
|
+
const secondaryLine = useCaptionLine(secondaryTrack, loadCues, peekCues);
|
|
1799
|
+
const primaryCues = primaryLine.cues;
|
|
1800
|
+
const secondaryCues = secondaryLine.cues;
|
|
1801
|
+
const captionsFailed = {
|
|
1802
|
+
primary: primaryLine.failed,
|
|
1803
|
+
secondary: secondaryLine.failed
|
|
1804
|
+
};
|
|
1805
|
+
const hasCaptions = primaryCues.length > 0 || secondaryCues.length > 0;
|
|
1806
|
+
const hasTranscript = primaryCues.length > 0;
|
|
1807
|
+
const activeCueIndex = (0, react.useMemo)(() => cueIndexAt(primaryCues, current), [primaryCues, current]);
|
|
1808
|
+
const secondaryCueIndex = (0, react.useMemo)(() => cueIndexAt(secondaryCues, current), [secondaryCues, current]);
|
|
1809
|
+
const spoilerLimit = timeline.navigation === "no-skip-ahead" && renderMode !== "review" ? Math.max(furthestShown, current) : Number.POSITIVE_INFINITY;
|
|
1810
|
+
const onPreferencesChangeRef = (0, react.useRef)(props.onPreferencesChange);
|
|
1811
|
+
onPreferencesChangeRef.current = props.onPreferencesChange;
|
|
1812
|
+
const tracksRef = (0, react.useRef)(tracks);
|
|
1813
|
+
tracksRef.current = tracks;
|
|
1814
|
+
/** The last second language the learner had, for Shift + C to bring back. */
|
|
1815
|
+
const lastSecondary = (0, react.useRef)(null);
|
|
1816
|
+
/**
|
|
1817
|
+
* A change the learner made: applied, remembered in this browser, handed to
|
|
1818
|
+
* the host, and — when it changes the captions on screen — reported.
|
|
1819
|
+
*/
|
|
1820
|
+
const changePreferences = (0, react.useCallback)((change) => {
|
|
1821
|
+
const previous = preferencesRef.current;
|
|
1822
|
+
const next = applyPreferenceChange(previous, change);
|
|
1823
|
+
if (previous.secondaryCaptionLanguage !== null) lastSecondary.current = previous.secondaryCaptionLanguage;
|
|
1824
|
+
chosen.current = {
|
|
1825
|
+
...chosen.current,
|
|
1826
|
+
...change
|
|
1827
|
+
};
|
|
1828
|
+
preferencesRef.current = next;
|
|
1829
|
+
setPreferences(next);
|
|
1830
|
+
rememberPreferences(change);
|
|
1831
|
+
onPreferencesChangeRef.current?.(next, change);
|
|
1832
|
+
const before = captionsShown(tracksRef.current, previous);
|
|
1833
|
+
const after = captionsShown(tracksRef.current, next);
|
|
1834
|
+
if (before.srclang !== after.srclang || before.secondary !== after.secondary) emit("video-captions-changed", after);
|
|
1835
|
+
}, [emit]);
|
|
1836
|
+
/**
|
|
1837
|
+
* A first language picked from the menu. Picking the language the second
|
|
1838
|
+
* line shows swaps the two — English + Español becomes Español + English —
|
|
1839
|
+
* rather than silently dropping one.
|
|
1840
|
+
*/
|
|
1841
|
+
const chooseCaptionTrack = (track) => {
|
|
1842
|
+
const swaps = primaryTrack !== void 0 && secondaryTrack !== void 0 && track.srclang.toLowerCase() === secondaryTrack.srclang.toLowerCase();
|
|
1843
|
+
changePreferences({
|
|
1844
|
+
captions: true,
|
|
1845
|
+
captionLanguage: track.srclang,
|
|
1846
|
+
...swaps ? { secondaryCaptionLanguage: primaryTrack.srclang } : {}
|
|
1847
|
+
});
|
|
1848
|
+
};
|
|
1849
|
+
/** A second language picked from the menu, or none. Picking one shows the captions. */
|
|
1850
|
+
const chooseSecondaryTrack = (track) => {
|
|
1851
|
+
changePreferences(track === null ? { secondaryCaptionLanguage: null } : {
|
|
1852
|
+
secondaryCaptionLanguage: track.srclang,
|
|
1853
|
+
captions: true
|
|
1854
|
+
});
|
|
1855
|
+
};
|
|
1856
|
+
/**
|
|
1857
|
+
* Shift + C. With a second line showing, it goes; with one chosen but the
|
|
1858
|
+
* captions off, the pair comes back; otherwise a second line comes on — the
|
|
1859
|
+
* last language the learner had, else the host's suggestion, else the first
|
|
1860
|
+
* other language the video has. Pressing the key is asking for a second
|
|
1861
|
+
* line, so that first language is the learner's choice, not a fallback the
|
|
1862
|
+
* player made for them. With no other language at all, it says so and
|
|
1863
|
+
* changes nothing.
|
|
1864
|
+
*/
|
|
1865
|
+
const toggleSecondary = () => {
|
|
1866
|
+
if (secondaryTrack !== void 0) {
|
|
1867
|
+
changePreferences(preferences.captions ? { secondaryCaptionLanguage: null } : { captions: true });
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const wanted = [
|
|
1871
|
+
lastSecondary.current,
|
|
1872
|
+
preferences.secondaryCaptionLanguage,
|
|
1873
|
+
sanitizePartialPreferences(defaults).secondaryCaptionLanguage ?? null,
|
|
1874
|
+
secondaryCandidates(tracks, primaryTrack)[0]?.srclang ?? null
|
|
1875
|
+
];
|
|
1876
|
+
for (const language of wanted) {
|
|
1877
|
+
if (language === null) continue;
|
|
1878
|
+
const { secondary } = resolveCaptionTracks(tracks, {
|
|
1879
|
+
captionLanguage: preferences.captionLanguage,
|
|
1880
|
+
secondaryCaptionLanguage: language
|
|
1881
|
+
});
|
|
1882
|
+
if (secondary !== void 0) {
|
|
1883
|
+
changePreferences({
|
|
1884
|
+
secondaryCaptionLanguage: secondary.srclang,
|
|
1885
|
+
captions: true
|
|
1886
|
+
});
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
announce(strings.videoNoSecondLanguage);
|
|
1891
|
+
};
|
|
1892
|
+
(0, react.useEffect)(() => {
|
|
1893
|
+
const video = videoRef.current;
|
|
1894
|
+
if (video !== null) {
|
|
1895
|
+
video.playbackRate = preferences.speed;
|
|
1896
|
+
video.volume = preferences.volume;
|
|
1897
|
+
video.muted = preferences.muted;
|
|
1898
|
+
}
|
|
1899
|
+
}, [
|
|
1900
|
+
preferences.speed,
|
|
1901
|
+
preferences.volume,
|
|
1902
|
+
preferences.muted
|
|
1903
|
+
]);
|
|
1904
|
+
(0, react.useEffect)(() => {
|
|
1905
|
+
const onChange = () => setFullscreen(document.fullscreenElement === shellRef.current);
|
|
1906
|
+
document.addEventListener("fullscreenchange", onChange);
|
|
1907
|
+
return () => document.removeEventListener("fullscreenchange", onChange);
|
|
1908
|
+
}, []);
|
|
1909
|
+
(0, react.useEffect)(() => {
|
|
1910
|
+
const video = videoRef.current;
|
|
1911
|
+
if (video === null) return;
|
|
1912
|
+
const enter = () => {
|
|
1913
|
+
setPictureInPicture(true);
|
|
1914
|
+
emit("video-pip-changed", { on: true });
|
|
1915
|
+
};
|
|
1916
|
+
const leave = () => {
|
|
1917
|
+
setPictureInPicture(false);
|
|
1918
|
+
emit("video-pip-changed", { on: false });
|
|
1919
|
+
};
|
|
1920
|
+
video.addEventListener("enterpictureinpicture", enter);
|
|
1921
|
+
video.addEventListener("leavepictureinpicture", leave);
|
|
1922
|
+
return () => {
|
|
1923
|
+
video.removeEventListener("enterpictureinpicture", enter);
|
|
1924
|
+
video.removeEventListener("leavepictureinpicture", leave);
|
|
1925
|
+
};
|
|
1926
|
+
}, [emit]);
|
|
1927
|
+
(0, react.useEffect)(() => () => {
|
|
1928
|
+
if (hideTimer.current !== null) clearTimeout(hideTimer.current);
|
|
1929
|
+
}, []);
|
|
1930
|
+
const revealChrome = (0, react.useCallback)(() => {
|
|
1931
|
+
setChromeShown(true);
|
|
1932
|
+
if (hideTimer.current !== null) clearTimeout(hideTimer.current);
|
|
1933
|
+
hideTimer.current = setTimeout(() => setChromeShown(false), 2400);
|
|
1934
|
+
}, []);
|
|
1935
|
+
const toggleFullscreen = (0, react.useCallback)(() => {
|
|
1936
|
+
const shell = shellRef.current;
|
|
1937
|
+
const video = videoRef.current;
|
|
1938
|
+
if (typeof document === "undefined" || shell === null) return;
|
|
1939
|
+
if (document.fullscreenElement) document.exitFullscreen().catch(() => void 0);
|
|
1940
|
+
else if (typeof shell.requestFullscreen === "function" && document.fullscreenEnabled !== false) shell.requestFullscreen().catch(() => void 0);
|
|
1941
|
+
else video?.webkitEnterFullscreen?.();
|
|
1942
|
+
emit("video-fullscreen-changed");
|
|
1943
|
+
}, [emit]);
|
|
1944
|
+
const pictureInPictureSupported = typeof document !== "undefined" && document.pictureInPictureEnabled === true;
|
|
1945
|
+
const togglePictureInPicture = (0, react.useCallback)(() => {
|
|
1946
|
+
const video = videoRef.current;
|
|
1947
|
+
if (video === null) return;
|
|
1948
|
+
if (document.pictureInPictureElement) document.exitPictureInPicture().catch(() => void 0);
|
|
1949
|
+
else video.requestPictureInPicture?.().catch(() => void 0);
|
|
1950
|
+
}, []);
|
|
1951
|
+
const jumpPoints = (0, react.useMemo)(() => [.../* @__PURE__ */ new Set([...chapters.map((c) => c.at), ...cues.map((c) => c.at)])].sort((a, b) => a - b), [chapters, cues]);
|
|
1952
|
+
const onKeyDown = (event) => {
|
|
1953
|
+
if (openQuiz !== null || !preferences.shortcuts) return;
|
|
1954
|
+
const target = event.target;
|
|
1955
|
+
if (target.isContentEditable || [
|
|
1956
|
+
"INPUT",
|
|
1957
|
+
"TEXTAREA",
|
|
1958
|
+
"SELECT"
|
|
1959
|
+
].includes(target.tagName) || target.tagName === "BUTTON" && (event.key === " " || event.key === "Enter")) return;
|
|
1960
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
1961
|
+
const video = videoRef.current;
|
|
1962
|
+
const key = event.key;
|
|
1963
|
+
const done = () => {
|
|
1964
|
+
event.preventDefault();
|
|
1965
|
+
revealChrome();
|
|
1966
|
+
};
|
|
1967
|
+
if (/^[0-9]$/.test(key)) {
|
|
1968
|
+
done();
|
|
1969
|
+
seek(Number(key) / 10 * duration);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
switch (key) {
|
|
1973
|
+
case " ":
|
|
1974
|
+
case "k":
|
|
1975
|
+
case "K":
|
|
1976
|
+
done();
|
|
1977
|
+
togglePlay();
|
|
1978
|
+
return;
|
|
1979
|
+
case "j":
|
|
1980
|
+
case "J":
|
|
1981
|
+
done();
|
|
1982
|
+
seek(current - 10);
|
|
1983
|
+
return;
|
|
1984
|
+
case "l":
|
|
1985
|
+
case "L":
|
|
1986
|
+
done();
|
|
1987
|
+
seek(current + 10);
|
|
1988
|
+
return;
|
|
1989
|
+
case "ArrowLeft":
|
|
1990
|
+
done();
|
|
1991
|
+
seek(current - (event.shiftKey ? 1 : 5));
|
|
1992
|
+
return;
|
|
1993
|
+
case "ArrowRight":
|
|
1994
|
+
done();
|
|
1995
|
+
seek(current + (event.shiftKey ? 1 : 5));
|
|
1996
|
+
return;
|
|
1997
|
+
case "Home":
|
|
1998
|
+
done();
|
|
1999
|
+
seek(0);
|
|
2000
|
+
return;
|
|
2001
|
+
case "End":
|
|
2002
|
+
done();
|
|
2003
|
+
seek(duration);
|
|
2004
|
+
return;
|
|
2005
|
+
case ",":
|
|
2006
|
+
case ".":
|
|
2007
|
+
done();
|
|
2008
|
+
video?.pause();
|
|
2009
|
+
seek(current + (key === "," ? -.03333333333333333 : FRAME_SECONDS));
|
|
2010
|
+
return;
|
|
2011
|
+
case "<":
|
|
2012
|
+
case ">":
|
|
2013
|
+
done();
|
|
2014
|
+
changePreferences({ speed: nudgeSpeed(preferences.speed, key === "<" ? -1 : 1) });
|
|
2015
|
+
return;
|
|
2016
|
+
case "[": {
|
|
2017
|
+
done();
|
|
2018
|
+
const previous = [...jumpPoints].reverse().find((at) => at < current - 1);
|
|
2019
|
+
seek(previous ?? 0);
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
case "]": {
|
|
2023
|
+
done();
|
|
2024
|
+
const next = jumpPoints.find((at) => at > current + .5);
|
|
2025
|
+
if (next !== void 0) seek(next);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
case "m":
|
|
2029
|
+
case "M":
|
|
2030
|
+
done();
|
|
2031
|
+
changePreferences({ muted: !preferences.muted });
|
|
2032
|
+
return;
|
|
2033
|
+
case "c":
|
|
2034
|
+
case "C":
|
|
2035
|
+
if (event.shiftKey) {
|
|
2036
|
+
done();
|
|
2037
|
+
toggleSecondary();
|
|
2038
|
+
} else if (hasCaptions) {
|
|
2039
|
+
done();
|
|
2040
|
+
changePreferences({ captions: !preferences.captions });
|
|
2041
|
+
}
|
|
2042
|
+
return;
|
|
2043
|
+
case "t":
|
|
2044
|
+
case "T":
|
|
2045
|
+
if (hasTranscript) {
|
|
2046
|
+
done();
|
|
2047
|
+
setPanelTab("transcript");
|
|
2048
|
+
changePreferences({ panel: !(preferences.panel && panelTab === "transcript") });
|
|
2049
|
+
}
|
|
2050
|
+
return;
|
|
2051
|
+
case "p":
|
|
2052
|
+
case "P":
|
|
2053
|
+
if (pictureInPictureSupported) {
|
|
2054
|
+
done();
|
|
2055
|
+
togglePictureInPicture();
|
|
2056
|
+
}
|
|
2057
|
+
return;
|
|
2058
|
+
case "f":
|
|
2059
|
+
case "F":
|
|
2060
|
+
done();
|
|
2061
|
+
toggleFullscreen();
|
|
2062
|
+
return;
|
|
2063
|
+
case "?":
|
|
2064
|
+
done();
|
|
2065
|
+
setSheet((open) => !open);
|
|
2066
|
+
return;
|
|
2067
|
+
default: return;
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
const quizzes = cues.map((cue) => {
|
|
2071
|
+
const quizSlots = slotsByCue.get(cue.id) ?? [];
|
|
2072
|
+
const count = quizSlots.filter((slot) => answered.has(slot.slotId)).length;
|
|
2073
|
+
return {
|
|
2074
|
+
id: cue.id,
|
|
2075
|
+
at: cue.at,
|
|
2076
|
+
title: quizTitle(cue),
|
|
2077
|
+
answered: count,
|
|
2078
|
+
total: quizSlots.length,
|
|
2079
|
+
required: cue.required === true && renderMode !== "review",
|
|
2080
|
+
state: markerState(quizSlots, answered, renderMode, props.outcomes)
|
|
2081
|
+
};
|
|
2082
|
+
});
|
|
2083
|
+
const markers = quizzes.map((quiz) => ({
|
|
2084
|
+
id: quiz.id,
|
|
2085
|
+
at: quiz.at,
|
|
2086
|
+
label: `${quiz.title} · ${strings.videoQuizProgress(quiz.answered, quiz.total)}`,
|
|
2087
|
+
state: quiz.state,
|
|
2088
|
+
required: quiz.required
|
|
2089
|
+
}));
|
|
2090
|
+
const totalQuestions = quizzes.reduce((sum, quiz) => sum + quiz.total, 0);
|
|
2091
|
+
const answeredCount = quizzes.reduce((sum, quiz) => sum + quiz.answered, 0);
|
|
2092
|
+
const graded = [...scores.values()];
|
|
2093
|
+
const percent = graded.length === 0 ? null : Math.round(graded.reduce((a, b) => a + b, 0) / graded.length * 100);
|
|
2094
|
+
const summary = (0, react.useCallback)(() => {
|
|
2095
|
+
return { slots: slots.filter((slot) => slot.group?.cue !== void 0).map((slot) => {
|
|
2096
|
+
const cueId = slot.group?.cue?.id;
|
|
2097
|
+
return {
|
|
2098
|
+
slotId: slot.slotId,
|
|
2099
|
+
activityId: slot.activity.id,
|
|
2100
|
+
cueId,
|
|
2101
|
+
status: answeredRef.current.has(slot.slotId) ? "answered" : handled.current.has(cueId) ? "skipped" : "unreached"
|
|
2102
|
+
};
|
|
2103
|
+
}) };
|
|
2104
|
+
}, [slots]);
|
|
2105
|
+
const finish = (0, react.useCallback)(() => {
|
|
2106
|
+
if (finishedOnce.current) return;
|
|
2107
|
+
finishedOnce.current = true;
|
|
2108
|
+
setFinishedState(true);
|
|
2109
|
+
onFinished?.(summary());
|
|
2110
|
+
}, [onFinished, summary]);
|
|
2111
|
+
const answerPending = pending.size > 0;
|
|
2112
|
+
(0, react.useEffect)(() => {
|
|
2113
|
+
if (ended && !answerPending && totalQuestions > 0 && answeredCount === totalQuestions && renderMode !== "review") finish();
|
|
2114
|
+
}, [
|
|
2115
|
+
ended,
|
|
2116
|
+
answerPending,
|
|
2117
|
+
totalQuestions,
|
|
2118
|
+
answeredCount,
|
|
2119
|
+
renderMode,
|
|
2120
|
+
finish
|
|
2121
|
+
]);
|
|
2122
|
+
const slotBindings = (0, react.useMemo)(() => {
|
|
2123
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
2124
|
+
if (recordingBinding === void 0) return bindings;
|
|
2125
|
+
const { assess, playbackUrl } = recordingBinding;
|
|
2126
|
+
for (const slot of slots) {
|
|
2127
|
+
const place = {
|
|
2128
|
+
slotId: slot.slotId,
|
|
2129
|
+
index: slot.index,
|
|
2130
|
+
activityId: slot.activity.id
|
|
2131
|
+
};
|
|
2132
|
+
bindings.set(slot.slotId, {
|
|
2133
|
+
upload: (take) => recordingBinding.upload(take, place),
|
|
2134
|
+
...assess !== void 0 ? { assess: (ref) => assess.call(recordingBinding, ref, place) } : {},
|
|
2135
|
+
...playbackUrl !== void 0 ? { playbackUrl: (ref) => playbackUrl.call(recordingBinding, ref, place) } : {}
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
return bindings;
|
|
2139
|
+
}, [slots, recordingBinding]);
|
|
2140
|
+
const places = (0, react.useMemo)(() => {
|
|
2141
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2142
|
+
for (const [cueId, cueSlots] of slotsByCue) for (const slot of cueSlots) byId.set(slot.slotId, {
|
|
2143
|
+
slotId: slot.slotId,
|
|
2144
|
+
index: slot.index,
|
|
2145
|
+
activityId: slot.activity.id,
|
|
2146
|
+
cueId
|
|
2147
|
+
});
|
|
2148
|
+
return byId;
|
|
2149
|
+
}, [slotsByCue]);
|
|
2150
|
+
const reportTo = (0, react.useRef)({
|
|
2151
|
+
onSubmit,
|
|
2152
|
+
onActivityComplete,
|
|
2153
|
+
onInteraction,
|
|
2154
|
+
renderMode,
|
|
2155
|
+
delivery,
|
|
2156
|
+
scoring: scoringGiven ? scoring : void 0
|
|
2157
|
+
});
|
|
2158
|
+
reportTo.current = {
|
|
2159
|
+
onSubmit,
|
|
2160
|
+
onActivityComplete,
|
|
2161
|
+
onInteraction,
|
|
2162
|
+
renderMode,
|
|
2163
|
+
delivery,
|
|
2164
|
+
scoring: scoringGiven ? scoring : void 0
|
|
2165
|
+
};
|
|
2166
|
+
/**
|
|
2167
|
+
* The calls a question reports what the learner did through — the SDK's own
|
|
2168
|
+
* components and a host's alike, so nothing downstream can tell them apart.
|
|
2169
|
+
* Made once per question.
|
|
2170
|
+
*/
|
|
2171
|
+
const calls = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
2172
|
+
const callsFor = (place) => {
|
|
2173
|
+
const made = calls.current.get(place.slotId);
|
|
2174
|
+
if (made !== void 0) return made;
|
|
2175
|
+
const { slotId } = place;
|
|
2176
|
+
const warned = /* @__PURE__ */ new Set();
|
|
2177
|
+
const refused = (call) => {
|
|
2178
|
+
if (reportTo.current.renderMode !== "review") return false;
|
|
2179
|
+
if (require_useAiHelp.isDevelopment() && !warned.has(call)) {
|
|
2180
|
+
warned.add(call);
|
|
2181
|
+
console.warn(`InteractiveVideo: \`${call}()\` was called for question "${slotId}" in renderMode "review", where nothing is submittable. It was ignored.`);
|
|
2182
|
+
}
|
|
2183
|
+
return true;
|
|
2184
|
+
};
|
|
2185
|
+
const next = {
|
|
2186
|
+
submit: (response) => {
|
|
2187
|
+
if (!alive.current || refused("submit")) return;
|
|
2188
|
+
setSlotAnswered(slotId, true);
|
|
2189
|
+
reportTo.current.onSubmit?.(response, place);
|
|
2190
|
+
},
|
|
2191
|
+
complete: (result) => {
|
|
2192
|
+
if (!alive.current || refused("complete")) return;
|
|
2193
|
+
setSlotAnswered(slotId, true);
|
|
2194
|
+
setSlotScore(slotId, result.maxScore > 0 ? result.score / result.maxScore : void 0);
|
|
2195
|
+
reportTo.current.onActivityComplete?.(result, place);
|
|
2196
|
+
},
|
|
2197
|
+
clear: () => {
|
|
2198
|
+
if (!alive.current || refused("clear")) return;
|
|
2199
|
+
setSlotAnswered(slotId, false);
|
|
2200
|
+
setSlotScore(slotId, void 0);
|
|
2201
|
+
},
|
|
2202
|
+
setPending: (on) => {
|
|
2203
|
+
if (!alive.current) return;
|
|
2204
|
+
setPendingKey(`host:${slotId}`, on === true);
|
|
2205
|
+
},
|
|
2206
|
+
emit: (type, payload) => {
|
|
2207
|
+
if (!alive.current) return;
|
|
2208
|
+
reportTo.current.onInteraction?.({
|
|
2209
|
+
type,
|
|
2210
|
+
activityId: place.activityId,
|
|
2211
|
+
timestamp: Date.now(),
|
|
2212
|
+
...payload !== void 0 ? { payload } : {}
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
calls.current.set(slotId, next);
|
|
2217
|
+
return next;
|
|
2218
|
+
};
|
|
2219
|
+
/**
|
|
2220
|
+
* What an SDK read-aloud reports about each take, through its slot's channel.
|
|
2221
|
+
* A take being stored or judged is on its way; every other state is not. A
|
|
2222
|
+
* failure the learner may retry is not on its way either: nothing comes of
|
|
2223
|
+
* it unless they press Try again, which reports the take in flight again.
|
|
2224
|
+
* (A pager holds its set for one while the learner is on that question; the
|
|
2225
|
+
* video's Finish is on the end card, where no question is.)
|
|
2226
|
+
*/
|
|
2227
|
+
const reportTake = (slotId, take, state) => {
|
|
2228
|
+
if (!alive.current) return;
|
|
2229
|
+
const latest = latestTakes.current.get(slotId);
|
|
2230
|
+
if (latest !== void 0 && take < latest) return;
|
|
2231
|
+
latestTakes.current.set(slotId, take);
|
|
2232
|
+
setPendingKey(`take:${slotId}`, state === "in-flight");
|
|
2233
|
+
};
|
|
2234
|
+
const channels = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
2235
|
+
const channelFor = (slotId) => {
|
|
2236
|
+
let channel = channels.current.get(slotId);
|
|
2237
|
+
if (channel === void 0) {
|
|
2238
|
+
channel = {
|
|
2239
|
+
captureGroup: `${playerId}::${slotId}`,
|
|
2240
|
+
get renderMode() {
|
|
2241
|
+
return reportTo.current.renderMode;
|
|
2242
|
+
},
|
|
2243
|
+
get delivery() {
|
|
2244
|
+
return reportTo.current.delivery;
|
|
2245
|
+
},
|
|
2246
|
+
get scoring() {
|
|
2247
|
+
return reportTo.current.scoring;
|
|
2248
|
+
},
|
|
2249
|
+
get triesClosed() {
|
|
2250
|
+
return finishedOnce.current;
|
|
2251
|
+
},
|
|
2252
|
+
triesState: () => {},
|
|
2253
|
+
takeState: (take, state) => reportTake(slotId, take, state)
|
|
2254
|
+
};
|
|
2255
|
+
channels.current.set(slotId, channel);
|
|
2256
|
+
}
|
|
2257
|
+
return channel;
|
|
2258
|
+
};
|
|
2259
|
+
/** What the host restored for a question: read the way the SDK's components read it. */
|
|
2260
|
+
const seedsFor = (slotId) => ({
|
|
2261
|
+
restored: props.responses !== void 0 && Object.hasOwn(props.responses, slotId) ? props.responses[slotId] : void 0,
|
|
2262
|
+
outcome: props.outcomes !== void 0 && Object.hasOwn(props.outcomes, slotId) ? props.outcomes[slotId] : void 0
|
|
2263
|
+
});
|
|
2264
|
+
/** The question as `renderQuestion` is handed it. */
|
|
2265
|
+
const questionFor = (slot, place, active) => {
|
|
2266
|
+
const { restored, outcome } = seedsFor(slot.slotId);
|
|
2267
|
+
return {
|
|
2268
|
+
activity: slot.activity,
|
|
2269
|
+
slot: place,
|
|
2270
|
+
renderMode,
|
|
2271
|
+
active,
|
|
2272
|
+
...locale !== void 0 ? { locale } : {},
|
|
2273
|
+
...restored !== void 0 ? { defaultValue: restored } : {},
|
|
2274
|
+
defaultSubmitted: submitted.has(slot.slotId),
|
|
2275
|
+
...outcome !== void 0 ? { outcome } : {},
|
|
2276
|
+
portalContainer,
|
|
2277
|
+
...aiInForce !== void 0 && renderMode !== "exam" ? { ai: aiInForce } : {},
|
|
2278
|
+
delivery,
|
|
2279
|
+
scoring,
|
|
2280
|
+
...callsFor(place)
|
|
2281
|
+
};
|
|
2282
|
+
};
|
|
2283
|
+
/** The SDK's own component for a question. */
|
|
2284
|
+
const sdkQuestion = (slot, place) => {
|
|
2285
|
+
const activity = slot.activity;
|
|
2286
|
+
const { restored, outcome } = seedsFor(slot.slotId);
|
|
2287
|
+
const reported = callsFor(place);
|
|
2288
|
+
const common = {
|
|
2289
|
+
renderMode,
|
|
2290
|
+
...restored !== void 0 ? { defaultValue: restored } : {},
|
|
2291
|
+
...submitted.has(slot.slotId) ? { defaultSubmitted: true } : {},
|
|
2292
|
+
...outcome !== void 0 ? { outcome } : {},
|
|
2293
|
+
onSubmit: reported.submit,
|
|
2294
|
+
onComplete: reported.complete,
|
|
2295
|
+
...onInteraction !== void 0 ? { onInteraction } : {},
|
|
2296
|
+
...sanitizeHtml !== void 0 ? { sanitizeHtml } : {},
|
|
2297
|
+
...props.strings !== void 0 ? { strings: props.strings } : {},
|
|
2298
|
+
...props.ai !== void 0 ? { ai: props.ai } : {},
|
|
2299
|
+
...props.delivery !== void 0 && props.delivery !== null ? { delivery: props.delivery } : {},
|
|
2300
|
+
...scoringGiven ? { scoring: props.scoring } : {},
|
|
2301
|
+
...locale !== void 0 ? { locale } : {}
|
|
2302
|
+
};
|
|
2303
|
+
switch (activity.type) {
|
|
2304
|
+
case "multiple-choice": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_MultipleChoice.MultipleChoice, {
|
|
2305
|
+
data: activity,
|
|
2306
|
+
...common,
|
|
2307
|
+
...shuffleSeed !== void 0 ? { shuffleSeed } : {}
|
|
2308
|
+
});
|
|
2309
|
+
case "fill-in-the-blanks": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_FillInTheBlanks.FillInTheBlanks, {
|
|
2310
|
+
data: activity,
|
|
2311
|
+
...common
|
|
2312
|
+
});
|
|
2313
|
+
case "gap-select": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_GapSelect.GapSelect, {
|
|
2314
|
+
data: activity,
|
|
2315
|
+
...common,
|
|
2316
|
+
...shuffleSeed !== void 0 ? { shuffleSeed } : {}
|
|
2317
|
+
});
|
|
2318
|
+
case "dictation": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_Dictation.Dictation, {
|
|
2319
|
+
data: activity,
|
|
2320
|
+
...common
|
|
2321
|
+
});
|
|
2322
|
+
case "read-aloud": {
|
|
2323
|
+
const binding = slotBindings.get(slot.slotId);
|
|
2324
|
+
const assessment = props.assessments !== void 0 && Object.hasOwn(props.assessments, slot.slotId) ? props.assessments[slot.slotId] : void 0;
|
|
2325
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ReadAloud.ReadAloud, {
|
|
2326
|
+
data: activity,
|
|
2327
|
+
...common,
|
|
2328
|
+
...binding !== void 0 ? { recordingBinding: binding } : {},
|
|
2329
|
+
...assessment !== void 0 ? { assessment } : {},
|
|
2330
|
+
...workletUrl !== void 0 ? { workletUrl } : {}
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
case "written-response": return null;
|
|
2334
|
+
default: return null;
|
|
2335
|
+
}
|
|
2336
|
+
};
|
|
2337
|
+
if (devError !== null) throw devError;
|
|
2338
|
+
const openCue = openQuiz === null ? void 0 : cues.find((cue) => cue.id === openQuiz.cueId);
|
|
2339
|
+
const openSlots = openCue === void 0 ? [] : slotsByCue.get(openCue.id) ?? [];
|
|
2340
|
+
const step = openQuiz?.step ?? 0;
|
|
2341
|
+
const stepSlot = openSlots[step];
|
|
2342
|
+
const stepAnswered = stepSlot !== void 0 && answered.has(stepSlot.slotId);
|
|
2343
|
+
const lastStep = step >= openSlots.length - 1;
|
|
2344
|
+
const required = openCue?.required === true && renderMode !== "review";
|
|
2345
|
+
const canAdvance = !required || stepAnswered;
|
|
2346
|
+
const goToStep = (next) => {
|
|
2347
|
+
setOpenQuiz((open) => open === null ? open : {
|
|
2348
|
+
...open,
|
|
2349
|
+
step: next
|
|
2350
|
+
});
|
|
2351
|
+
const slot = openSlots[next];
|
|
2352
|
+
if (slot !== void 0 && openCue !== void 0) {
|
|
2353
|
+
announce(strings.videoQuestionProgress(next + 1, openSlots.length));
|
|
2354
|
+
emit("video-quiz-question-shown", {
|
|
2355
|
+
cueId: openCue.id,
|
|
2356
|
+
slotId: slot.slotId
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
const captionsHidden = !preferences.captions || openQuiz !== null || ended || errorCode !== null;
|
|
2361
|
+
const primaryText = captionsHidden ? void 0 : primaryCues[activeCueIndex]?.text;
|
|
2362
|
+
const secondaryText = captionsHidden ? void 0 : secondaryCues[secondaryCueIndex]?.text;
|
|
2363
|
+
const chromeVisible = !playing || chromeShown || menu !== null || scrubbing || focusInChrome || sheet || openQuiz !== null;
|
|
2364
|
+
const volumeLevel = preferences.muted || preferences.volume === 0 ? "muted" : preferences.volume < .5 ? "low" : "high";
|
|
2365
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2366
|
+
ref: shellRef,
|
|
2367
|
+
className: "lk-iv",
|
|
2368
|
+
tabIndex: -1,
|
|
2369
|
+
lang: locale,
|
|
2370
|
+
style: theme,
|
|
2371
|
+
"data-render-mode": renderMode,
|
|
2372
|
+
"data-quiz-open": openQuiz !== null || void 0,
|
|
2373
|
+
"data-fullscreen": fullscreen || void 0,
|
|
2374
|
+
"data-chrome": chromeVisible ? "shown" : "hidden",
|
|
2375
|
+
onKeyDown,
|
|
2376
|
+
children: [
|
|
2377
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2378
|
+
className: "lk-iv-stage",
|
|
2379
|
+
onPointerDown: (event) => {
|
|
2380
|
+
lastPointer.current = event.pointerType;
|
|
2381
|
+
},
|
|
2382
|
+
onPointerMove: (event) => {
|
|
2383
|
+
if (event.pointerType !== "touch") revealChrome();
|
|
2384
|
+
},
|
|
2385
|
+
onPointerLeave: () => {
|
|
2386
|
+
if (playing) setChromeShown(false);
|
|
2387
|
+
},
|
|
2388
|
+
children: [
|
|
2389
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("video", {
|
|
2390
|
+
ref: videoRef,
|
|
2391
|
+
className: "lk-iv-video",
|
|
2392
|
+
src: media?.url,
|
|
2393
|
+
...media?.poster !== void 0 ? { poster: media.poster } : {},
|
|
2394
|
+
preload: "metadata",
|
|
2395
|
+
playsInline: true,
|
|
2396
|
+
"aria-label": group.title ?? media?.alt,
|
|
2397
|
+
onClick: () => {
|
|
2398
|
+
if (lastPointer.current === "touch" && !chromeVisible) {
|
|
2399
|
+
revealChrome();
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
togglePlay();
|
|
2403
|
+
},
|
|
2404
|
+
onDoubleClick: toggleFullscreen,
|
|
2405
|
+
onPlay: () => {
|
|
2406
|
+
setPlaying(true);
|
|
2407
|
+
setEnded(false);
|
|
2408
|
+
revealChrome();
|
|
2409
|
+
emit("video-played", { at: videoRef.current?.currentTime ?? 0 });
|
|
2410
|
+
},
|
|
2411
|
+
onPause: () => {
|
|
2412
|
+
setPlaying(false);
|
|
2413
|
+
emit("video-paused", { at: videoRef.current?.currentTime ?? 0 });
|
|
2414
|
+
reportProgress(true);
|
|
2415
|
+
},
|
|
2416
|
+
onWaiting: () => setWaiting(true),
|
|
2417
|
+
onPlaying: () => setWaiting(false),
|
|
2418
|
+
onProgress: (event) => setBuffered(bufferedAhead(event.currentTarget)),
|
|
2419
|
+
onTimeUpdate: (event) => {
|
|
2420
|
+
if (!playing && !scrubbing) setCurrent(event.currentTarget.currentTime);
|
|
2421
|
+
},
|
|
2422
|
+
onRateChange: (event) => emit("video-rate-changed", { rate: event.currentTarget.playbackRate }),
|
|
2423
|
+
onLoadedMetadata: (event) => {
|
|
2424
|
+
const video = event.currentTarget;
|
|
2425
|
+
const length = Number.isFinite(video.duration) ? video.duration : 0;
|
|
2426
|
+
setDuration(length);
|
|
2427
|
+
video.playbackRate = preferences.speed;
|
|
2428
|
+
video.volume = preferences.volume;
|
|
2429
|
+
video.muted = preferences.muted;
|
|
2430
|
+
const stored = (0, _intellectif_lk_core.readMediaProgress)(props.progress, length);
|
|
2431
|
+
if (stored !== null) {
|
|
2432
|
+
const at = renderMode === "review" ? stored.at : limitResume(timeline, stored.at, finished);
|
|
2433
|
+
furthest.current = Math.max(stored.furthest, at);
|
|
2434
|
+
setFurthestShown(furthest.current);
|
|
2435
|
+
video.currentTime = at;
|
|
2436
|
+
previousTime.current = at;
|
|
2437
|
+
setCurrent(at);
|
|
2438
|
+
}
|
|
2439
|
+
},
|
|
2440
|
+
onEnded: () => {
|
|
2441
|
+
setPlaying(false);
|
|
2442
|
+
setCurrent(duration);
|
|
2443
|
+
emit("video-ended");
|
|
2444
|
+
reportProgress(true);
|
|
2445
|
+
const first = quizzesAtEnd(cues, duration, handled.current)[0];
|
|
2446
|
+
if (first !== void 0) openAt(first);
|
|
2447
|
+
else setEnded(true);
|
|
2448
|
+
},
|
|
2449
|
+
onError: (event) => {
|
|
2450
|
+
setWaiting(false);
|
|
2451
|
+
setErrorCode(event.currentTarget.error?.code ?? 0);
|
|
2452
|
+
}
|
|
2453
|
+
}),
|
|
2454
|
+
label !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2455
|
+
className: "lk-iv-label",
|
|
2456
|
+
children: label
|
|
2457
|
+
}) : null,
|
|
2458
|
+
primaryText !== void 0 || secondaryText !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2459
|
+
className: "lk-iv-captions",
|
|
2460
|
+
"data-size": preferences.captionSize,
|
|
2461
|
+
"data-background": preferences.captionBackground || void 0,
|
|
2462
|
+
children: [primaryText !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2463
|
+
className: "lk-iv-caption",
|
|
2464
|
+
"data-role": "primary",
|
|
2465
|
+
"data-size": preferences.captionSize,
|
|
2466
|
+
"data-background": preferences.captionBackground || void 0,
|
|
2467
|
+
lang: primaryTrack?.srclang,
|
|
2468
|
+
dir: "auto",
|
|
2469
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: primaryText })
|
|
2470
|
+
}) : null, secondaryText !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2471
|
+
className: "lk-iv-caption",
|
|
2472
|
+
"data-role": "secondary",
|
|
2473
|
+
"data-size": preferences.captionSize,
|
|
2474
|
+
"data-background": preferences.captionBackground || void 0,
|
|
2475
|
+
lang: secondaryTrack?.srclang,
|
|
2476
|
+
dir: "auto",
|
|
2477
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: secondaryText })
|
|
2478
|
+
}) : null]
|
|
2479
|
+
}) : null,
|
|
2480
|
+
waiting && errorCode === null && openQuiz === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2481
|
+
className: "lk-iv-spinner",
|
|
2482
|
+
"aria-hidden": "true"
|
|
2483
|
+
}) : null,
|
|
2484
|
+
errorCode !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2485
|
+
className: "lk-iv-error",
|
|
2486
|
+
role: "alert",
|
|
2487
|
+
children: [
|
|
2488
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AlertIcon, {}),
|
|
2489
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: strings.videoError(errorCode) }),
|
|
2490
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2491
|
+
type: "button",
|
|
2492
|
+
className: "lk-iv-action",
|
|
2493
|
+
onClick: () => {
|
|
2494
|
+
setErrorCode(null);
|
|
2495
|
+
videoRef.current?.load();
|
|
2496
|
+
},
|
|
2497
|
+
children: strings.videoTryAgain
|
|
2498
|
+
})
|
|
2499
|
+
]
|
|
2500
|
+
}) : null,
|
|
2501
|
+
!playing && !waiting && errorCode === null && openQuiz === null && !ended ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2502
|
+
type: "button",
|
|
2503
|
+
className: "lk-iv-big-play",
|
|
2504
|
+
"aria-label": strings.media.play,
|
|
2505
|
+
onClick: togglePlay,
|
|
2506
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayIcon, {})
|
|
2507
|
+
}) : null,
|
|
2508
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2509
|
+
className: "lk-iv-chrome",
|
|
2510
|
+
inert: openQuiz !== null,
|
|
2511
|
+
onFocus: () => setFocusInChrome(true),
|
|
2512
|
+
onBlur: (event) => {
|
|
2513
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setFocusInChrome(false);
|
|
2514
|
+
},
|
|
2515
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Scrubber, {
|
|
2516
|
+
current,
|
|
2517
|
+
duration,
|
|
2518
|
+
buffered,
|
|
2519
|
+
chapters,
|
|
2520
|
+
markers,
|
|
2521
|
+
cues: primaryCues,
|
|
2522
|
+
previewLimit: spoilerLimit,
|
|
2523
|
+
strings,
|
|
2524
|
+
onSeek: seek,
|
|
2525
|
+
onScrubChange: setScrubbing
|
|
2526
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2527
|
+
className: "lk-iv-bar",
|
|
2528
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2529
|
+
className: "lk-iv-bar-group",
|
|
2530
|
+
children: [
|
|
2531
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2532
|
+
ref: playButtonRef,
|
|
2533
|
+
type: "button",
|
|
2534
|
+
className: "lk-iv-button",
|
|
2535
|
+
"aria-label": ended ? strings.videoReplay : playing ? strings.media.pause : strings.media.play,
|
|
2536
|
+
onClick: togglePlay,
|
|
2537
|
+
children: ended ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReplayIcon, {}) : playing ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PauseIcon, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayIcon, {})
|
|
2538
|
+
}),
|
|
2539
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2540
|
+
type: "button",
|
|
2541
|
+
className: "lk-iv-button lk-iv-wide",
|
|
2542
|
+
"aria-label": strings.videoBack(10),
|
|
2543
|
+
onClick: () => seek(current - 10),
|
|
2544
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BackIcon, {})
|
|
2545
|
+
}),
|
|
2546
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2547
|
+
type: "button",
|
|
2548
|
+
className: "lk-iv-button lk-iv-wide",
|
|
2549
|
+
"aria-label": strings.videoForward(10),
|
|
2550
|
+
onClick: () => seek(current + 10),
|
|
2551
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ForwardIcon, {})
|
|
2552
|
+
}),
|
|
2553
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2554
|
+
className: "lk-iv-volume",
|
|
2555
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2556
|
+
type: "button",
|
|
2557
|
+
className: "lk-iv-button",
|
|
2558
|
+
"aria-label": preferences.muted ? strings.media.unmute : strings.media.mute,
|
|
2559
|
+
"aria-pressed": preferences.muted,
|
|
2560
|
+
onClick: () => changePreferences({ muted: !preferences.muted }),
|
|
2561
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VolumeIcon, { level: volumeLevel })
|
|
2562
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2563
|
+
id: `${playerId}-volume`,
|
|
2564
|
+
className: "lk-iv-volume-slider",
|
|
2565
|
+
type: "range",
|
|
2566
|
+
min: 0,
|
|
2567
|
+
max: 1,
|
|
2568
|
+
step: .05,
|
|
2569
|
+
value: preferences.muted ? 0 : preferences.volume,
|
|
2570
|
+
"aria-label": strings.media.volume,
|
|
2571
|
+
onChange: (event) => changePreferences({
|
|
2572
|
+
volume: Number(event.target.value),
|
|
2573
|
+
muted: false
|
|
2574
|
+
})
|
|
2575
|
+
})]
|
|
2576
|
+
}),
|
|
2577
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2578
|
+
type: "button",
|
|
2579
|
+
className: "lk-iv-time",
|
|
2580
|
+
"aria-label": countdown ? strings.videoShowElapsed : strings.videoShowRemaining,
|
|
2581
|
+
onClick: () => setCountdown((on) => !on),
|
|
2582
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2583
|
+
"aria-hidden": "true",
|
|
2584
|
+
children: [countdown ? `-${clock(Math.max(0, duration - current))}` : clock(current), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2585
|
+
className: "lk-iv-time-total",
|
|
2586
|
+
children: [" / ", clock(duration)]
|
|
2587
|
+
})]
|
|
2588
|
+
})
|
|
2589
|
+
})
|
|
2590
|
+
]
|
|
2591
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2592
|
+
className: "lk-iv-bar-group",
|
|
2593
|
+
children: [
|
|
2594
|
+
quizzes.length > 0 || chapters.length > 0 || hasTranscript ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2595
|
+
type: "button",
|
|
2596
|
+
className: "lk-iv-button",
|
|
2597
|
+
"aria-label": strings.videoPanel,
|
|
2598
|
+
"aria-pressed": preferences.panel,
|
|
2599
|
+
onClick: () => changePreferences({ panel: !preferences.panel }),
|
|
2600
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContentsIcon, {})
|
|
2601
|
+
}) : null,
|
|
2602
|
+
hasCaptions ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2603
|
+
type: "button",
|
|
2604
|
+
className: "lk-iv-button",
|
|
2605
|
+
"aria-label": preferences.captions ? strings.videoCaptionsHide : strings.videoCaptionsShow,
|
|
2606
|
+
"aria-pressed": preferences.captions,
|
|
2607
|
+
onClick: () => changePreferences({ captions: !preferences.captions }),
|
|
2608
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CaptionsIcon, { on: preferences.captions })
|
|
2609
|
+
}) : null,
|
|
2610
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2611
|
+
className: "lk-iv-menu-anchor",
|
|
2612
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2613
|
+
type: "button",
|
|
2614
|
+
className: "lk-iv-button lk-iv-speed",
|
|
2615
|
+
"aria-label": strings.media.speed,
|
|
2616
|
+
"aria-haspopup": "menu",
|
|
2617
|
+
"aria-expanded": menu === "speed",
|
|
2618
|
+
onClick: () => setMenu((open) => open === "speed" ? null : "speed"),
|
|
2619
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2620
|
+
"aria-hidden": "true",
|
|
2621
|
+
children: `${preferences.speed}×`
|
|
2622
|
+
})
|
|
2623
|
+
}), menu === "speed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpeedMenu, {
|
|
2624
|
+
speed: preferences.speed,
|
|
2625
|
+
strings,
|
|
2626
|
+
onChoose: (speed) => {
|
|
2627
|
+
changePreferences({ speed });
|
|
2628
|
+
setMenu(null);
|
|
2629
|
+
},
|
|
2630
|
+
onClose: () => setMenu(null)
|
|
2631
|
+
}) : null]
|
|
2632
|
+
}),
|
|
2633
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2634
|
+
className: "lk-iv-menu-anchor",
|
|
2635
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2636
|
+
type: "button",
|
|
2637
|
+
className: "lk-iv-button",
|
|
2638
|
+
"aria-label": strings.videoSettings,
|
|
2639
|
+
"aria-haspopup": "menu",
|
|
2640
|
+
"aria-expanded": menu === "settings",
|
|
2641
|
+
onClick: () => setMenu((open) => open === "settings" ? null : "settings"),
|
|
2642
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsIcon, {})
|
|
2643
|
+
}), menu === "settings" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsMenu, {
|
|
2644
|
+
preferences,
|
|
2645
|
+
tracks,
|
|
2646
|
+
primaryTrack,
|
|
2647
|
+
secondaryTrack,
|
|
2648
|
+
captionsFailed,
|
|
2649
|
+
onCaptionTrack: chooseCaptionTrack,
|
|
2650
|
+
onSecondaryTrack: chooseSecondaryTrack,
|
|
2651
|
+
strings,
|
|
2652
|
+
onChange: changePreferences,
|
|
2653
|
+
onShowShortcuts: () => {
|
|
2654
|
+
setMenu(null);
|
|
2655
|
+
setSheet(true);
|
|
2656
|
+
},
|
|
2657
|
+
onClose: () => setMenu(null)
|
|
2658
|
+
}) : null]
|
|
2659
|
+
}),
|
|
2660
|
+
pictureInPictureSupported ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2661
|
+
type: "button",
|
|
2662
|
+
className: "lk-iv-button lk-iv-wide",
|
|
2663
|
+
"aria-label": strings.videoPictureInPicture,
|
|
2664
|
+
"aria-pressed": pictureInPicture,
|
|
2665
|
+
onClick: togglePictureInPicture,
|
|
2666
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PictureInPictureIcon, {})
|
|
2667
|
+
}) : null,
|
|
2668
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2669
|
+
type: "button",
|
|
2670
|
+
className: "lk-iv-button",
|
|
2671
|
+
"aria-label": fullscreen ? strings.videoExitFullscreen : strings.videoFullscreen,
|
|
2672
|
+
onClick: toggleFullscreen,
|
|
2673
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FullscreenIcon, { on: fullscreen })
|
|
2674
|
+
})
|
|
2675
|
+
]
|
|
2676
|
+
})]
|
|
2677
|
+
})]
|
|
2678
|
+
}),
|
|
2679
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2680
|
+
className: "lk-iv-quiz",
|
|
2681
|
+
role: "dialog",
|
|
2682
|
+
"aria-modal": "false",
|
|
2683
|
+
"aria-labelledby": `${playerId}-quiz-title`,
|
|
2684
|
+
hidden: openQuiz === null,
|
|
2685
|
+
onKeyDown: (event) => {
|
|
2686
|
+
if (event.key === "Escape" && !event.defaultPrevented && !required && openQuiz !== null) {
|
|
2687
|
+
event.preventDefault();
|
|
2688
|
+
closeQuiz("skip");
|
|
2689
|
+
}
|
|
2690
|
+
},
|
|
2691
|
+
children: [
|
|
2692
|
+
openCue !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2693
|
+
className: "lk-iv-quiz-head",
|
|
2694
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
|
|
2695
|
+
id: `${playerId}-quiz-title`,
|
|
2696
|
+
ref: quizHeadingRef,
|
|
2697
|
+
tabIndex: -1,
|
|
2698
|
+
className: "lk-iv-quiz-title",
|
|
2699
|
+
children: [
|
|
2700
|
+
quizTitle(openCue),
|
|
2701
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2702
|
+
className: "lk-iv-quiz-time",
|
|
2703
|
+
children: [" · ", clock(openCue.at)]
|
|
2704
|
+
}),
|
|
2705
|
+
required ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2706
|
+
className: "lk-iv-tag",
|
|
2707
|
+
children: strings.videoRequired
|
|
2708
|
+
})] }) : null
|
|
2709
|
+
]
|
|
2710
|
+
}), openSlots.length > 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2711
|
+
className: "lk-iv-steps",
|
|
2712
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2713
|
+
className: "lk-iv-steps-label",
|
|
2714
|
+
children: strings.videoQuestionProgress(step + 1, openSlots.length)
|
|
2715
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
|
|
2716
|
+
className: "lk-iv-steps-dots",
|
|
2717
|
+
children: openSlots.map((slot, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2718
|
+
type: "button",
|
|
2719
|
+
className: "lk-iv-step",
|
|
2720
|
+
"data-current": index === step || void 0,
|
|
2721
|
+
"data-answered": answered.has(slot.slotId) || void 0,
|
|
2722
|
+
"aria-current": index === step ? "step" : void 0,
|
|
2723
|
+
"aria-label": strings.videoQuestionStep(index + 1, answered.has(slot.slotId)),
|
|
2724
|
+
onClick: () => goToStep(index)
|
|
2725
|
+
}) }, slot.slotId))
|
|
2726
|
+
})]
|
|
2727
|
+
}) : null]
|
|
2728
|
+
}) : null,
|
|
2729
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2730
|
+
className: "lk-iv-quiz-body",
|
|
2731
|
+
children: cues.filter((cue) => mounted.has(cue.id)).map((cue) => (slotsByCue.get(cue.id) ?? []).map((slot, index) => {
|
|
2732
|
+
const shown = openQuiz?.cueId === cue.id && step === index;
|
|
2733
|
+
const place = places.get(slot.slotId);
|
|
2734
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuestionPane, {
|
|
2735
|
+
hidden: !shown,
|
|
2736
|
+
channel: channelFor(slot.slotId),
|
|
2737
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SlotQuestion, {
|
|
2738
|
+
title: slot.activity.title,
|
|
2739
|
+
strings,
|
|
2740
|
+
render: props.renderQuestion,
|
|
2741
|
+
question: props.renderQuestion === void 0 ? void 0 : questionFor(slot, place, shown),
|
|
2742
|
+
sdk: () => sdkQuestion(slot, place),
|
|
2743
|
+
setHostPending: callsFor(place).setPending
|
|
2744
|
+
})
|
|
2745
|
+
}, slot.slotId);
|
|
2746
|
+
}))
|
|
2747
|
+
}),
|
|
2748
|
+
openCue !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2749
|
+
className: "lk-iv-quiz-foot",
|
|
2750
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2751
|
+
className: "lk-iv-quiz-foot-start",
|
|
2752
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2753
|
+
type: "button",
|
|
2754
|
+
className: "lk-iv-link",
|
|
2755
|
+
onClick: () => closeQuiz("rewatch"),
|
|
2756
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReplayIcon, {}), strings.videoRewatch]
|
|
2757
|
+
}), !required && !lastStep ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2758
|
+
type: "button",
|
|
2759
|
+
className: "lk-iv-link",
|
|
2760
|
+
onClick: () => closeQuiz("skip"),
|
|
2761
|
+
children: strings.videoSkipQuiz
|
|
2762
|
+
}) : null]
|
|
2763
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2764
|
+
className: "lk-iv-quiz-foot-end",
|
|
2765
|
+
children: [renderMode === "exam" && stepAnswered ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2766
|
+
className: "lk-iv-saved",
|
|
2767
|
+
role: "status",
|
|
2768
|
+
children: strings.videoAnswerSaved
|
|
2769
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2770
|
+
type: "button",
|
|
2771
|
+
className: stepAnswered || renderMode === "review" ? "lk-iv-action lk-iv-action-primary" : "lk-iv-action",
|
|
2772
|
+
"aria-disabled": !canAdvance || void 0,
|
|
2773
|
+
onClick: () => {
|
|
2774
|
+
if (!canAdvance) return;
|
|
2775
|
+
if (lastStep) closeQuiz("continue");
|
|
2776
|
+
else goToStep(step + 1);
|
|
2777
|
+
},
|
|
2778
|
+
children: [lastStep ? strings.videoContinue : strings.videoNextQuestion, lastStep ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayIcon, {}) : null]
|
|
2779
|
+
})]
|
|
2780
|
+
})]
|
|
2781
|
+
}) : null
|
|
2782
|
+
]
|
|
2783
|
+
}),
|
|
2784
|
+
ended && openQuiz === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EndScreen, {
|
|
2785
|
+
idPrefix: playerId,
|
|
2786
|
+
renderMode,
|
|
2787
|
+
quizzes,
|
|
2788
|
+
answered: answeredCount,
|
|
2789
|
+
total: totalQuestions,
|
|
2790
|
+
percent: delivery.feedback ? percent : null,
|
|
2791
|
+
strings,
|
|
2792
|
+
onOpenQuiz: (id) => {
|
|
2793
|
+
const cue = cues.find((candidate) => candidate.id === id);
|
|
2794
|
+
if (cue !== void 0) openAt(cue);
|
|
2795
|
+
},
|
|
2796
|
+
onWatchAgain: togglePlay,
|
|
2797
|
+
pending: answerPending,
|
|
2798
|
+
onFinish: renderMode === "review" ? void 0 : () => {
|
|
2799
|
+
if (pendingRef.current.size > 0) {
|
|
2800
|
+
announce(strings.videoAnswerPending);
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
finish();
|
|
2804
|
+
}
|
|
2805
|
+
}) : null,
|
|
2806
|
+
sheet ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ShortcutSheet, {
|
|
2807
|
+
strings,
|
|
2808
|
+
onClose: () => {
|
|
2809
|
+
setSheet(false);
|
|
2810
|
+
playButtonRef.current?.focus();
|
|
2811
|
+
}
|
|
2812
|
+
}) : null
|
|
2813
|
+
]
|
|
2814
|
+
}),
|
|
2815
|
+
preferences.panel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContentsPanel, {
|
|
2816
|
+
idPrefix: playerId,
|
|
2817
|
+
tab: panelTab,
|
|
2818
|
+
onTab: setPanelTab,
|
|
2819
|
+
chapters,
|
|
2820
|
+
quizzes,
|
|
2821
|
+
cues: primaryCues,
|
|
2822
|
+
primaryLanguage: primaryTrack?.srclang,
|
|
2823
|
+
secondaryCues: secondaryTrack === void 0 ? NO_CUES : secondaryCues,
|
|
2824
|
+
secondaryLanguage: secondaryTrack?.srclang,
|
|
2825
|
+
activeCue: activeCueIndex,
|
|
2826
|
+
limit: spoilerLimit,
|
|
2827
|
+
strings,
|
|
2828
|
+
onSeek: seek,
|
|
2829
|
+
onOpenQuiz: (id) => {
|
|
2830
|
+
const cue = cues.find((candidate) => candidate.id === id);
|
|
2831
|
+
if (cue === void 0) return;
|
|
2832
|
+
if (renderMode !== "review" && limitSeek(timeline, videoRef.current?.currentTime ?? 0, cue.at, furthest.current, finished).at < cue.at - .25) {
|
|
2833
|
+
seek(cue.at);
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
openAt(cue);
|
|
2837
|
+
}
|
|
2838
|
+
}) : null,
|
|
2839
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2840
|
+
className: "lk-iv-live",
|
|
2841
|
+
"aria-live": "polite",
|
|
2842
|
+
"aria-atomic": "true",
|
|
2843
|
+
children: announcement
|
|
2844
|
+
}),
|
|
2845
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2846
|
+
ref: setPortalContainer,
|
|
2847
|
+
className: "lk-iv-portal"
|
|
2848
|
+
})
|
|
2849
|
+
]
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
/**
|
|
2853
|
+
* One question's content: the host's, when `renderQuestion` returns anything
|
|
2854
|
+
* but `undefined`, else the SDK's own component. Inside the boundary either
|
|
2855
|
+
* way, and `renderQuestion` is called inside it too, so a host that throws —
|
|
2856
|
+
* while rendering, or while deciding what to render — loses that one question
|
|
2857
|
+
* and never the video.
|
|
2858
|
+
*/
|
|
2859
|
+
function SlotQuestion({ title, strings, ...content }) {
|
|
2860
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ActivityErrorBoundary.ActivityErrorBoundary, {
|
|
2861
|
+
...title !== void 0 ? { activityTitle: title } : {},
|
|
2862
|
+
strings,
|
|
2863
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SlotContent, { ...content })
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
function SlotContent({ render, question, sdk, setHostPending }) {
|
|
2867
|
+
const hosted = render !== void 0 && question !== void 0 ? render(question) : void 0;
|
|
2868
|
+
const hosting = hosted !== void 0;
|
|
2869
|
+
(0, react.useEffect)(() => {
|
|
2870
|
+
if (!hosting) return;
|
|
2871
|
+
return () => setHostPending(false);
|
|
2872
|
+
}, [hosting, setHostPending]);
|
|
2873
|
+
return hosting ? hosted : sdk();
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* One question of a quiz. Every question stays mounted once its quiz has
|
|
2877
|
+
* opened, so an answer survives stepping back or rewinding the video; hiding
|
|
2878
|
+
* it — another question, or the quiz closing — stops whatever it was
|
|
2879
|
+
* recording or playing, as a sequence's pane does.
|
|
2880
|
+
*/
|
|
2881
|
+
function QuestionPane({ hidden, channel, children }) {
|
|
2882
|
+
const ref = (0, react.useRef)(null);
|
|
2883
|
+
const captureGroup = channel.captureGroup;
|
|
2884
|
+
(0, react.useEffect)(() => {
|
|
2885
|
+
if (!hidden) return;
|
|
2886
|
+
require_ReadAloud.stopCaptureGroup(captureGroup);
|
|
2887
|
+
for (const media of ref.current?.querySelectorAll("audio, video") ?? []) {
|
|
2888
|
+
const element = media;
|
|
2889
|
+
if (!element.paused) element.pause();
|
|
2890
|
+
}
|
|
2891
|
+
}, [hidden, captureGroup]);
|
|
2892
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2893
|
+
ref,
|
|
2894
|
+
className: "lk-iv-question",
|
|
2895
|
+
hidden,
|
|
2896
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_useAiHelp.SequenceSlotContext.Provider, {
|
|
2897
|
+
value: channel,
|
|
2898
|
+
children
|
|
2899
|
+
})
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
/** How far the browser has downloaded past the playhead, in seconds from 0. */
|
|
2903
|
+
function bufferedAhead(video) {
|
|
2904
|
+
const ranges = video.buffered;
|
|
2905
|
+
if (ranges === void 0) return 0;
|
|
2906
|
+
for (let index = 0; index < ranges.length; index += 1) if (ranges.start(index) <= video.currentTime && video.currentTime <= ranges.end(index)) return ranges.end(index);
|
|
2907
|
+
return 0;
|
|
2908
|
+
}
|
|
2909
|
+
/** A quiz's marker state: its shape on the progress bar and its dot in the lists. */
|
|
2910
|
+
function markerState(quizSlots, answered, renderMode, outcomes) {
|
|
2911
|
+
if (renderMode === "review" && outcomes !== void 0) {
|
|
2912
|
+
const results = quizSlots.map((slot) => Object.hasOwn(outcomes, slot.slotId) ? outcomes[slot.slotId] : void 0);
|
|
2913
|
+
if (results.some((outcome) => outcome === void 0 || outcome.status === "deferred")) return "pending";
|
|
2914
|
+
const scored = results.filter((outcome) => outcome?.status === "scored");
|
|
2915
|
+
if (scored.length === results.length && scored.every((outcome) => outcome.passed)) return "correct";
|
|
2916
|
+
return scored.some((outcome) => outcome.passed) ? "partial" : "incorrect";
|
|
2917
|
+
}
|
|
2918
|
+
const count = quizSlots.filter((slot) => answered.has(slot.slotId)).length;
|
|
2919
|
+
if (count === quizSlots.length && count > 0) return "answered";
|
|
2920
|
+
if (count > 0) return "open";
|
|
2921
|
+
return "unreached";
|
|
2922
|
+
}
|
|
2923
|
+
//#endregion
|
|
2924
|
+
//#region src/components/InteractiveVideo/index.tsx
|
|
2925
|
+
/**
|
|
2926
|
+
* Public interactive video: the player wrapped in `ActivityErrorBoundary`, so a
|
|
2927
|
+
* render failure — the development-time schema check among them — degrades to
|
|
2928
|
+
* an accessible fallback instead of taking the host page down.
|
|
2929
|
+
*
|
|
2930
|
+
* A formative tool, for lessons and practice — not for official summative
|
|
2931
|
+
* exams, which `<ActivitySequence>` delivers.
|
|
2932
|
+
*/
|
|
2933
|
+
function InteractiveVideo(props) {
|
|
2934
|
+
const strings = require_LkIntlProvider.useLkStrings(props.strings);
|
|
2935
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ActivityErrorBoundary.ActivityErrorBoundary, {
|
|
2936
|
+
...props.group?.title !== void 0 ? { activityTitle: props.group.title } : {},
|
|
2937
|
+
strings,
|
|
2938
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InteractiveVideo$1, { ...props })
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
//#endregion
|
|
2942
|
+
Object.defineProperty(exports, "InteractiveVideo", {
|
|
2943
|
+
enumerable: true,
|
|
2944
|
+
get: function() {
|
|
2945
|
+
return InteractiveVideo;
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
Object.defineProperty(exports, "resolveCaptionTracks", {
|
|
2949
|
+
enumerable: true,
|
|
2950
|
+
get: function() {
|
|
2951
|
+
return resolveCaptionTracks;
|
|
2952
|
+
}
|
|
2953
|
+
});
|
|
2954
|
+
|
|
2955
|
+
//# sourceMappingURL=InteractiveVideo-BguGv-HA.cjs.map
|