diff --git a/src/screens/Watch/Components/Transcriptions/CaptionLine/index.js b/src/screens/Watch/Components/Transcriptions/CaptionLine/index.js index 79f70ccb..3a7d99cc 100644 --- a/src/screens/Watch/Components/Transcriptions/CaptionLine/index.js +++ b/src/screens/Watch/Components/Transcriptions/CaptionLine/index.js @@ -1,7 +1,10 @@ import React, { useRef, useState, useEffect } from 'react'; import { isMobile } from 'react-device-detect'; import * as KeyCode from 'keycode-js'; - +import { + WEBVTT_SUBTITLES, + WEBVTT_DESCRIPTIONS, +} from '../../../Utils/constants.util'; import { prettierTimeStr } from '../../../Utils'; import './index.scss'; @@ -12,7 +15,6 @@ function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) { const endTimeRef = useRef(); const textRef = useRef(); - const [fullBeginTime, setFullBeginTime] = useState(begin); const [fullEndTime, setFullEndTime] = useState(end); const [savedText, setSavedText] = useState(text); @@ -33,15 +35,13 @@ function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) { const validateText = (input) => { const MAX_LINE_LENGTH = 42; let lines = []; - let violations = []; + let violationArr = []; - const splitText = (text) => { + const splitText = (textInput) => { let currentLine = ''; - let words = text.split(' '); + let words = textInput.split(' '); let currentLineLength = 0; - - console.log(`Processing text: "${text}"`); - + words.forEach((word) => { if (currentLineLength + word.length + (currentLineLength > 0 ? 1 : 0) > MAX_LINE_LENGTH) { lines.push(currentLine.trim()); @@ -62,36 +62,35 @@ function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) { } }; splitText(input); - - console.log(`Lines after split: ${JSON.stringify(lines)}`); - + lines.forEach((line, index) => { if (line.length > MAX_LINE_LENGTH) { - violations.push(`Line ${index + 1} exceeds the max character length.`); + violationArr.push(`Line ${index + 1} exceeds the max character length.`); } }); if (input.length <= MAX_LINE_LENGTH && lines.length > 1) { - violations.push("Text is incorrectly flagged as multi-line for a short subtitle."); + violationArr.push("Text is incorrectly flagged as multi-line for a short subtitle."); } if (lines.length > 2) { - violations.push('Text exceeds two lines.'); + violationArr.push('Text exceeds two lines.'); } - - console.log(`Violations: ${JSON.stringify(violations)}`); - - return { lines, violations }; + + return { lines, violationArr }; }; - // The control flow is that a change to time is saved in handleTimeKeyDown, - // which triggers handleSave, which then changes the displayedTime to the correct truncated time const handleSave = () => { - const { lines, violations: textViolations } = validateText(savedText); + const { lines, violationArr = [] } = validateText(savedText); + const parseTime = (timeStr) => { + const [hours, minutes, seconds] = timeStr.split(':').map(Number); + const [sec, ms] = seconds.toString().split('.'); + return hours * 3600 + minutes * 60 + (Number(sec) || 0) + (ms ? parseFloat(`0.${ms}`) : 0); + }; - const beginTime = parseFloat(fullBeginTime.replace(/:/g, '')); - const endTime = parseFloat(fullEndTime.replace(/:/g, '')); + const beginTime = parseTime(fullBeginTime); + const endTime = parseTime(fullEndTime); const duration = endTime - beginTime; const durationViolations = []; @@ -100,8 +99,8 @@ function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) { } else if (duration > 6) { durationViolations.push('Caption duration is too long (more than 6 seconds).'); } - - const allViolations = [...textViolations, ...durationViolations]; + + const allViolations = [...violationArr, ...durationViolations]; dispatch({ type: 'watch/saveCaption', @@ -109,8 +108,10 @@ function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) { }); setDisplayedStartTime(prettierTimeStr(fullBeginTime, false)); setDisplayedEndTime(prettierTimeStr(fullEndTime, false)); - setViolations(allViolations); - setIsTextInvalid(allViolations.length > 0); + if(kind === WEBVTT_SUBTITLES) { + setViolations(allViolations); + setIsTextInvalid(allViolations.length > 0); + } }; @@ -118,22 +119,14 @@ function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) { handleSave() }, [savedText, fullBeginTime, fullEndTime]) - // NOTE: ALL editable text boxes reset the value to the original if the textbox loses focus - // Users MUST hit enter for their changes to not be lost const handleTimeBlur = (setDisplayedTime, originalValue) => { setDisplayedTime(prettierTimeStr(originalValue, false)); }; - // Ideally, you could do something like setSavedText(savedText), akin to how handleTextKeyDown - // lazy updates savedText, but this won't trigger a DOM update, so we have to do manually - // update the DOM const handleTextBlur = () => { if (textRef.current) { - // textRef.current.innerText = savedText - const { lines, violations: newViolations } = validateText(savedText); + const { lines, violationArr = [] } = validateText(savedText); textRef.current.innerText = lines.join('\n'); - setViolations(newViolations); - setIsTextInvalid(newViolations.length > 0); } }; @@ -195,8 +188,15 @@ function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) { className={`watch-caption-line ${isTextInvalid ? 'invalid-text' : ''}`} kind={kind} data-unsaved + onFocus={() => setIsTextInvalid(violations.length > 0)} + onBlur={() => setIsTextInvalid(false)} >
+ {/* Triangle indicator */} + {(violations.length > 0 && kind === WEBVTT_SUBTITLES) && ( +
+ )} + {/* Editable Start Time */}
{displayedStartTime}
- + {/* Editable Text */}
{savedText}
-
- {violations.join(', ')} + +
0 ? 'show-tooltip' : ''}`}> + {violations.length > 0 && {violations.join(', ')}}
+ {/* Editable End Time */}
- - {/* Action Buttons */} - {/*
- {true && ( -
Return (save changes). Shift-Return (newline)
- )} - - -
*/}
- ); + ); } export default CaptionLine; diff --git a/src/screens/Watch/Components/Transcriptions/CaptionLine/index.scss b/src/screens/Watch/Components/Transcriptions/CaptionLine/index.scss index 90205a8c..76848c2a 100644 --- a/src/screens/Watch/Components/Transcriptions/CaptionLine/index.scss +++ b/src/screens/Watch/Components/Transcriptions/CaptionLine/index.scss @@ -1,405 +1,425 @@ -/* Time */ -/* Text */ -/* .watch-caption-line[data-unsaved=true] .caption-line-text { - color: rgb(255, 166, 0); -} */ -/* Save Button */ -/* .watch-caption-line[data-unsaved=true] .caption-line-btns .caption-line-prompt { - color: rgb(255, 166, 0); -} */ -/* Description */ -.watch-caption-line { - position: relative; - display: flex; - flex-direction: column; - justify-content: center; - min-height: 4em; - padding: .7em; - transition: var(--ct-transition-all); - -o-transition: var(--ct-transition-all); - -moz-transition: var(--ct-transition-all); - -webkit-transition: var(--ct-transition-all); - border-radius: 5px; - z-index: 10; -} -.watch-caption-line[editing=true] { - background: rgb(46, 46, 46); - .caption-line-text { - color: var(--ct-text-white) !important; - outline: none; - font-weight: bold; - } - >.caption-line-btns { - height: 40px; - } -} -.watch-caption-line[hide=true] { - display: none; -} -.caption-line-content { - position: relative; - width: 100%; - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: flex-start; -} -.caption-line-time-display { - margin-top: 10px; - margin-right: .5em; - span { - display: flex; - justify-content: center; - align-items: center; - padding: .1em .3em; - color: rgb(209, 209, 209); - border: 1px solid #75757577; - border-radius: 2px; - font-size: 14px; - font-weight: bold; - white-space: nowrap; - } - &:hover { - >span { - color: black; - background: var(--ct-text-highlight); - border-color: var(--ct-text-highlight); - } - } -} -.watch-caption-line[current=true] { - .caption-line-time-display { - span { - color: black; - background: var(--ct-text-highlight); - border-color: var(--ct-text-highlight); - } - } - .caption-line-text { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - outline: none; - color: var(--ct-text-highlight); - } - } - .caption-line-text-normal { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - outline: none; - color: var(--ct-text-highlight); - } - } - .caption-line-text-large { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - outline: none; - color: var(--ct-text-highlight); - } - } - - .caption-line-text-small { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - outline: none; - color: var(--ct-text-highlight); - } - } -} -.caption-line-text { - font-weight: bold; - font-size: 1.2em; - line-height: 18px; - min-height: 36px; - color: rgba(215, 215, 215, 0.747); - white-space: pre-line; - cursor: pointer; - line-break: normal; - width: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - background: none; - border: none; - box-shadow: none; - text-align: center; - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: center; - resize: none; - padding-top: 10px; - &:focus { - cursor: text; - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - } - &:active { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - &:hover { - outline: none; - color: var(--ct-text-highlight); - } -} - -.caption-line-text-normal { - font-weight: bold; - font-size: 1.2em; - line-height: 18px; - min-height: 36px; - color: rgba(215, 215, 215, 0.747); - white-space: pre-line; - cursor: pointer; - line-break: normal; - width: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - background: none; - border: none; - box-shadow: none; - text-align: center; - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: center; - resize: none; - padding-top: 10px; - &:focus { - cursor: text; - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - } - &:active { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - &:hover { - outline: none; - color: var(--ct-text-highlight); - } -} - -.caption-line-text-large { - font-weight: bold; - font-size: 1.6em; - line-height: 18px; - min-height: 36px; - color: rgba(215, 215, 215, 0.747); - white-space: pre-line; - cursor: pointer; - line-break: normal; - width: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - background: none; - border: none; - box-shadow: none; - text-align: center; - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: center; - resize: none; - padding-top: 10px; - &:focus { - cursor: text; - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - } - &:active { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - &:hover { - outline: none; - color: var(--ct-text-highlight); - }} - -.caption-line-text-small { - font-weight: bold; - font-size: .8em; - line-height: 18px; - min-height: 36px; - color: rgba(215, 215, 215, 0.747); - white-space: pre-line; - cursor: pointer; - line-break: normal; - width: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - background: none; - border: none; - box-shadow: none; - text-align: center; - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: center; - resize: none; - padding-top: 10px; - &:focus { - cursor: text; - color: var(--ct-text-white); - outline: none; - font-weight: bold; - &:hover { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - } - &:active { - color: var(--ct-text-white); - outline: none; - font-weight: bold; - } - &:hover { - outline: none; - color: var(--ct-text-highlight); - }} - -.caption-line-btns { - position: relative; - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-end; - overflow: hidden; - height: 0; - font-family: var(--ct-font-google); - transition: var(--ct-transition-all); - -o-transition: var(--ct-transition-all); - -moz-transition: var(--ct-transition-all); - -webkit-transition: var(--ct-transition-all); - .caption-line-prompt { - color: rgb(168, 168, 168); - } -} -.caption-line-save-btn { - margin-top: .5em; - color: white; - background: rgb(97, 97, 97); - padding: .5em 1em; - border-radius: 20px; - font-weight: bold; - margin-right: .5em; - transition: var(--ct-transition-all); - -o-transition: var(--ct-transition-all); - -moz-transition: var(--ct-transition-all); - -webkit-transition: var(--ct-transition-all); - &:hover { - outline: none; - background: rgb(88, 88, 88); - } - &:focus { - outline: none; - background: rgb(88, 88, 88); - } -} -.watch-caption-line[kind=descriptions] { - border: 1px solid grey; - margin-top: 10px; - margin-bottom: 10px; - .caption-line-time-display { - &:hover { - >span { - background: rgb(255, 213, 28); - border-color: rgb(255, 213, 28); - } - } - &:focus { - >span { - background: rgb(255, 213, 28); - border-color: rgb(255, 213, 28); - } - } - } -} -.description-line-text { - width: 100%; - text-align: center; - font-weight: bold; - font-style: italic; - font-size: 1.3em; -} -.watch-caption-line[kind=descriptions][current=true] { - background: rgba(209, 209, 209, 0.178); - .caption-line-time-display { - span { - background: rgb(255, 213, 28); - border-color: rgb(255, 213, 28); - } - } -} -.description-line-text-title { - font-size: 0.8em; - line-height: 2em; - color: var(--ct-text-white-hover); -} - -.invalid-text { - color: red !important; -} - -.tooltip { - visibility: hidden; - width: 200px; - background-color: rgba(0, 0, 0, 0.8); - color: #fff; - text-align: center; - border-radius: 5px; - padding: 8px; - position: absolute; - z-index: 1; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - opacity: 0; - transition: opacity 0.2s ease-in-out; -} - -.tooltip::after { - content: ''; - position: absolute; - top: 100%; - left: 50%; - margin-left: -5px; - border-width: 5px; - border-style: solid; - border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; -} - -.caption-line-text-small:hover .tooltip, -.caption-line-text-medium:hover .tooltip, -.caption-line-text-large:hover .tooltip, -.caption-line-text-normal:hover .tooltip, -.watch-caption-line.invalid-text:hover .tooltip { - visibility: visible; - opacity: 1; -} +/* Time */ +/* Text */ +/* .watch-caption-line[data-unsaved=true] .caption-line-text { + color: rgb(255, 166, 0); +} */ +/* Save Button */ +/* .watch-caption-line[data-unsaved=true] .caption-line-btns .caption-line-prompt { + color: rgb(255, 166, 0); +} */ +/* Description */ +.watch-caption-line { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 4em; + padding: .7em; + transition: var(--ct-transition-all); + -o-transition: var(--ct-transition-all); + -moz-transition: var(--ct-transition-all); + -webkit-transition: var(--ct-transition-all); + border-radius: 5px; + z-index: 10; +} +.watch-caption-line[editing=true] { + background: rgb(46, 46, 46); + .caption-line-text { + color: var(--ct-text-white) !important; + outline: none; + font-weight: bold; + } + >.caption-line-btns { + height: 40px; + } +} +.watch-caption-line[hide=true] { + display: none; +} +.caption-line-content { + position: relative; + width: 100%; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: flex-start; +} +.caption-line-time-display { + margin-top: 10px; + // margin-right: .5em; + span { + display: flex; + justify-content: center; + align-items: center; + padding: .1em .3em; + color: rgb(209, 209, 209); + border: 1px solid #75757577; + border-radius: 2px; + font-size: 14px; + font-weight: bold; + white-space: nowrap; + } + &:hover { + >span { + color: black; + background: var(--ct-text-highlight); + border-color: var(--ct-text-highlight); + } + } +} +.watch-caption-line[current=true] { + .caption-line-time-display { + span { + color: black; + background: var(--ct-text-highlight); + border-color: var(--ct-text-highlight); + } + } + .caption-line-text { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + outline: none; + color: var(--ct-text-highlight); + } + } + .caption-line-text-normal { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + outline: none; + color: var(--ct-text-highlight); + } + } + .caption-line-text-large { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + outline: none; + color: var(--ct-text-highlight); + } + } + + .caption-line-text-small { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + outline: none; + color: var(--ct-text-highlight); + } + } +} +.caption-line-text { + font-weight: bold; + font-size: 1.2em; + line-height: 18px; + min-height: 36px; + color: rgba(215, 215, 215, 0.747); + white-space: pre-line; + cursor: pointer; + line-break: normal; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + background: none; + border: none; + box-shadow: none; + text-align: center; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: center; + resize: none; + padding-top: 10px; + &:focus { + cursor: text; + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + } + &:active { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + &:hover { + outline: none; + color: var(--ct-text-highlight); + } +} + +.caption-line-text-normal { + font-weight: bold; + font-size: 1.2em; + line-height: 18px; + min-height: 36px; + color: rgba(215, 215, 215, 0.747); + white-space: pre-line; + cursor: pointer; + line-break: normal; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + background: none; + border: none; + box-shadow: none; + text-align: center; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: center; + resize: none; + padding-top: 10px; + &:focus { + cursor: text; + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + } + &:active { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + &:hover { + outline: none; + color: var(--ct-text-highlight); + } +} + +.caption-line-text-large { + font-weight: bold; + font-size: 1.6em; + line-height: 18px; + min-height: 36px; + color: rgba(215, 215, 215, 0.747); + white-space: pre-line; + cursor: pointer; + line-break: normal; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + background: none; + border: none; + box-shadow: none; + text-align: center; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: center; + resize: none; + padding-top: 10px; + &:focus { + cursor: text; + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + } + &:active { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + &:hover { + outline: none; + color: var(--ct-text-highlight); + }} + +.caption-line-text-small { + font-weight: bold; + font-size: .8em; + line-height: 18px; + min-height: 36px; + color: rgba(215, 215, 215, 0.747); + white-space: pre-line; + cursor: pointer; + line-break: normal; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + background: none; + border: none; + box-shadow: none; + text-align: center; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: center; + resize: none; + padding-top: 10px; + &:focus { + cursor: text; + color: var(--ct-text-white); + outline: none; + font-weight: bold; + &:hover { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + } + &:active { + color: var(--ct-text-white); + outline: none; + font-weight: bold; + } + &:hover { + outline: none; + color: var(--ct-text-highlight); + }} + +.caption-line-btns { + position: relative; + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + overflow: hidden; + height: 0; + font-family: var(--ct-font-google); + transition: var(--ct-transition-all); + -o-transition: var(--ct-transition-all); + -moz-transition: var(--ct-transition-all); + -webkit-transition: var(--ct-transition-all); + .caption-line-prompt { + color: rgb(168, 168, 168); + } +} +.caption-line-save-btn { + margin-top: .5em; + color: white; + background: rgb(97, 97, 97); + padding: .5em 1em; + border-radius: 20px; + font-weight: bold; + margin-right: .5em; + transition: var(--ct-transition-all); + -o-transition: var(--ct-transition-all); + -moz-transition: var(--ct-transition-all); + -webkit-transition: var(--ct-transition-all); + &:hover { + outline: none; + background: rgb(88, 88, 88); + } + &:focus { + outline: none; + background: rgb(88, 88, 88); + } +} +.watch-caption-line[kind=descriptions] { + border: 1px solid grey; + margin-top: 10px; + margin-bottom: 10px; + .caption-line-time-display { + &:hover { + >span { + background: rgb(255, 213, 28); + border-color: rgb(255, 213, 28); + } + } + &:focus { + >span { + background: rgb(255, 213, 28); + border-color: rgb(255, 213, 28); + } + } + } +} + +.description-line-text { + width: 100%; + text-align: center; + font-weight: bold; + font-style: italic; + font-size: 1.3em; +} +.watch-caption-line[kind=descriptions][current=true] { + background: rgba(209, 209, 209, 0.178); + .caption-line-time-display { + span { + background: rgb(255, 213, 28); + border-color: rgb(255, 213, 28); + } + } +} +.description-line-text-title { + font-size: 0.8em; + line-height: 2em; + color: var(--ct-text-white-hover); +} +.invalid-text { + position: relative; /* To position the triangle */ +} + +.tooltip { + visibility: hidden; + width: 200px; + background-color: rgba(0, 0, 0, 0.8); + border-color: white; + border-style: groove; + color: #fff; + text-align: center; + border-radius: 5px; + padding: 8px; + position: absolute; + z-index: 1; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; +} + +.caption-line-text-small:hover .tooltip, +.caption-line-text-medium:hover .tooltip, +.caption-line-text-large:hover .tooltip, +.caption-line-text-normal:hover .tooltip, +.watch-caption-line.invalid-text:hover .tooltip { + visibility: visible; + opacity: 1; +} + +.watch-caption-line:focus-within .tooltip { + visibility: visible; + opacity: 1; +} + +.triangle-indicator { + // position: absolute; + // left: -45px; + // top: 50%; + // transform: translateY(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 10px solid rgb(204, 0, 0); + opacity: 1; +} diff --git a/src/screens/Watch/Utils/helpers.js b/src/screens/Watch/Utils/helpers.js index b64cfde7..84dec2b0 100644 --- a/src/screens/Watch/Utils/helpers.js +++ b/src/screens/Watch/Utils/helpers.js @@ -1,121 +1,121 @@ -import { uurl, links } from 'utils'; -import moment from 'moment'; -import { cc_colorMap, CC_COLOR_BLACK } from './constants.util'; - -/** - * Convert seconds to a readable time string `H:mm:ss` - * @param {Number} sec - seconds - * @returns {String} H:mm:ss - */ -export function parseSec(sec) { - const formatter = sec < 3600 ? 'mm:ss' : 'H:mm:ss'; - return moment().startOf('day').seconds(sec).format(formatter); -} - -/** - * Parse time string H:mm:ss to seconds - * @param {String} str - time string H:mm:ss - * @returns {Number} seconds - */ -export function timeStrToSec(str) { - if (typeof str !== 'string') return ''; - const strs = str.split(':'); - const len3 = strs.length > 2; - const sec = (len3 ? parseFloat(strs[2]) : parseFloat(strs[1])) || 0; - const min = (len3 ? parseFloat(strs[1]) : parseFloat(strs[0])) * 60 || 0; - const hr = (len3 ? parseFloat(strs[0]) : 0) * 3600 || 0; - return sec + min + hr; -} - -/** - * @param {Number} time current time - * @returns {()=>Boolean} - */ -export function isEarlier(time) { - return ({ begin }) => time >= timeStrToSec(begin); -} - -/** - * @param {Number} time current time - * @returns {()=>Boolean} - */ -export function isLater(time) { - return ({ begin }) => time <= timeStrToSec(begin); -} - -export function prettierTimeStr(time, showMilliseconds = false) { - if (typeof time !== 'string') return ''; - - const parts = time.split(':').map((part) => parseFloat(part)); - let hours = 0; - let mins = 0; - let secs = 0; - let millis = 0; - - if (parts.length === 3) { - hours = parts[0]; - mins = parts[1]; - secs = Math.floor(parts[2]); - millis = Math.round((parts[2] % 1) * 1000); - } else if (parts.length === 2) { - mins = parts[0]; - secs = Math.floor(parts[1]); - millis = Math.round((parts[1] % 1) * 1000); - } else if (parts.length === 1) { - secs = Math.floor(parts[0]); - millis = Math.round((parts[0] % 1) * 1000); - } - - const format = (num, digits = 2) => String(num).padStart(digits, '0'); - const formattedTime = `${format(hours)}:${format(mins)}:${format(secs)}`; - - return showMilliseconds - ? `${formattedTime}.${format(millis, 3)}` - : formattedTime; -} - - - -// export function prettierTimeStr(str) { -// if (typeof str !== 'string') return ''; - -// const strs = str.split(':'); -// if (strs.length !== 3) return ''; // Ensure the input is in HH:MM:SS format - -// let hours = parseInt(strs[0], 10); -// let mins = parseInt(strs[1], 10); -// let sec = parseInt(strs[2], 10); - -// // Format minutes and seconds to two digits -// if (hours < 10) hours = `0${hours}`; -// if (mins < 10) mins = `0${mins}`; -// if (sec < 10) sec = `0${sec}`; - -// return `${hours}:${mins}:${sec}`; -// } - - -export function getCCSelectOptions(array = [], operation = (item) => item) { - const options = []; - array.forEach((item) => { - const text = operation(item); - options.push({ text, value: item }); - }); - return options; -} - -export function colorMap(color = CC_COLOR_BLACK, opacity = 1) { - const colorStr = cc_colorMap[color]; - if (!colorStr) return CC_COLOR_BLACK; - return colorStr.replace('*', opacity); -} - -/** handle Share */ -// Get share url -export function getShareableURL(begin = 0) { - const { origin } = window.location; - const { id } = uurl.useSearch(); - const pathname = links.watch(id, { begin, from: 'sharedlink' }); - - return origin + pathname; -} +import { uurl, links } from 'utils'; +import moment from 'moment'; +import { cc_colorMap, CC_COLOR_BLACK } from './constants.util'; + +/** + * Convert seconds to a readable time string `H:mm:ss` + * @param {Number} sec - seconds + * @returns {String} H:mm:ss + */ +export function parseSec(sec) { + const formatter = sec < 3600 ? 'mm:ss' : 'H:mm:ss'; + return moment().startOf('day').seconds(sec).format(formatter); +} + +/** + * Parse time string H:mm:ss to seconds + * @param {String} str - time string H:mm:ss + * @returns {Number} seconds + */ +export function timeStrToSec(str) { + if (typeof str !== 'string') return ''; + const strs = str.split(':'); + const len3 = strs.length > 2; + const sec = (len3 ? parseFloat(strs[2]) : parseFloat(strs[1])) || 0; + const min = (len3 ? parseFloat(strs[1]) : parseFloat(strs[0])) * 60 || 0; + const hr = (len3 ? parseFloat(strs[0]) : 0) * 3600 || 0; + return sec + min + hr; +} + +/** + * @param {Number} time current time + * @returns {()=>Boolean} + */ +export function isEarlier(time) { + return ({ begin }) => time >= timeStrToSec(begin); +} + +/** + * @param {Number} time current time + * @returns {()=>Boolean} + */ +export function isLater(time) { + return ({ begin }) => time <= timeStrToSec(begin); +} + +export function prettierTimeStr(time, showMilliseconds = false) { + if (typeof time !== 'string') return ''; + + const parts = time.split(':').map((part) => parseFloat(part)); + let hours = 0; + let mins = 0; + let secs = 0; + let millis = 0; + + if (parts.length === 3) { + hours = parts[0]; + mins = parts[1]; + secs = Math.floor(parts[2]); + millis = Math.round((parts[2] % 1) * 1000); + } else if (parts.length === 2) { + mins = parts[0]; + secs = Math.floor(parts[1]); + millis = Math.round((parts[1] % 1) * 1000); + } else if (parts.length === 1) { + secs = Math.floor(parts[0]); + millis = Math.round((parts[0] % 1) * 1000); + } + + const format = (num, digits = 2) => String(num).padStart(digits, '0'); + const formattedTime = `${format(hours)}:${format(mins)}:${format(secs)}`; + + return showMilliseconds + ? `${formattedTime}.${format(millis, 3)}` + : formattedTime; +} + + + +// export function prettierTimeStr(str) { +// if (typeof str !== 'string') return ''; + +// const strs = str.split(':'); +// if (strs.length !== 3) return ''; // Ensure the input is in HH:MM:SS format + +// let hours = parseInt(strs[0], 10); +// let mins = parseInt(strs[1], 10); +// let sec = parseInt(strs[2], 10); + +// // Format minutes and seconds to two digits +// if (hours < 10) hours = `0${hours}`; +// if (mins < 10) mins = `0${mins}`; +// if (sec < 10) sec = `0${sec}`; + +// return `${hours}:${mins}:${sec}`; +// } + + +export function getCCSelectOptions(array = [], operation = (item) => item) { + const options = []; + array.forEach((item) => { + const text = operation(item); + options.push({ text, value: item }); + }); + return options; +} + +export function colorMap(color = CC_COLOR_BLACK, opacity = 1) { + const colorStr = cc_colorMap[color]; + if (!colorStr) return CC_COLOR_BLACK; + return colorStr.replace('*', opacity); +} + +/** handle Share */ +// Get share url +export function getShareableURL(begin = 0) { + const { origin } = window.location; + const { id } = uurl.useSearch(); + const pathname = links.watch(id, { begin, from: 'sharedlink' }); + + return origin + pathname; +} diff --git a/src/screens/Watch/Utils/prompt.control.js b/src/screens/Watch/Utils/prompt.control.js index 97c58035..09071c8f 100644 --- a/src/screens/Watch/Utils/prompt.control.js +++ b/src/screens/Watch/Utils/prompt.control.js @@ -1,81 +1,81 @@ -import { prompt } from 'utils'; - -/** - * Functions for controlling prompts - */ -const standardPosition = [70, 70]; - -export const promptControl = { - - closePrompt() { - prompt.closeAll(); - }, - - editCaptionUsingKeyboard() { - prompt.addOne({ - status: 'success', - text: 'You are editing the current caption! Hit return to save changes.', - offset: standardPosition, - }); - }, - - editCaptionTips() { - prompt.addOne({ - text: 'Hit return to save your changes!', - offset: standardPosition, - }); - }, - - savingCaption() { }, - - savedCaption(isClosedCaption, success = true) { - const captionType = isClosedCaption ? 'Closed Caption' : 'Description' - prompt.addOne({ - status: success ? 'success' : 'error', - text: success ? `${captionType} updated` : `${captionType} could not be saved`, - offset: standardPosition, - timeout: 3000, - }); - }, - - timestampFailed(isClosedCaption) { - const captionType = isClosedCaption ? 'Closed Caption' : 'Description' - prompt.addOne({ - status: 'error', - text: `${captionType} could not be saved. Timestamps must be in HH:MM:SS.MS format`, - offset: standardPosition, - timeout: 3000, - }); - }, - - hideSecondaryScreen() { - prompt.addOne({ - text: 'Click video_label to see more screen options.', - offset: standardPosition, - timeout: 5000, - }); - }, - - error(target = 'media data') { - const { search, pathname } = window.location; - prompt.addOne({ - text: `Couldn't load ${target}. Please refresh to retry.`, - offset: standardPosition, - status: 'error', - }); - }, - - videoNotLoading() { - prompt.addOne({ - text: `Sorry, if the video can't load, please use Chrome to open the page. - Click to open in Chrome`, - position: 'top', - status: 'error', - }); - }, -}; +import { prompt } from 'utils'; + +/** + * Functions for controlling prompts + */ +const standardPosition = [70, 70]; + +export const promptControl = { + + closePrompt() { + prompt.closeAll(); + }, + + editCaptionUsingKeyboard() { + prompt.addOne({ + status: 'success', + text: 'You are editing the current caption! Hit return to save changes.', + offset: standardPosition, + }); + }, + + editCaptionTips() { + prompt.addOne({ + text: 'Hit return to save your changes!', + offset: standardPosition, + }); + }, + + savingCaption() { }, + + savedCaption(isClosedCaption, success = true) { + const captionType = isClosedCaption ? 'Closed Caption' : 'Description' + prompt.addOne({ + status: success ? 'success' : 'error', + text: success ? `${captionType} updated` : `${captionType} could not be saved`, + offset: standardPosition, + timeout: 3000, + }); + }, + + timestampFailed(isClosedCaption) { + const captionType = isClosedCaption ? 'Closed Caption' : 'Description' + prompt.addOne({ + status: 'error', + text: `${captionType} could not be saved. Timestamps must be in HH:MM:SS.MS format`, + offset: standardPosition, + timeout: 3000, + }); + }, + + hideSecondaryScreen() { + prompt.addOne({ + text: 'Click video_label to see more screen options.', + offset: standardPosition, + timeout: 5000, + }); + }, + + error(target = 'media data') { + const { search, pathname } = window.location; + prompt.addOne({ + text: `Couldn't load ${target}. Please refresh to retry.`, + offset: standardPosition, + status: 'error', + }); + }, + + videoNotLoading() { + prompt.addOne({ + text: `Sorry, if the video can't load, please use Chrome to open the page. + Click to open in Chrome`, + position: 'top', + status: 'error', + }); + }, +}; diff --git a/src/screens/Watch/model.js b/src/screens/Watch/model.js index 2a7802eb..98f01bb1 100644 --- a/src/screens/Watch/model.js +++ b/src/screens/Watch/model.js @@ -1,512 +1,512 @@ -/* eslint-disable no-console */ -import { isSafari, isIPad13, isIPhone13, isMobile } from 'react-device-detect'; -import { api, prompt, uurl } from 'utils'; -import _ from 'lodash'; -import { ARRAY_INIT, DEFAULT_ROLE } from 'utils/constants'; -import { timeStrToSec } from './Utils/helpers'; -import PlayerData from './player' -import { - WEBVTT_SUBTITLES, - SEARCH_HIDE, - WEBVTT_DESCRIPTIONS, - ARRAY_EMPTY, - // PROFANITY_LIST, -} from './Utils/constants.util'; - -import { uEvent } from './Utils/UserEventController'; -import { promptControl } from './Utils/prompt.control'; -import setup from './model/setup' -import player_effects from './model/player_effects' -import menu_effects from './model/menu_effects' -import trans_effects from './model/trans_effects' -import search_effects from './model/search_effects' -import { - // constants - MENU_HIDE, - NORMAL_MODE, - SEARCH_INIT, - MODAL_HIDE, - CTP_LOADING, - CTP_PLAYING, - ERR_INVALID_MEDIA_ID, - ERR_AUTH - // MODAL_SHARE -} from './Utils'; - - -const initState = { - // Basics - userRole: DEFAULT_ROLE, - - error: null, - - // Metadata - media: { - id: '', - mediaName: '', - createdAt: '', - isTwoScreen: false, - hasASL: false, - videos: [], - transcriptions: [], - isUnavailable: false, - flashDetected: false - }, - flashAcknowledged: false, - playlist: {}, - playlists: [], - offering: {}, - watchHistory: [], - starredOfferings: [], - - // VideoInfo - time: 0, - duration: 0, - bufferedTime: 0, - isSwitched: false, - paused: true, - - isFullscreen: false, - isFullscreenTwo: false, - ctpPriEvent: CTP_LOADING, - ctpSecEvent: CTP_LOADING, - - // Trans - transcriptions: [], - currTrans: {}, - trackerMap: new Map(), - transcript: [], - captions: [], - currCaption: null, - descriptions: [], - currDescription: null, - currEditing: null, - bulkEditing: false, - updating: false, - currCaptionIndex: 0, - captionSpeedUp: 0, - offSet: 0, - sliderOffSet: 0, - fontSize: 'normal', - eventListener: undefined, - - // screen options - mode: NORMAL_MODE, - - menu: MENU_HIDE, - modal: MODAL_HIDE, - liveMode: false, - englishTrack: undefined, - currentAudioTrack: 0, - audioTracks: undefined, - - // Others - prompt: null, - search: SEARCH_INIT, - mouseOnCaption: false, - embedded: false, - textTracks: [], -} -/** -* Function used to union two caption arrays -* Merging is based on the { begin, end } of each entry in the arrays -*/ -// const unionTranscript = (captions, source) => { -// let union = _.concat( -// captions === ARRAY_EMPTY ? [] : captions, -// source === ARRAY_EMPTY ? [] : source, -// ); -// // -// union = _.sortBy(union, (item) => timeStrToSec(item.begin)); -// union = _.map(union, (item, index) => ({ ...item, index })); -// return union; -// } - -const WatchModel = { - namespace: 'watch', - state: { ...initState }, - reducers: { - // Metadata - setError(state, { payload }) { - return { ...state, error: payload }; - }, - - setMedia(state, { payload }) { - return { ...state, media: payload, embedded: false, liveMode: payload.isLive ? 1 : 0 }; - }, - setEmbeddedMedia(state, { payload: { media, ...embeded_payload } }) { - return { - ...state, media, - embedded: embeded_payload, - liveMode: media.isLive ? 1 : 0 - }; - }, - setLiveMode(state, { payload }) { - return { ...state, liveMode: payload }; - }, - setTextTracks(state, { payload }) { - return { ...state, textTracks: payload }; - }, - - setAudioTracks(state, { payload }) { - return { ...state, audioTracks: payload }; - }, - setCurrCaptionIndex(state, { payload }) { - return { ...state, currCaptionIndex: payload }; - }, - setPlaylist(state, { payload }) { - return { ...state, playlist: payload }; - }, - setPlaylists(state, { payload }) { - return { ...state, playlists: payload }; - }, - setOffering(state, { payload }) { - return { ...state, offering: payload }; - }, - setEventListener(state, { payload }) { - return { ...state, setEventListener: payload }; - }, - setWatchHistory(state, { payload }) { - return { ...state, watchHistory: payload }; - }, - setOffSet(state, { payload }) { - return { ...state, offSet: payload }; - }, - - setCaptionSpeedUp(state, { payload }) { - return { ...state, captionSpeedUp: payload }; - }, - setStarredOfferings(state, { payload }) { - return { ...state, starredOfferings: payload }; - }, - setEnglishTrack(state, { payload }) { - if(state.englishTrack !== undefined) { - // state.englishTrack.mode = 'hidden'; - // state.englishTrack.removeEventListener('cuechange', state.eventListener); - - } - let currTrack = document.getElementsByTagName('video')[0].textTracks; - return { ...state, englishTrack: currTrack[payload], transcript: []}; - }, - - setFullscreen(state, { payload }) { - return { ...state, isFullscreen: payload }; - }, - setFullscreenTwo(state, { payload }) { - return { ...state, isFullscreenTwo: payload }; - }, - // Transcription - setTranscriptions(state, { payload }) { - return { ...state, transcriptions: payload }; - }, - // setCurrTrans(state, { payload }) { - // return { ...state, currTrans: payload }; - // }, - setCurrentTranscriptionMulti(state, { payload }) { - const { transKey, active } = payload; - let { currentTranscriptionMulti = {transKeysSelected:[] } } = state; - let newKeys = currentTranscriptionMulti.transKeysSelected.filter(i => (i !== transKey)) - if( active ) { - newKeys.push(transKey) - } - return { ...state, currentTranscriptionMulti: {transKeysSelected: newKeys} }; - }, - setUpdating(state, { payload }) { - // - return { ...state, updating: payload }; - }, - - // Test live caption font size change - setFontSize(state, { payload }) { - return { ...state, fontSize: payload }; - }, - - // setup the transcript UI model; the caption and description data must already be loaded - // eslint-disable-next-line no-unused-vars - setTranscript(state, _unused) { - // Todo check that the payload is immutable because we use the subobjects in our immutable model - // console.log("setTranscript") - let all = [... state.captions,...state.descriptions] - - let transcript = all; - // Using String sort so numbers (1.123 21.0) must be right aligned with same number of decimal places - // Put Closed Captions after Descriptions - transcript = _.sortBy(transcript, (item) => `${timeStrToSec(item.begin).toFixed(2).padStart(10)}/${item.transcription.transcriptionType === 0?'Z':item.transcription.transcriptionType}`); - transcript= _.map(transcript, (item, index) => ({ ...item, index })); - - if (transcript.length === 0) transcript = ARRAY_EMPTY; - - return { ...state, transcript }; - } - , - /** - * Function called for setting captions array - */ - setCaptions(state, { payload }) { - // console.log(`setCaptions ${payload.length}`) - let parsedCap = _.map(payload, (c) => ({ ...c, kind: WEBVTT_SUBTITLES })); - // if (parsedCap.length === 0) parsedCap = ARRAY_EMPTY; - return { ...state, captions: parsedCap }; - }, - setCurrCaption(state, { payload }) { - return {...state, currCaption: payload} - }, - /** - * * Function called for get or set audio descriptions - * - */ - setDescriptions(state, { payload }) { - const parsedDes = _.map(payload, (d) => ({ ...d, kind: WEBVTT_DESCRIPTIONS })); - return { ...state, descriptions: parsedDes }; - }, - setCurrDescription(state, { payload }) { - return { ...state, currDescription: payload }; - }, - setCurrEditing(state, { payload }) { - return { ...state, currEditing: payload }; - }, - setBulkEditing(state, { payload }) { - return { ...state, bulkEditing: payload }; - }, - - // Settings - setMode(state, { payload }) { - return { ...state, mode: payload, prevmode: state.mode }; - }, - setMenu(state, { payload }) { - return { ...state, menu: payload }; - }, - setModal(state, { payload }) { - return { ...state, modal: payload }; - }, - - setTime(state, { payload }) { - let liveMode = state.liveMode - if(state.liveMode === 1) { - liveMode = payload < state.duration - 60 ? 2 : 1 - } - - return { ...state, time: payload, liveMode }; - }, - setBufferedTime(state, { payload }) { - return { ...state, bufferedTime: payload }; - }, - setDuration(state, { payload }) { - return { ...state, duration: payload }; - }, - switchScreen(state, { payload }) { - return { ...state, isSwitched: payload }; - }, - setMouseOnCaption(state, { payload }) { - return { ...state, mouseOnCaption: payload }; - }, - setPause(state, { payload }) { - return { ...state, paused: payload }; - }, - setCTPEvent(state, { payload: { event = CTP_PLAYING, priVideo = true } }) { - if (priVideo) { - return { ...state, ctpPriEvent: event }; - } - return { ...state, ctpSecEvent: event }; - }, - // Others - setSearch(state, { payload }) { - return { ...state, search: { ...state.search, ...payload } }; - }, - resetSearch(state, { payload: status = SEARCH_HIDE }) { - return { - ...state, search: { - status, - value: '', - inVideoTransResults: ARRAY_INIT, - inCourseTransResults: ARRAY_INIT, - playlistResults: ARRAY_INIT, - } - } - }, - setPrompt(state, { payload }) { - return { ...state, prompt: payload }; - }, - - // actions - setReduxState(state, { payload }) { - return { ...state, ...payload }; - }, - - - setFlashAcknowledged(state, { payload }) { - return { ...state, flashAcknowledged: payload }; - }, - - changeVideo(state, { payload }) { - return { - ...state, - ...payload, - time: 0, - duration: 0, - bufferedTime: 0, - isFullscreen: false, - hasASL: false, - ctpPriEvent: CTP_LOADING, - ctpSecEvent: CTP_LOADING, - paused: true, - isSwitched: false, - - transcriptions: [], - currTrans: {}, - transcript: [], - captions: [], - currCaption: null, - descriptions: [], - currDescription: null, - currEditing: null, - bulkEditing: false, - - modal: MODAL_HIDE, - liveMode: false, - prompt: null, - search: SEARCH_INIT, - flashAcknowledged: false, - }; - }, - - // eslint-disable-next-line no-unused-vars - resetStates(_state, { _unused }) { - return { ...initState }; - }, - }, - effects: { - *setupMedia(_unused, { call, put }) { - // Get media - yield put.resolve({ type: 'changeVideo', payload: { media: {} } }) - const { id } = uurl.useSearch(); - let media = null; - try { - const { data } = yield call(api.getMediaById, id); - media = api.parseMedia(data); - } catch (error) { - if (api.parseError(error).status === 404) { - yield put({ type: 'setError', payload: ERR_INVALID_MEDIA_ID }); - } else { - yield put({ type: 'setError', payload: ERR_AUTH }); - } - return null; - } - PlayerData.param = {}; - yield put({ type: 'setMedia', payload: media }) - yield put({ type: 'setMenu', payload: MENU_HIDE }) - // Set transcriptions - - const { transcriptions } = media; - // console.log('-----'); - // console.log(`*setupMedia ${transcriptions.length} transcriptions`); - - // setTranscriptions - yield put({ type: 'setTranscriptions', payload: transcriptions }) - // Get Playlist - const { playlistId } = media; - const playlist = yield call(setup.getPlaylist, playlistId); - if (!playlist) { - promptControl.error('playlist'); - api.contentLoaded(); - return; - } - // Set data - yield put({ type: 'setPlaylist', payload: playlist }) - - const { offeringId } = playlist; - let { data: offering } = yield call(api.getOfferingById, offeringId); - offering = api.parseSingleOffering(offering); - yield put({ type: 'setOffering', payload: offering }) - // register the ids to the user event controller - uEvent.registerIds(media.id, offeringId); - // send select video event - uEvent.selectvideo(media.id); - - api.contentLoaded(); - - // Get playlists - const playlists = yield call(setup.getPlaylists, offeringId); - if (playlists) { - yield put({ type: 'setPlaylists', payload: playlists }) - } - if (isSafari && isIPad13 && isIPhone13) { - promptControl.videoNotLoading(); - } - try { - let { data } = yield call(api.getUserWatchHistories) - yield put({ type: 'setWatchHistories', payload: data.filter(media_ => media_?.id) }) - } catch (error) { - prompt.addOne({ text: "Couldn't load watch histories.", status: 'error' }); - } - }, - *setupEmbeddedMedia({ payload }, { call, put }) { - const { mediaId, ...props } = payload; - let media = payload.media; - if (!media) { - if (mediaId) { - try { - const { data } = yield call(api.getMediaById, mediaId); - media = api.parseMedia(data); - } catch (error) { - if (api.parseError(error).status === 404) { - yield put({ type: 'setError', payload: ERR_INVALID_MEDIA_ID }); - } else { - yield put({ type: 'setError', payload: ERR_AUTH }); - } - return false; - } - } else { - return false; - } - } - const transcriptions = media.transcriptions; - delete props.media - // delete media.transcriptions; - yield put({ type: 'setEmbeddedMedia', payload: { media, ...props } }) - yield put({ type: 'setTranscriptions', payload: transcriptions }) - }, - ...player_effects, - ...menu_effects, - ...trans_effects, - ...search_effects - }, - subscriptions: { - setup({ dispatch, history }) { - if (!isMobile) { - // document.removeEventListener('fullscreenchange', this.onFullScreenChange, true); - document.addEventListener('fullscreenchange', (e) => { - dispatch({ type: 'onFullScreenChange', payload: e }) - }, true); - if (isMobile) { - window.addEventListener('orientationchange', () => { - // - if ([90, -90].includes(window.orientation)) { - /* NOT IMPLEMENTED - if (that.currTime() > 0) { - that.enterFullScreen(); - } - */ - } - }); - } else { - window.addEventListener('resize', () => { - if (window.innerWidth < 900) { - /* NOT IMPLEMENTED - if (that.SCREEN_MODE === PS_MODE) { - this.dispatch({ type: 'watch/setWatchMode', payload: { mode: NESTED_MODE, config: { sendUserAction: false } } }); - } - */ - } - }); - } - } - history.listen((event) => { - if (event.pathname === '/video' || event.action === 'PUSH' && event.location.pathname === '/video') { - dispatch({ type: 'setupMedia' }); - } - }) - } - } -} +/* eslint-disable no-console */ +import { isSafari, isIPad13, isIPhone13, isMobile } from 'react-device-detect'; +import { api, prompt, uurl } from 'utils'; +import _ from 'lodash'; +import { ARRAY_INIT, DEFAULT_ROLE } from 'utils/constants'; +import { timeStrToSec } from './Utils/helpers'; +import PlayerData from './player' +import { + WEBVTT_SUBTITLES, + SEARCH_HIDE, + WEBVTT_DESCRIPTIONS, + ARRAY_EMPTY, + // PROFANITY_LIST, +} from './Utils/constants.util'; + +import { uEvent } from './Utils/UserEventController'; +import { promptControl } from './Utils/prompt.control'; +import setup from './model/setup' +import player_effects from './model/player_effects' +import menu_effects from './model/menu_effects' +import trans_effects from './model/trans_effects' +import search_effects from './model/search_effects' +import { + // constants + MENU_HIDE, + NORMAL_MODE, + SEARCH_INIT, + MODAL_HIDE, + CTP_LOADING, + CTP_PLAYING, + ERR_INVALID_MEDIA_ID, + ERR_AUTH + // MODAL_SHARE +} from './Utils'; + + +const initState = { + // Basics + userRole: DEFAULT_ROLE, + + error: null, + + // Metadata + media: { + id: '', + mediaName: '', + createdAt: '', + isTwoScreen: false, + hasASL: false, + videos: [], + transcriptions: [], + isUnavailable: false, + flashDetected: false + }, + flashAcknowledged: false, + playlist: {}, + playlists: [], + offering: {}, + watchHistory: [], + starredOfferings: [], + + // VideoInfo + time: 0, + duration: 0, + bufferedTime: 0, + isSwitched: false, + paused: true, + + isFullscreen: false, + isFullscreenTwo: false, + ctpPriEvent: CTP_LOADING, + ctpSecEvent: CTP_LOADING, + + // Trans + transcriptions: [], + currTrans: {}, + trackerMap: new Map(), + transcript: [], + captions: [], + currCaption: null, + descriptions: [], + currDescription: null, + currEditing: null, + bulkEditing: false, + updating: false, + currCaptionIndex: 0, + captionSpeedUp: 0, + offSet: 0, + sliderOffSet: 0, + fontSize: 'normal', + eventListener: undefined, + + // screen options + mode: NORMAL_MODE, + + menu: MENU_HIDE, + modal: MODAL_HIDE, + liveMode: false, + englishTrack: undefined, + currentAudioTrack: 0, + audioTracks: undefined, + + // Others + prompt: null, + search: SEARCH_INIT, + mouseOnCaption: false, + embedded: false, + textTracks: [], +} +/** +* Function used to union two caption arrays +* Merging is based on the { begin, end } of each entry in the arrays +*/ +// const unionTranscript = (captions, source) => { +// let union = _.concat( +// captions === ARRAY_EMPTY ? [] : captions, +// source === ARRAY_EMPTY ? [] : source, +// ); +// // +// union = _.sortBy(union, (item) => timeStrToSec(item.begin)); +// union = _.map(union, (item, index) => ({ ...item, index })); +// return union; +// } + +const WatchModel = { + namespace: 'watch', + state: { ...initState }, + reducers: { + // Metadata + setError(state, { payload }) { + return { ...state, error: payload }; + }, + + setMedia(state, { payload }) { + return { ...state, media: payload, embedded: false, liveMode: payload.isLive ? 1 : 0 }; + }, + setEmbeddedMedia(state, { payload: { media, ...embeded_payload } }) { + return { + ...state, media, + embedded: embeded_payload, + liveMode: media.isLive ? 1 : 0 + }; + }, + setLiveMode(state, { payload }) { + return { ...state, liveMode: payload }; + }, + setTextTracks(state, { payload }) { + return { ...state, textTracks: payload }; + }, + + setAudioTracks(state, { payload }) { + return { ...state, audioTracks: payload }; + }, + setCurrCaptionIndex(state, { payload }) { + return { ...state, currCaptionIndex: payload }; + }, + setPlaylist(state, { payload }) { + return { ...state, playlist: payload }; + }, + setPlaylists(state, { payload }) { + return { ...state, playlists: payload }; + }, + setOffering(state, { payload }) { + return { ...state, offering: payload }; + }, + setEventListener(state, { payload }) { + return { ...state, setEventListener: payload }; + }, + setWatchHistory(state, { payload }) { + return { ...state, watchHistory: payload }; + }, + setOffSet(state, { payload }) { + return { ...state, offSet: payload }; + }, + + setCaptionSpeedUp(state, { payload }) { + return { ...state, captionSpeedUp: payload }; + }, + setStarredOfferings(state, { payload }) { + return { ...state, starredOfferings: payload }; + }, + setEnglishTrack(state, { payload }) { + if(state.englishTrack !== undefined) { + // state.englishTrack.mode = 'hidden'; + // state.englishTrack.removeEventListener('cuechange', state.eventListener); + + } + let currTrack = document.getElementsByTagName('video')[0].textTracks; + return { ...state, englishTrack: currTrack[payload], transcript: []}; + }, + + setFullscreen(state, { payload }) { + return { ...state, isFullscreen: payload }; + }, + setFullscreenTwo(state, { payload }) { + return { ...state, isFullscreenTwo: payload }; + }, + // Transcription + setTranscriptions(state, { payload }) { + return { ...state, transcriptions: payload }; + }, + // setCurrTrans(state, { payload }) { + // return { ...state, currTrans: payload }; + // }, + setCurrentTranscriptionMulti(state, { payload }) { + const { transKey, active } = payload; + let { currentTranscriptionMulti = {transKeysSelected:[] } } = state; + let newKeys = currentTranscriptionMulti.transKeysSelected.filter(i => (i !== transKey)) + if( active ) { + newKeys.push(transKey) + } + return { ...state, currentTranscriptionMulti: {transKeysSelected: newKeys} }; + }, + setUpdating(state, { payload }) { + // + return { ...state, updating: payload }; + }, + + // Test live caption font size change + setFontSize(state, { payload }) { + return { ...state, fontSize: payload }; + }, + + // setup the transcript UI model; the caption and description data must already be loaded + // eslint-disable-next-line no-unused-vars + setTranscript(state, _unused) { + // Todo check that the payload is immutable because we use the subobjects in our immutable model + // console.log("setTranscript") + let all = [... state.captions,...state.descriptions] + + let transcript = all; + // Using String sort so numbers (1.123 21.0) must be right aligned with same number of decimal places + // Put Closed Captions after Descriptions + transcript = _.sortBy(transcript, (item) => `${timeStrToSec(item.begin).toFixed(2).padStart(10)}/${item.transcription.transcriptionType === 0?'Z':item.transcription.transcriptionType}`); + transcript= _.map(transcript, (item, index) => ({ ...item, index })); + + if (transcript.length === 0) transcript = ARRAY_EMPTY; + + return { ...state, transcript }; + } + , + /** + * Function called for setting captions array + */ + setCaptions(state, { payload }) { + // console.log(`setCaptions ${payload.length}`) + let parsedCap = _.map(payload, (c) => ({ ...c, kind: WEBVTT_SUBTITLES })); + // if (parsedCap.length === 0) parsedCap = ARRAY_EMPTY; + return { ...state, captions: parsedCap }; + }, + setCurrCaption(state, { payload }) { + return {...state, currCaption: payload} + }, + /** + * * Function called for get or set audio descriptions + * + */ + setDescriptions(state, { payload }) { + const parsedDes = _.map(payload, (d) => ({ ...d, kind: WEBVTT_DESCRIPTIONS })); + return { ...state, descriptions: parsedDes }; + }, + setCurrDescription(state, { payload }) { + return { ...state, currDescription: payload }; + }, + setCurrEditing(state, { payload }) { + return { ...state, currEditing: payload }; + }, + setBulkEditing(state, { payload }) { + return { ...state, bulkEditing: payload }; + }, + + // Settings + setMode(state, { payload }) { + return { ...state, mode: payload, prevmode: state.mode }; + }, + setMenu(state, { payload }) { + return { ...state, menu: payload }; + }, + setModal(state, { payload }) { + return { ...state, modal: payload }; + }, + + setTime(state, { payload }) { + let liveMode = state.liveMode + if(state.liveMode === 1) { + liveMode = payload < state.duration - 60 ? 2 : 1 + } + + return { ...state, time: payload, liveMode }; + }, + setBufferedTime(state, { payload }) { + return { ...state, bufferedTime: payload }; + }, + setDuration(state, { payload }) { + return { ...state, duration: payload }; + }, + switchScreen(state, { payload }) { + return { ...state, isSwitched: payload }; + }, + setMouseOnCaption(state, { payload }) { + return { ...state, mouseOnCaption: payload }; + }, + setPause(state, { payload }) { + return { ...state, paused: payload }; + }, + setCTPEvent(state, { payload: { event = CTP_PLAYING, priVideo = true } }) { + if (priVideo) { + return { ...state, ctpPriEvent: event }; + } + return { ...state, ctpSecEvent: event }; + }, + // Others + setSearch(state, { payload }) { + return { ...state, search: { ...state.search, ...payload } }; + }, + resetSearch(state, { payload: status = SEARCH_HIDE }) { + return { + ...state, search: { + status, + value: '', + inVideoTransResults: ARRAY_INIT, + inCourseTransResults: ARRAY_INIT, + playlistResults: ARRAY_INIT, + } + } + }, + setPrompt(state, { payload }) { + return { ...state, prompt: payload }; + }, + + // actions + setReduxState(state, { payload }) { + return { ...state, ...payload }; + }, + + + setFlashAcknowledged(state, { payload }) { + return { ...state, flashAcknowledged: payload }; + }, + + changeVideo(state, { payload }) { + return { + ...state, + ...payload, + time: 0, + duration: 0, + bufferedTime: 0, + isFullscreen: false, + hasASL: false, + ctpPriEvent: CTP_LOADING, + ctpSecEvent: CTP_LOADING, + paused: true, + isSwitched: false, + + transcriptions: [], + currTrans: {}, + transcript: [], + captions: [], + currCaption: null, + descriptions: [], + currDescription: null, + currEditing: null, + bulkEditing: false, + + modal: MODAL_HIDE, + liveMode: false, + prompt: null, + search: SEARCH_INIT, + flashAcknowledged: false, + }; + }, + + // eslint-disable-next-line no-unused-vars + resetStates(_state, { _unused }) { + return { ...initState }; + }, + }, + effects: { + *setupMedia(_unused, { call, put }) { + // Get media + yield put.resolve({ type: 'changeVideo', payload: { media: {} } }) + const { id } = uurl.useSearch(); + let media = null; + try { + const { data } = yield call(api.getMediaById, id); + media = api.parseMedia(data); + } catch (error) { + if (api.parseError(error).status === 404) { + yield put({ type: 'setError', payload: ERR_INVALID_MEDIA_ID }); + } else { + yield put({ type: 'setError', payload: ERR_AUTH }); + } + return null; + } + PlayerData.param = {}; + yield put({ type: 'setMedia', payload: media }) + yield put({ type: 'setMenu', payload: MENU_HIDE }) + // Set transcriptions + + const { transcriptions } = media; + // console.log('-----'); + // console.log(`*setupMedia ${transcriptions.length} transcriptions`); + + // setTranscriptions + yield put({ type: 'setTranscriptions', payload: transcriptions }) + // Get Playlist + const { playlistId } = media; + const playlist = yield call(setup.getPlaylist, playlistId); + if (!playlist) { + promptControl.error('playlist'); + api.contentLoaded(); + return; + } + // Set data + yield put({ type: 'setPlaylist', payload: playlist }) + + const { offeringId } = playlist; + let { data: offering } = yield call(api.getOfferingById, offeringId); + offering = api.parseSingleOffering(offering); + yield put({ type: 'setOffering', payload: offering }) + // register the ids to the user event controller + uEvent.registerIds(media.id, offeringId); + // send select video event + uEvent.selectvideo(media.id); + + api.contentLoaded(); + + // Get playlists + const playlists = yield call(setup.getPlaylists, offeringId); + if (playlists) { + yield put({ type: 'setPlaylists', payload: playlists }) + } + if (isSafari && isIPad13 && isIPhone13) { + promptControl.videoNotLoading(); + } + try { + let { data } = yield call(api.getUserWatchHistories) + yield put({ type: 'setWatchHistories', payload: data.filter(media_ => media_?.id) }) + } catch (error) { + prompt.addOne({ text: "Couldn't load watch histories.", status: 'error' }); + } + }, + *setupEmbeddedMedia({ payload }, { call, put }) { + const { mediaId, ...props } = payload; + let media = payload.media; + if (!media) { + if (mediaId) { + try { + const { data } = yield call(api.getMediaById, mediaId); + media = api.parseMedia(data); + } catch (error) { + if (api.parseError(error).status === 404) { + yield put({ type: 'setError', payload: ERR_INVALID_MEDIA_ID }); + } else { + yield put({ type: 'setError', payload: ERR_AUTH }); + } + return false; + } + } else { + return false; + } + } + const transcriptions = media.transcriptions; + delete props.media + // delete media.transcriptions; + yield put({ type: 'setEmbeddedMedia', payload: { media, ...props } }) + yield put({ type: 'setTranscriptions', payload: transcriptions }) + }, + ...player_effects, + ...menu_effects, + ...trans_effects, + ...search_effects + }, + subscriptions: { + setup({ dispatch, history }) { + if (!isMobile) { + // document.removeEventListener('fullscreenchange', this.onFullScreenChange, true); + document.addEventListener('fullscreenchange', (e) => { + dispatch({ type: 'onFullScreenChange', payload: e }) + }, true); + if (isMobile) { + window.addEventListener('orientationchange', () => { + // + if ([90, -90].includes(window.orientation)) { + /* NOT IMPLEMENTED + if (that.currTime() > 0) { + that.enterFullScreen(); + } + */ + } + }); + } else { + window.addEventListener('resize', () => { + if (window.innerWidth < 900) { + /* NOT IMPLEMENTED + if (that.SCREEN_MODE === PS_MODE) { + this.dispatch({ type: 'watch/setWatchMode', payload: { mode: NESTED_MODE, config: { sendUserAction: false } } }); + } + */ + } + }); + } + } + history.listen((event) => { + if (event.pathname === '/video' || event.action === 'PUSH' && event.location.pathname === '/video') { + dispatch({ type: 'setupMedia' }); + } + }) + } + } +} export default WatchModel \ No newline at end of file diff --git a/src/screens/Watch/model/trans_effects.js b/src/screens/Watch/model/trans_effects.js index ce29e79a..571fbb2e 100644 --- a/src/screens/Watch/model/trans_effects.js +++ b/src/screens/Watch/model/trans_effects.js @@ -1,324 +1,324 @@ -/* eslint-disable no-console */ -/* eslint-disable complexity */ - -import { api } from 'utils'; -import _ from 'lodash'; -// import { isMobile } from 'react-device-detect'; -import { /* CROWDEDIT_ALLOW, */CROWDEDIT_FREEZE_ALL } from 'utils/constants.js'; -import { promptControl } from '../Utils/prompt.control'; -import { timeStrToSec } from '../Utils/helpers'; - -import { uEvent } from '../Utils/UserEventController'; -import { scrollTransToView, findTransByLanguages } from '../Utils' -/** - * * Find subtitle based on current time -*/ -const findCurrent = (array = [], prev = {}, now = 0, deterFunc) => { - if (!array || array.length === 0) return null; - let next = prev; - const isCurrent = (item) => { - if (!item) return false; // Check the item type - const end = timeStrToSec(item.end); - const begin = timeStrToSec(item.begin); - let deter = true; - if (deterFunc) deter = deterFunc(item, prev); - return begin <= now && now <= end && deter; - }; - - // if it's the first time to find captions - if (!prev) { - next = _.find(array, isCurrent) || null; - - // if looking for caption that is after the current one - } else if (now > timeStrToSec(prev.begin)) { - next = _.find(array, isCurrent, prev.index + 1) || prev; - - // if looking for caption that is prior to the current one - } else if (now < timeStrToSec(prev.end)) { - next = _.findLast(array, isCurrent, prev.index - 1) || prev; - } - - return next; -} - -const findCurrentDescription = (descriptions, currentTime) => { - let closestDescription = null; - let maxEndTime = -Infinity; - - for (const description of descriptions) { - const endTime = timeStrToSec(description.end); - - // Check if the description ends before or at the current time - if (endTime <= currentTime && endTime > maxEndTime) { - maxEndTime = endTime; - closestDescription = description; - } - } - - return closestDescription; -}; - -export default { - - // We have an array of transcript Ids to display, time to get the actual transcripts from the server - *setCurrTrans({ payload: trans }, { all, call, put }) { - // // console.log("Starting setCurrTrans with trans payload:", trans); - - // Ensure trans is an array - if (!Array.isArray(trans)) { - trans = [trans]; - } - // // console.log("Normalized trans array:", trans); - - let alldata; - if (trans.length > 0) { - // // console.log("Fetching captions for each transcription ID..."); - - // Fetch data for each transcription ID - const allTranscriptionData = yield all( - trans.map((tran) => call(api.getCaptionsByTranscriptionId, tran.id)) - ); - // // console.log("Fetched allTranscriptionData:", allTranscriptionData); - - // Attach transcription reference to each caption - allTranscriptionData.forEach((captionList, listIndex) => { - const t = trans[listIndex]; - captionList.data?.forEach((c) => { - c.transcription = t; - // // console.log(`Assigned transcription for caption (ID: ${c.id}):`, c.transcription); - }); - }); - - // Merge all caption data into alldata - alldata = allTranscriptionData.reduce((acc, { data = [] }) => [...acc, ...data], []); - // // console.log("Combined alldata:", alldata); - } - - if (alldata === undefined) { - alldata = []; - } - - // Filter captions by transcription type - let closedcaptions = alldata.filter((c) => c.transcription.transcriptionType === 0); - let descriptions = alldata.filter((c) => c.transcription.transcriptionType !== 0); - // // console.log("Filtered closedcaptions:", closedcaptions); - // // console.log("Filtered descriptions:", descriptions); - - // Dispatch closed captions - yield put({ type: 'setCaptions', payload: closedcaptions }); - - // Dispatch descriptions - const descriptionData = descriptions; - // // console.log("Dispatching descriptionData:", descriptionData); - yield put.resolve({ type: 'setDescriptions', payload: descriptionData }); - - // Dispatch final transcript set - yield put({ type: 'setTranscript' }); - // // console.log("Completed setCurrTrans"); - }, - - *setTranscriptions({ payload: trans }, { put, select }) { - const { playerpref } = yield select(); - let keys = playerpref.transKeys; - if (keys === undefined) { - // No preference, so look for 1 caption and 1 description... - const seen = new Set(); - keys = []; - for (const t of trans) { - if (!seen.has(t.transcriptionType)) { - keys.push(t.transKey); - seen.add(t.transcriptionType); - } - } - // if(keys.length === 0 && trans.length > 0) { - yield put({ - type: 'playerpref/setPreference', payload: { transKeys: keys } - }); - } - for (const t of keys) { - yield put({ - type: 'setCurrentTranscriptionMulti', - payload: { transKey: t, active: true }, - }); - } - }, - *updateTranscript({ payload: currentTime }, { put, select }) { - const { watch, playerpref } = yield select(); - const prevCaption_ = watch.caption; - // TODO: Fix: if watch.transcript is ARRAY_EMPTY (?) or length 0 then findCurrent will - // return with the current transcript. Should put({ type: 'setCurrCaption', payload: null - - // TODO: is index reset in frontend? the findCurrent assumes index are increasing integers - // Ans: Yes in model.js/setTranscript, after filtering wanted transcriptions - - // "transcript= _.map(transcript, (item, index) => ({ ...item, index })); - - - const next = findCurrent(watch.transcript, prevCaption_, currentTime); - if (next && next.id) { - // // console.log(next); - // pause video if it's AD - - // determine whether should scroll smoothly - const smoothScroll = - prevCaption_ && next && Math.abs(prevCaption_.index - next.index) === 1; - yield put({ type: 'setCurrCaption', payload: next }); - - if (playerpref.autoScroll && !watch.mouseOnCaption && !watch.currEditing) { - const { media = {} } = watch; - scrollTransToView(next.id, smoothScroll, media.isTwoScreen); - } - } else { - yield put({ type: 'setCurrCaption', payload: null }); - } - // // console.log(watch) - // // console.log(`pauseWhileAD:${playerpref.pauseWhileAD}`); - const nextDescription = findCurrentDescription(watch.descriptions, currentTime); - if (playerpref.openAD && nextDescription) { - const nextDescriptionBeginTime = timeStrToSec(nextDescription.begin); - if (Math.abs(currentTime - nextDescriptionBeginTime) <= 1) { - if (playerpref.pauseWhileAD) { - yield put({ type: 'media_pause' }); - } - // Speak out loud - // // console.log(`SPEAK ${nextDescription.text}`); - yield put({ type: 'playerpref/setPreference', payload: { description: nextDescription.text } }) - } - } - return next || null; - // transControl.updateTranscript(currentTime); - }, - *setLanguage({ payload: language }, { put, select }) { - const { watch } = yield select(); - const currTrans = findTransByLanguages(watch.transcriptions, [language]); - if (currTrans) { - yield put({ type: 'setCurrTrans', payload: currTrans }); - } - uEvent.langchange(watch.time, language); - uEvent.registerLanguage(language); - }, - *setCurrentTranscriptionMulti(_ignore, { put, select }) { - const { watch } = yield select(); - - const selected = watch.currentTranscriptionMulti.transKeysSelected - - const currTrans = watch.transcriptions.filter((t) => selected.includes(t.transKey)); - - // if (currTrans.length > 0) { - yield put({ type: 'setCurrTrans', payload: currTrans }); - // } - const currKeys = currTrans.map((t) => t.transKey); - // remember preference for next time - yield put({ type: 'playerpref/setPreference', payload: { transKeys: currKeys } }); - // TODO - fix uEvent - // uEvent.langchange(watch.time, language); - // uEvent.registerLanguage(language); - }, - - *setTransEditMode({ payload: { caption } }, { put, select }) { - // if no param caption, edit current caption - const { watch, playerpref } = yield select(); - - const media = watch.media; - const crowdEdit = media.crowdEditMode !== CROWDEDIT_FREEZE_ALL; - if (!crowdEdit) { - return; - } - - const currCap = caption || watch.currCaption_; - yield put({ type: 'setCurrEditing', payload: currCap }); - if (playerpref.pauseWhileEditing) { - yield put({ type: 'media_pause' }); - } - if (playerpref.showCaptionTips) { - promptControl.editCaptionTips(); - yield put({ type: 'playerpref/setPreference', payload: { showCaptionTips: false } }); - } - }, - *timestampFailed({ payload: { caption } }) { - promptControl.timestampFailed(caption.transcription.transcriptionType === 0); - yield; - }, - // This is a transcript caption - *saveCaption({ payload: { caption, text, begin, end } }, { call, put, select }) { - const { watch } = yield select(); - - // console.log("Entering saveCaption with payload:", { caption, text, begin, end }); - // console.log("Current watch state:", watch); - - /** - * @todo check PROFANITY_LIST - */ - /** - * @todo check begin overlap or other timestamp checks - */ - - // currEditing could be missing if captions are frozen - // if (!text || !watch?.currEditing || (watch.currEditing && watch.currEditing.text === text && watch.currEditing.begin === begin)) { - // // console.log("Exiting saveCaption early. Conditions not met."); - // promptControl.closePrompt(); - // return; - // // return this.edit(null); NOT IMPLEMENTED - // } - - if (!text) { - // console.log("Exiting saveCaption early: 'text' is falsy."); - promptControl.closePrompt(); - return; - } - - if (!watch?.currEditing) { - // console.log("Exiting saveCaption early: 'watch.currEditing' is falsy."); - promptControl.closePrompt(); - return; - } - - if (watch.currEditing && watch.currEditing.text === text && watch.currEditing.begin === begin && watch.currEditing.end === end) { - // console.log("Exiting saveCaption early: No changes detected in 'currEditing'."); - promptControl.closePrompt(); - return; - } - - // console.log("Updating caption with text:", text); - caption.text = text; // update data model - caption.begin = begin; - caption.end = end; - promptControl.savingCaption(); // just a ui prompt, empty atm - - const { id } = watch.currEditing; - // console.log("Sending user event with ID:", id, "Current time:", watch.currTime, "Old text:", watch.currEditing.text, "New text:", text); - - const isClosedCaption = caption.transcription.transcriptionType === 0; - // console.log("Is closed caption:", isClosedCaption); - - yield put({ type: 'setCurrEditing', payload: null }); - - try { - // console.log("Calling API to update caption line with data:", { id, text, begin, end }); - yield call(api.updateCaptionLine, { id, text, begin, end }); - - if (isClosedCaption) { - // console.log("Updating closed captions in state."); - yield put({ type: 'setCaptions', payload: watch.captions }); - } else { - // console.log("Updating descriptions in state."); - yield put({ type: 'setDescriptions', payload: watch.descriptions }); - } - // another elif here for chapter breaks eventually - promptControl.savedCaption(isClosedCaption, true); - // console.log("Caption saved successfully."); - } catch (error) { - console.error("Error saving caption:", error); - promptControl.savedCaption(isClosedCaption, false); - } - }, - *setFontSize({ payload: fontSize }, { put, select }) { - const { watch } = yield select(); - if (fontSize == null) { - yield put({ type: 'setFontSize', payload: "normal" }); - } else if (fontSize === watch.fontSize) { - // very good it has changed so stop calling yourself - } else { - yield put({ type: 'setFontSize', payload: fontSize }); - } - }, +/* eslint-disable no-console */ +/* eslint-disable complexity */ + +import { api } from 'utils'; +import _ from 'lodash'; +// import { isMobile } from 'react-device-detect'; +import { /* CROWDEDIT_ALLOW, */CROWDEDIT_FREEZE_ALL } from 'utils/constants.js'; +import { promptControl } from '../Utils/prompt.control'; +import { timeStrToSec } from '../Utils/helpers'; + +import { uEvent } from '../Utils/UserEventController'; +import { scrollTransToView, findTransByLanguages } from '../Utils' +/** + * * Find subtitle based on current time +*/ +const findCurrent = (array = [], prev = {}, now = 0, deterFunc) => { + if (!array || array.length === 0) return null; + let next = prev; + const isCurrent = (item) => { + if (!item) return false; // Check the item type + const end = timeStrToSec(item.end); + const begin = timeStrToSec(item.begin); + let deter = true; + if (deterFunc) deter = deterFunc(item, prev); + return begin <= now && now <= end && deter; + }; + + // if it's the first time to find captions + if (!prev) { + next = _.find(array, isCurrent) || null; + + // if looking for caption that is after the current one + } else if (now > timeStrToSec(prev.begin)) { + next = _.find(array, isCurrent, prev.index + 1) || prev; + + // if looking for caption that is prior to the current one + } else if (now < timeStrToSec(prev.end)) { + next = _.findLast(array, isCurrent, prev.index - 1) || prev; + } + + return next; +} + +const findCurrentDescription = (descriptions, currentTime) => { + let closestDescription = null; + let maxEndTime = -Infinity; + + for (const description of descriptions) { + const endTime = timeStrToSec(description.end); + + // Check if the description ends before or at the current time + if (endTime <= currentTime && endTime > maxEndTime) { + maxEndTime = endTime; + closestDescription = description; + } + } + + return closestDescription; +}; + +export default { + + // We have an array of transcript Ids to display, time to get the actual transcripts from the server + *setCurrTrans({ payload: trans }, { all, call, put }) { + // // console.log("Starting setCurrTrans with trans payload:", trans); + + // Ensure trans is an array + if (!Array.isArray(trans)) { + trans = [trans]; + } + // // console.log("Normalized trans array:", trans); + + let alldata; + if (trans.length > 0) { + // // console.log("Fetching captions for each transcription ID..."); + + // Fetch data for each transcription ID + const allTranscriptionData = yield all( + trans.map((tran) => call(api.getCaptionsByTranscriptionId, tran.id)) + ); + // // console.log("Fetched allTranscriptionData:", allTranscriptionData); + + // Attach transcription reference to each caption + allTranscriptionData.forEach((captionList, listIndex) => { + const t = trans[listIndex]; + captionList.data?.forEach((c) => { + c.transcription = t; + // // console.log(`Assigned transcription for caption (ID: ${c.id}):`, c.transcription); + }); + }); + + // Merge all caption data into alldata + alldata = allTranscriptionData.reduce((acc, { data = [] }) => [...acc, ...data], []); + // // console.log("Combined alldata:", alldata); + } + + if (alldata === undefined) { + alldata = []; + } + + // Filter captions by transcription type + let closedcaptions = alldata.filter((c) => c.transcription.transcriptionType === 0); + let descriptions = alldata.filter((c) => c.transcription.transcriptionType !== 0); + // // console.log("Filtered closedcaptions:", closedcaptions); + // // console.log("Filtered descriptions:", descriptions); + + // Dispatch closed captions + yield put({ type: 'setCaptions', payload: closedcaptions }); + + // Dispatch descriptions + const descriptionData = descriptions; + // // console.log("Dispatching descriptionData:", descriptionData); + yield put.resolve({ type: 'setDescriptions', payload: descriptionData }); + + // Dispatch final transcript set + yield put({ type: 'setTranscript' }); + // // console.log("Completed setCurrTrans"); + }, + + *setTranscriptions({ payload: trans }, { put, select }) { + const { playerpref } = yield select(); + let keys = playerpref.transKeys; + if (keys === undefined) { + // No preference, so look for 1 caption and 1 description... + const seen = new Set(); + keys = []; + for (const t of trans) { + if (!seen.has(t.transcriptionType)) { + keys.push(t.transKey); + seen.add(t.transcriptionType); + } + } + // if(keys.length === 0 && trans.length > 0) { + yield put({ + type: 'playerpref/setPreference', payload: { transKeys: keys } + }); + } + for (const t of keys) { + yield put({ + type: 'setCurrentTranscriptionMulti', + payload: { transKey: t, active: true }, + }); + } + }, + *updateTranscript({ payload: currentTime }, { put, select }) { + const { watch, playerpref } = yield select(); + const prevCaption_ = watch.caption; + // TODO: Fix: if watch.transcript is ARRAY_EMPTY (?) or length 0 then findCurrent will + // return with the current transcript. Should put({ type: 'setCurrCaption', payload: null + + // TODO: is index reset in frontend? the findCurrent assumes index are increasing integers + // Ans: Yes in model.js/setTranscript, after filtering wanted transcriptions - + // "transcript= _.map(transcript, (item, index) => ({ ...item, index })); + + + const next = findCurrent(watch.transcript, prevCaption_, currentTime); + if (next && next.id) { + // // console.log(next); + // pause video if it's AD + + // determine whether should scroll smoothly + const smoothScroll = + prevCaption_ && next && Math.abs(prevCaption_.index - next.index) === 1; + yield put({ type: 'setCurrCaption', payload: next }); + + if (playerpref.autoScroll && !watch.mouseOnCaption && !watch.currEditing) { + const { media = {} } = watch; + scrollTransToView(next.id, smoothScroll, media.isTwoScreen); + } + } else { + yield put({ type: 'setCurrCaption', payload: null }); + } + // // console.log(watch) + // // console.log(`pauseWhileAD:${playerpref.pauseWhileAD}`); + const nextDescription = findCurrentDescription(watch.descriptions, currentTime); + if (playerpref.openAD && nextDescription) { + const nextDescriptionBeginTime = timeStrToSec(nextDescription.begin); + if (Math.abs(currentTime - nextDescriptionBeginTime) <= 1) { + if (playerpref.pauseWhileAD) { + yield put({ type: 'media_pause' }); + } + // Speak out loud + // // console.log(`SPEAK ${nextDescription.text}`); + yield put({ type: 'playerpref/setPreference', payload: { description: nextDescription.text } }) + } + } + return next || null; + // transControl.updateTranscript(currentTime); + }, + *setLanguage({ payload: language }, { put, select }) { + const { watch } = yield select(); + const currTrans = findTransByLanguages(watch.transcriptions, [language]); + if (currTrans) { + yield put({ type: 'setCurrTrans', payload: currTrans }); + } + uEvent.langchange(watch.time, language); + uEvent.registerLanguage(language); + }, + *setCurrentTranscriptionMulti(_ignore, { put, select }) { + const { watch } = yield select(); + + const selected = watch.currentTranscriptionMulti.transKeysSelected + + const currTrans = watch.transcriptions.filter((t) => selected.includes(t.transKey)); + + // if (currTrans.length > 0) { + yield put({ type: 'setCurrTrans', payload: currTrans }); + // } + const currKeys = currTrans.map((t) => t.transKey); + // remember preference for next time + yield put({ type: 'playerpref/setPreference', payload: { transKeys: currKeys } }); + // TODO - fix uEvent + // uEvent.langchange(watch.time, language); + // uEvent.registerLanguage(language); + }, + + *setTransEditMode({ payload: { caption } }, { put, select }) { + // if no param caption, edit current caption + const { watch, playerpref } = yield select(); + + const media = watch.media; + const crowdEdit = media.crowdEditMode !== CROWDEDIT_FREEZE_ALL; + if (!crowdEdit) { + return; + } + + const currCap = caption || watch.currCaption_; + yield put({ type: 'setCurrEditing', payload: currCap }); + if (playerpref.pauseWhileEditing) { + yield put({ type: 'media_pause' }); + } + if (playerpref.showCaptionTips) { + promptControl.editCaptionTips(); + yield put({ type: 'playerpref/setPreference', payload: { showCaptionTips: false } }); + } + }, + *timestampFailed({ payload: { caption } }) { + promptControl.timestampFailed(caption.transcription.transcriptionType === 0); + yield; + }, + // This is a transcript caption + *saveCaption({ payload: { caption, text, begin, end } }, { call, put, select }) { + const { watch } = yield select(); + + // console.log("Entering saveCaption with payload:", { caption, text, begin, end }); + // console.log("Current watch state:", watch); + + /** + * @todo check PROFANITY_LIST + */ + /** + * @todo check begin overlap or other timestamp checks + */ + + // currEditing could be missing if captions are frozen + // if (!text || !watch?.currEditing || (watch.currEditing && watch.currEditing.text === text && watch.currEditing.begin === begin)) { + // // console.log("Exiting saveCaption early. Conditions not met."); + // promptControl.closePrompt(); + // return; + // // return this.edit(null); NOT IMPLEMENTED + // } + + if (!text) { + // console.log("Exiting saveCaption early: 'text' is falsy."); + promptControl.closePrompt(); + return; + } + + if (!watch?.currEditing) { + // console.log("Exiting saveCaption early: 'watch.currEditing' is falsy."); + promptControl.closePrompt(); + return; + } + + if (watch.currEditing && watch.currEditing.text === text && watch.currEditing.begin === begin && watch.currEditing.end === end) { + // console.log("Exiting saveCaption early: No changes detected in 'currEditing'."); + promptControl.closePrompt(); + return; + } + + // console.log("Updating caption with text:", text); + caption.text = text; // update data model + caption.begin = begin; + caption.end = end; + promptControl.savingCaption(); // just a ui prompt, empty atm + + const { id } = watch.currEditing; + // console.log("Sending user event with ID:", id, "Current time:", watch.currTime, "Old text:", watch.currEditing.text, "New text:", text); + + const isClosedCaption = caption.transcription.transcriptionType === 0; + // console.log("Is closed caption:", isClosedCaption); + + yield put({ type: 'setCurrEditing', payload: null }); + + try { + // console.log("Calling API to update caption line with data:", { id, text, begin, end }); + yield call(api.updateCaptionLine, { id, text, begin, end }); + + if (isClosedCaption) { + // console.log("Updating closed captions in state."); + yield put({ type: 'setCaptions', payload: watch.captions }); + } else { + // console.log("Updating descriptions in state."); + yield put({ type: 'setDescriptions', payload: watch.descriptions }); + } + // another elif here for chapter breaks eventually + promptControl.savedCaption(isClosedCaption, true); + // console.log("Caption saved successfully."); + } catch (error) { + console.error("Error saving caption:", error); + promptControl.savedCaption(isClosedCaption, false); + } + }, + *setFontSize({ payload: fontSize }, { put, select }) { + const { watch } = yield select(); + if (fontSize == null) { + yield put({ type: 'setFontSize', payload: "normal" }); + } else if (fontSize === watch.fontSize) { + // very good it has changed so stop calling yourself + } else { + yield put({ type: 'setFontSize', payload: fontSize }); + } + }, } \ No newline at end of file diff --git a/src/utils/cthttp/entities/Captions.js b/src/utils/cthttp/entities/Captions.js index 6e4aab1b..7ef27a3b 100644 --- a/src/utils/cthttp/entities/Captions.js +++ b/src/utils/cthttp/entities/Captions.js @@ -1,87 +1,87 @@ -import { cthttp } from '../request'; - -// const qs = require('qs') - -// ------------------------------------------------------------ -// Captions -// ------------------------------------------------------------ - -// GET - -export function getTranscriptionFile(transcriptionId,format) { - // {vtt,srt,txt} - return cthttp.get(`Captions/TranscriptionFile/${transcriptionId}/${format}`); -} - - -export function getCaptionsByTranscriptionId(transcriptionId) { - return cthttp.get(`Captions/ByTranscription/${transcriptionId}`); -} - -export function getCaptionLine(transcriptionId, index) { - return cthttp.get('Captions', { params: { transcriptionId, index } }); -} - -export function searchCaptionInOffering(offeringId, query, filterLanguage = 'en-US') { - return cthttp.get('Captions/SearchInOffering', { params: { offeringId, query, filterLanguage } }); -} - -// POST - -export function updateCaptionLine(data) { - // eslint-disable-next-line no-console - console.log("Preparing to update caption line with data:", data); - - // Check if all required fields are present - if (!data.id || !data.text || !data.begin || !data.end) { - // eslint-disable-next-line no-console - console.error("Missing required data fields:", data); - throw new Error("Required data fields are missing."); - } - - return cthttp.post('Captions', { - id: data.id, - text: data.text, - begin: data.begin, - end: data.end - }).then(response => { - // eslint-disable-next-line no-console - console.log("Caption line updated successfully:", response); - return response; - }).catch(error => { - // eslint-disable-next-line no-console - console.error("Error updating caption line:", error); - throw error; // Re-throw to handle it upstream if necessary - }); -} - - -export function searchCaptions(transList, data) { - return cthttp.post('CaptionsSearch', - transList - , { params: { query: data.text, page: data.page, pageSize: data.pageSize } }); -} - -// ------------------------------------------------------------ -// Vote -// ------------------------------------------------------------ - -export function captionUpVote(id) { - // captionId - return cthttp.post('Captions/UpVote', null, { params: { id } }); -} - -export function captionCancelUpVote(id) { - // captionId - return cthttp.post('Captions/CancelUpVote', null, { params: { id } }); -} - -export function captionDownVote(id) { - // captionId - return cthttp.post('Captions/DownVote', null, { params: { id } }); -} - -export function captionCancelDownVote(id) { - // captionId - return cthttp.post('Captions/CancelDownVote', null, { params: { id } }); -} +import { cthttp } from '../request'; + +// const qs = require('qs') + +// ------------------------------------------------------------ +// Captions +// ------------------------------------------------------------ + +// GET + +export function getTranscriptionFile(transcriptionId,format) { + // {vtt,srt,txt} + return cthttp.get(`Captions/TranscriptionFile/${transcriptionId}/${format}`); +} + + +export function getCaptionsByTranscriptionId(transcriptionId) { + return cthttp.get(`Captions/ByTranscription/${transcriptionId}`); +} + +export function getCaptionLine(transcriptionId, index) { + return cthttp.get('Captions', { params: { transcriptionId, index } }); +} + +export function searchCaptionInOffering(offeringId, query, filterLanguage = 'en-US') { + return cthttp.get('Captions/SearchInOffering', { params: { offeringId, query, filterLanguage } }); +} + +// POST + +export function updateCaptionLine(data) { + // eslint-disable-next-line no-console + console.log("Preparing to update caption line with data:", data); + + // Check if all required fields are present + if (!data.id || !data.text || !data.begin || !data.end) { + // eslint-disable-next-line no-console + console.error("Missing required data fields:", data); + throw new Error("Required data fields are missing."); + } + + return cthttp.post('Captions', { + id: data.id, + text: data.text, + begin: data.begin, + end: data.end + }).then(response => { + // eslint-disable-next-line no-console + console.log("Caption line updated successfully:", response); + return response; + }).catch(error => { + // eslint-disable-next-line no-console + console.error("Error updating caption line:", error); + throw error; // Re-throw to handle it upstream if necessary + }); +} + + +export function searchCaptions(transList, data) { + return cthttp.post('CaptionsSearch', + transList + , { params: { query: data.text, page: data.page, pageSize: data.pageSize } }); +} + +// ------------------------------------------------------------ +// Vote +// ------------------------------------------------------------ + +export function captionUpVote(id) { + // captionId + return cthttp.post('Captions/UpVote', null, { params: { id } }); +} + +export function captionCancelUpVote(id) { + // captionId + return cthttp.post('Captions/CancelUpVote', null, { params: { id } }); +} + +export function captionDownVote(id) { + // captionId + return cthttp.post('Captions/DownVote', null, { params: { id } }); +} + +export function captionCancelDownVote(id) { + // captionId + return cthttp.post('Captions/CancelDownVote', null, { params: { id } }); +}