Skip to content

Commit

Permalink
feat: Transcript
Browse files Browse the repository at this point in the history
  • Loading branch information
Betree committed Jun 20, 2024
1 parent fe68439 commit 7e4299d
Show file tree
Hide file tree
Showing 15 changed files with 448 additions and 29 deletions.
15 changes: 14 additions & 1 deletion app/components/Statements/StatementsList.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import memoizeOne from 'memoize-one'
import React from 'react'
import FlipMove from 'react-flip-move'
import { withNamespaces } from 'react-i18next'
Expand All @@ -16,6 +17,8 @@ import { StatementForm } from './StatementForm'
speakers: state.VideoDebate.video.data.speakers,
statements: state.VideoDebate.statements.data,
statementFormSpeakerId: statementFormValueSelector(state, 'speaker_id'),
statementFormText: statementFormValueSelector(state, 'text'),
statementFormTime: statementFormValueSelector(state, 'time'),
offset: state.VideoDebate.video.offset,
}),
{ closeStatementForm, postStatement, setScrollTo },
Expand Down Expand Up @@ -45,6 +48,12 @@ export default class StatementsList extends React.PureComponent {
}
}

getInitialValues = memoizeOne((speakerId, text, time) => ({
speaker_id: speakerId,
text,
time,
}))

render() {
const { speakers, statementFormSpeakerId, statements, offset } = this.props
const speakerId =
Expand All @@ -54,7 +63,11 @@ export default class StatementsList extends React.PureComponent {
{statementFormSpeakerId !== undefined && (
<StatementForm
offset={offset}
initialValues={{ speaker_id: speakerId }}
initialValues={this.getInitialValues(
speakerId,
this.props.statementFormText,
this.props.statementFormTime,
)}
enableReinitialize
keepDirtyOnReinitialize
handleAbort={() => this.props.closeStatementForm()}
Expand Down
12 changes: 11 additions & 1 deletion app/components/StyledUtils/Text.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import styled from 'styled-components'
import { color, display, fontSize, fontStyle, fontWeight, space, textAlign } from 'styled-system'
import {
color,
display,
fontSize,
fontStyle,
fontWeight,
lineHeight,
space,
textAlign,
} from 'styled-system'

export const Span = styled.span`
${color}
Expand All @@ -25,4 +34,5 @@ export const P = styled.p`
${space}
${display}
${textAlign}
${lineHeight}
`
45 changes: 34 additions & 11 deletions app/components/VideoDebate/ActionBubbleMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'

import { destroyStatementForm } from '../../state/video_debate/statements/effects'
import { changeStatementFormSpeaker } from '../../state/video_debate/statements/reducer'
import { changeStatementForm } from '../../state/video_debate/statements/reducer'
import { hasStatementForm } from '../../state/video_debate/statements/selectors'
import { forcePosition, setPlaying } from '../../state/video_debate/video/reducer'
import { withLoggedInUser } from '../LoggedInUser/UserProvider'
import { Icon } from '../Utils/Icon'

Expand All @@ -17,30 +18,45 @@ import { Icon } from '../Utils/Icon'
hasStatementForm: hasStatementForm(state),
}),
{
changeStatementFormSpeaker,
changeStatementForm,
destroyStatementForm,
forcePosition,
setPlaying,
},
)
@withNamespaces('videoDebate')
@withRouter
@withLoggedInUser
export default class ActionBubbleMenu extends React.PureComponent {
render() {
const { t, hasStatementForm, isAuthenticated } = this.props

const { t, hasStatementForm, isAuthenticated, hidden } = this.props
return (
<div
className={classNames('action-bubble-container', {
hasForm: hasStatementForm,
hiddenBelow: hidden,
})}
>
{isAuthenticated ? (
<ActionBubble
iconName={hasStatementForm ? 'times' : 'commenting-o'}
label={t(hasStatementForm ? 'statement.abortAdd' : 'statement.add')}
activated={!hasStatementForm}
onClick={() => this.onStatementBubbleClick()}
/>
<React.Fragment>
<ActionBubble
iconName={hasStatementForm ? 'times' : 'commenting-o'}
label={t(hasStatementForm ? 'statement.abortAdd' : 'statement.add')}
activated={!hasStatementForm}
onClick={() => !hidden && this.onStatementBubbleClick()}
/>
{!isNaN(this.props.playSelectionPosition) && (
<ActionBubble
iconName="play-circle"
label="Play selection"
activated
onClick={() => {
this.props.setPlaying(true)
this.props.forcePosition(this.props.playSelectionPosition)
}}
/>
)}
</React.Fragment>
) : (
<ActionBubble
iconName="sign-in"
Expand All @@ -56,7 +72,14 @@ export default class ActionBubbleMenu extends React.PureComponent {
if (this.props.hasStatementForm) {
this.props.destroyStatementForm()
} else {
this.props.changeStatementFormSpeaker({ id: 0 })
const subPathRegex = new RegExp('/videos/(.+)/(captions|transcript)/?')
const match = subPathRegex.exec(location.pathname)
if (match) {
this.props.history.push(`/videos/${match[1]}`)
}

const values = this.props.getStatementInitialValues?.() || {}
this.props.changeStatementForm({ speaker_id: 0, ...values })
}
}
}
Expand Down
241 changes: 241 additions & 0 deletions app/components/VideoDebate/CaptionsExtractor.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { gql, useQuery } from '@apollo/client'
import { debounce } from 'lodash'
import React from 'react'
import { withNamespaces } from 'react-i18next'
import { usePopper } from 'react-popper'
import { connect } from 'react-redux'
import styled, { css } from 'styled-components'

import Statement from '../Statements/Statement'
import Container from '../StyledUtils/Container'
import { P } from '../StyledUtils/Text'
import UnstyledButton from '../StyledUtils/UnstyledButton'
import { Icon, LoadingFrame } from '../Utils'
import Message from '../Utils/Message'
import ActionBubbleMenu from './ActionBubbleMenu'

const captionsQuery = gql`
query VideoCaptionsQuery($videoId: ID!) {
video(hashId: $videoId) {
id
captions {
text
start
duration
}
}
}
`

const CaptionText = styled.span`
color: #000;
transition:
color 0.3s,
text-shadow 0.3s;
${({ $isCurrent, $isPlaying, $isPast }) => {
if ($isPlaying) {
if ($isCurrent) {
return css`
text-shadow: #9f9f9f 1px 1px 0px;
color: #000;
`
} else if (!$isPast) {
return css`
color: #999;
`
}
}
}}
`

// A statement is displayed before each caption whe
const getStatementsAtPosition = (statements, caption, nextCaption) => {
const selected = []
for (const statement of statements) {
if (statement.time >= caption.start) {
if (!nextCaption || statement.time < nextCaption.start) {
selected.push(statement)
} else {
break
}
}
}

return selected
}

const Arrow = styled.div`
visibility: hidden;
&,
&::before {
position: absolute;
width: 8px;
height: 8px;
background: white;
}
&::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
`

const StatementTooltip = styled.div`
background: white;
border: 1px solid #ccc;
border-radius: 8px;
padding: 8px;
font-size: 12px;
&[data-popper-placement^='top'] ${Arrow} {
bottom: -4px;
}
&[data-popper-placement^='bottom'] ${Arrow} {
top: -4px;
}
&[data-popper-placement^='left'] ${Arrow} {
right: -4px;
}
&[data-popper-placement^='right'] ${Arrow} {
left: -4px;
}
`

const StatementIconButton = styled(UnstyledButton)`
background: white;
border: 1px solid #ccc;
border-radius: 50%;
padding: 4px;
margin-right: 4px;
cursor: pointer;
width: 34px;
height: 34px;
&:hover {
background: #f9f9f9;
}
`

const StatementIndicator = ({ statement }) => {
const [referenceElement, setReferenceElement] = React.useState(null)
const [popperElement, setPopperElement] = React.useState(null)
const [arrowElement, setArrowElement] = React.useState(null)
const [show, setShow] = React.useState(false)
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top',
modifiers: [
{ name: 'arrow', options: { element: arrowElement } },
{ name: 'offset', options: { offset: [0, 8] } },
{ name: 'flip', options: { fallbackPlacements: ['bottom'] } },
{ name: 'preventOverflow', options: { padding: 8 } },
],
})

return (
<>
<StatementIconButton
ref={setReferenceElement}
onFocus={() => setShow(true)}
onBlur={() => setShow(false)}
>
<Icon name="commenting-o" size="small" className="has-text-primary" />
</StatementIconButton>

{show && (
<StatementTooltip ref={setPopperElement} style={styles.popper} {...attributes.popper}>
<Statement statement={statement} speaker={statement.speaker} withoutActions />
<Arrow ref={setArrowElement} style={styles.arrow} />
</StatementTooltip>
)}
</>
)
}

const CaptionsExtractor = ({ t, videoId, playbackPosition, statements }) => {
const { data, loading, error } = useQuery(captionsQuery, { variables: { videoId } })
const [selection, setSelection] = React.useState({ text: null })
const textContainerRef = React.useRef()

// Watch for selection changes
React.useEffect(() => {
const watchForSelectionChange = debounce(() => {
if (!textContainerRef.current) {
return
}

const selection = document.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
if (textContainerRef.current.contains(range.commonAncestorContainer)) {
const firstNode = range.startContainer.parentNode
setSelection({
text: selection.toString(),
start: parseFloat(firstNode.getAttribute('data-start')) || 0,
})
}
}
}, 100)

document.addEventListener('selectionchange', watchForSelectionChange)
return () => {
document.removeEventListener('selectionchange', watchForSelectionChange)
}
}, [])

if (loading) {
return <LoadingFrame title={t('loading.captions')} />
} else if (error) {
// eslint-disable-next-line no-console
console.error(error)
return <Message type="danger">{t('captions.errorLoading')}</Message>
} else if (!data.video.captions?.length) {
return <Message type="info">{t('captions.notFound')}</Message>
}

return (
<Container position="relative" id="ajfkdlsjfsd">
<P fontSize="20px" lineHeight="1.5" ref={textContainerRef}>
{data.video.captions.map((caption, index) => {
const nextCaption = data.video.captions[index + 1]
const statementsAtPosition = getStatementsAtPosition(statements, caption, nextCaption)
return (
<React.Fragment key={index}>
{statementsAtPosition.map((statement) => (
<StatementIndicator key={statement.id} statement={statement} />
))}
<CaptionText
data-start={caption.start}
$isPlaying={Boolean(playbackPosition)}
$isPast={playbackPosition > caption.start + caption.duration}
$isCurrent={
playbackPosition >= caption.start &&
playbackPosition <= caption.start + caption.duration
}
>
{caption.text}
</CaptionText>{' '}
</React.Fragment>
)
})}
</P>

<ActionBubbleMenu
hidden={!selection.text}
playSelectionPosition={selection.start}
getStatementInitialValues={() => {
return {
text: selection.text,
time: Math.floor(selection.start),
}
}}
/>
</Container>
)
}

export default connect((state) => ({
statements: state.VideoDebate.statements.data,
playbackPosition: state.VideoDebate.video.playback.position,
}))(withNamespaces('videoDebate')(CaptionsExtractor))
Loading

0 comments on commit 7e4299d

Please sign in to comment.