Skip to content

Commit 0e8e8df

Browse files
Come-PeyrelongueLeMouj
authored andcommitted
FEATURE: Split a video should be interactive (fixes #366).
1 parent bd12706 commit 0e8e8df

8 files changed

Lines changed: 248 additions & 56 deletions

File tree

frontend/src/components/EditableText.jsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import '../styles/EditableText.css';
22

3-
import { useState, useEffect, useCallback } from 'react';
3+
import { useState, useEffect, useCallback, useContext } from 'react';
44
import FormattedText from './FormattedText';
55
import DiscreeteDropdown from './DiscreeteDropdown';
66
import PictureUploadAction from '../menu-items/PictureUploadAction';
77
import {v4 as uuid} from 'uuid';
88
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
9+
import { SegmentContext } from './SegmentContext';
910

10-
function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment, setHighlightedText, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate}) {
11+
function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment, setHighlightedText, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate, isSegmentReceiver}) {
1112
const [beingEdited, setBeingEdited] = useState(false);
1213
const [editedDocument, setEditedDocument] = useState();
1314
const [editedText, setEditedText] = useState();
1415
const [hasBeenChanged, setHasBeenChanged] = useState(false);
16+
const { segmentTimecode, setSegmentTimecode } = useContext(SegmentContext);
1517
const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`);
1618

1719
let parsePassage = (rawText) => (rubric)
@@ -46,6 +48,19 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
4648
}
4749
}, [fragment, parseFirstPassage, setFragment, updateEditedDocument]);
4850

51+
useEffect(() => {
52+
if (segmentTimecode && isSegmentReceiver) {
53+
updateEditedDocument()
54+
.then((x) => {
55+
let existingText = parseFirstPassage(x.text);
56+
setEditedText((existingText && `${existingText}\n\n`) + segmentTimecode);
57+
setBeingEdited(true);
58+
setSegmentTimecode(null);
59+
setHasBeenChanged(true);
60+
});
61+
}
62+
}, [segmentTimecode, isSegmentReceiver, parseFirstPassage, setSegmentTimecode, updateEditedDocument]);
63+
4964
useEffect(() => {
5065
if (rawEditMode) {
5166
updateEditedDocument()
@@ -128,4 +143,4 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
128143
);
129144
}
130145

131-
export default EditableText;
146+
export default EditableText;

frontend/src/components/FormattedText.jsx

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { remarkDefinitionList, defListHastHandlers } from 'remark-definition-lis
55
import CroppedImage from './CroppedImage';
66
import VideoComment from './VideoComment';
77
import FragmentComment from './FragmentComment';
8+
import VideoPlayer from './VideoPlayer';
89

9-
function FormattedText({children, setHighlightedText, selectable, setSelectedText}) {
10+
function FormattedText({children, setHighlightedText, selectable, setSelectedText, showSegmentControls}) {
1011

1112
const handleMouseUp = () => {
1213
if (selectable) {
@@ -16,11 +17,23 @@ function FormattedText({children, setHighlightedText, selectable, setSelectedTex
1617
}
1718
};
1819

20+
function getVideoId(src) {
21+
const regExp = /^.*(?:youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]{11})/;
22+
const match = src.match(regExp);
23+
return match ? match[1] : null;
24+
}
25+
1926
return (<>
2027
<ReactMarkdown
2128
remarkPlugins={[remarkGfm, remarkDefinitionList, remarkUnwrapImages]}
2229
components={{
23-
img: (x) => embedVideo(x) || CroppedImage(x),
30+
img: (x) => {
31+
let videoId = getVideoId(x.src);
32+
if (videoId) {
33+
return <VideoPlayer videoId={videoId} showSegmentControls={showSegmentControls} />;
34+
}
35+
return CroppedImage(x);
36+
},
2437
p: (x) => VideoComment(x)
2538
|| FragmentComment({...x, setHighlightedText})
2639
|| <p onMouseUp={handleMouseUp}>{x.children}</p>,
@@ -35,21 +48,4 @@ function FormattedText({children, setHighlightedText, selectable, setSelectedTex
3548
</>);
3649
}
3750

38-
function getId(text) {
39-
const regExp = /^.*(?:youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]{11})/;
40-
const match = text.match(regExp);
41-
return match ? match[1] : null;
42-
}
43-
44-
function embedVideo({src}) {
45-
const videoId = getId(src);
46-
if (videoId) {
47-
const embedLink = `https://www.youtube.com/embed/${videoId}`;
48-
return (
49-
<iframe width="80%" height="300" src={embedLink} frameBorder="0" allowFullScreen></iframe>
50-
);
51-
}
52-
return null;
53-
}
54-
55-
export default FormattedText;
51+
export default FormattedText;

frontend/src/components/Passage.jsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import '../styles/Passage.css';
22

3-
import { useState } from 'react';
3+
import { useState, useEffect, useContext } from 'react';
44
import Container from 'react-bootstrap/Container';
55
import Row from 'react-bootstrap/Row';
66
import Col from 'react-bootstrap/Col';
@@ -9,12 +9,25 @@ import FormattedText from './FormattedText';
99
import EditableText from '../components/EditableText';
1010
import DiscreeteDropdown from './DiscreeteDropdown';
1111
import CommentFragmentAction from '../menu-items/CommentFragmentAction';
12+
import { SegmentContext } from './SegmentContext';
1213

1314
function Passage({source, rubric, scholia, margin, sourceId, isComposite, rawEditMode, setRawEditMode, backend, setLastUpdate}) {
1415
const [selectedText, setSelectedText] = useState();
1516
const [highlightedText, setHighlightedText] = useState('');
1617
const [fragment, setFragment] = useState();
1718
const isFromScratch = margin === sourceId;
19+
const { segmentTimecode, setSegmentTimecode } = useContext(SegmentContext);
20+
21+
const hasVideo = source.some(
22+
chunk => /(?:youtube\.com\/watch\?v=|youtu\.be\/)/.test(chunk)
23+
);
24+
25+
useEffect(() => {
26+
if (segmentTimecode && hasVideo) {
27+
setFragment(segmentTimecode + '\n\n');
28+
setSegmentTimecode(null);
29+
}
30+
}, [segmentTimecode, hasVideo, setFragment, setSegmentTimecode]);
1831

1932
scholia = scholia.filter(x => (x.isPartOf === margin));
2033
if (!scholia.length) {
@@ -74,7 +87,7 @@ function PassageSource({children, isComposite, highlightedText, setHighlightedTe
7487
function SelectableFormattedText({children, highlightedText, setHighlightedText, setSelectedText}) {
7588
return (
7689
<Marker mark={highlightedText} options={({separateWordSearch: false})}>
77-
<FormattedText selectable="true" {...{setSelectedText, setHighlightedText}}>
90+
<FormattedText selectable="true" showSegmentControls={true} {...{setSelectedText, setHighlightedText}}>
7891
{children}
7992
</FormattedText>
8093
</Marker>
@@ -98,4 +111,4 @@ function PassageMargin({active, scholia, setHighlightedText, fragment, setFragme
98111
);
99112
}
100113

101-
export default Passage;
114+
export default Passage;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createContext } from 'react';
2+
3+
export const SegmentContext = createContext({});

frontend/src/components/VideoComment.jsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,39 @@ function VideoComment({ children }) {
1717
}
1818
className="videoComment"
1919
>
20-
{children[0]}
20+
{children[0].replace(/ @\S+$/, '')}
2121
</p>
2222
</OverlayTrigger>
2323
);
2424

25-
function playVideoAt(timecode) {
26-
let [start, end] = timecode.split('-->');
25+
function playVideoAt(timecodeString) {
26+
let videoIdMatch = timecodeString.match(/@(\S+)/);
27+
let targetVideoId = videoIdMatch ? videoIdMatch[1] : null;
28+
29+
let [start, end] = timecodeString.split('-->');
2730
let [hour, min, sec] = start.split(/[:.]/);
2831
let startTime = Number(hour * 3600) + Number(min * 60) + Number(sec);
2932
[hour, min, sec] = end.split(/[:.]/);
3033
let endTime = Number(hour * 3600) + Number(min * 60) + Number(sec);
31-
let iframe = document.getElementsByTagName('iframe');
32-
if (iframe.length != 0) {
33-
let youTubeLink = new URL(iframe[0].src);
34-
let youTubeBaseLink = youTubeLink.origin + youTubeLink.pathname;
35-
let targetLink = `${youTubeBaseLink}?start=${startTime}&end=${endTime}&autoplay=1&mute=1`;
36-
iframe[0].src = targetLink;
34+
35+
let iframes = document.getElementsByTagName('iframe');
36+
let iframe;
37+
38+
if (targetVideoId) {
39+
iframe = Array.from(iframes).find(
40+
f => f.getAttribute('data-video-id') === targetVideoId
41+
);
42+
}
43+
if (!iframe && iframes.length !== 0) {
44+
iframe = iframes[0];
45+
}
46+
47+
if (iframe) {
48+
let videoIdForUrl = targetVideoId || new URL(iframe.src).pathname.split('/').pop();
49+
let targetLink = `https://www.youtube.com/embed/${videoIdForUrl}?start=${startTime}&end=${endTime}&autoplay=1&mute=1`;
50+
iframe.src = targetLink;
3751
}
3852
}
3953
}
4054

41-
export default VideoComment;
55+
export default VideoComment;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { useState, useEffect, useRef, useContext } from 'react';
2+
import { SegmentContext } from './SegmentContext';
3+
4+
let apiLoaded = false;
5+
let apiCallbacks = [];
6+
7+
function ensureYouTubeAPI() {
8+
if (window.YT && window.YT.Player) {
9+
apiLoaded = true;
10+
return;
11+
}
12+
if (document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) return;
13+
let script = document.createElement('script');
14+
script.src = 'https://www.youtube.com/iframe_api';
15+
document.head.appendChild(script);
16+
window.onYouTubeIframeAPIReady = () => {
17+
apiLoaded = true;
18+
apiCallbacks.forEach(cb => cb());
19+
apiCallbacks = [];
20+
};
21+
}
22+
23+
function onAPIReady(cb) {
24+
if (apiLoaded) {
25+
cb();
26+
} else {
27+
apiCallbacks.push(cb);
28+
}
29+
}
30+
31+
function formatTimecode(seconds) {
32+
let h = Math.floor(seconds / 3600);
33+
let m = Math.floor((seconds % 3600) / 60);
34+
let s = Math.floor(seconds % 60);
35+
let ms = Math.round((seconds % 1) * 1000);
36+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`;
37+
}
38+
39+
function VideoPlayer({ videoId, showSegmentControls }) {
40+
let containerRef = useRef(null);
41+
let playerRef = useRef(null);
42+
let [ready, setReady] = useState(false);
43+
let [state, setState] = useState('idle');
44+
let [startTimecode, setStartTimecode] = useState(null);
45+
let { showSegmentButton, setSegmentTimecode } = useContext(SegmentContext);
46+
47+
useEffect(() => {
48+
let mounted = true;
49+
let container = containerRef.current;
50+
51+
let playerDiv = document.createElement('div');
52+
container.appendChild(playerDiv);
53+
54+
ensureYouTubeAPI();
55+
onAPIReady(() => {
56+
if (!mounted) return;
57+
playerRef.current = new window.YT.Player(playerDiv, {
58+
videoId,
59+
width: '100%',
60+
height: '300',
61+
events: {
62+
onReady: () => {
63+
if (!mounted) return;
64+
let iframe = playerRef.current.getIframe();
65+
iframe.setAttribute('data-video-id', videoId);
66+
setReady(true);
67+
}
68+
}
69+
});
70+
});
71+
72+
return () => {
73+
mounted = false;
74+
if (playerRef.current && playerRef.current.destroy) {
75+
playerRef.current.destroy();
76+
playerRef.current = null;
77+
}
78+
while (container.firstChild) {
79+
container.removeChild(container.firstChild);
80+
}
81+
setReady(false);
82+
};
83+
}, [videoId]);
84+
85+
let handleClick = () => {
86+
if (!ready || !playerRef.current) return;
87+
let current = formatTimecode(playerRef.current.getCurrentTime());
88+
89+
if (state === 'idle') {
90+
setStartTimecode(current);
91+
setState('start_captured');
92+
} else {
93+
setSegmentTimecode(`${startTimecode} --> ${current} @${videoId}`);
94+
setState('idle');
95+
setStartTimecode(null);
96+
}
97+
};
98+
99+
let handleCancel = () => {
100+
setState('idle');
101+
setStartTimecode(null);
102+
};
103+
104+
let shouldShowButton = showSegmentButton && showSegmentControls;
105+
106+
return (
107+
<div className="video-player-container">
108+
<div ref={containerRef}/>
109+
{shouldShowButton && (
110+
<div className="segment-selector justify-content-end">
111+
<button
112+
className={`btn btn-sm ${state === 'idle' ? 'btn-outline-danger' : 'btn-warning'}`}
113+
onClick={handleClick}
114+
disabled={!ready}
115+
>
116+
{!ready
117+
? 'Loading the player…'
118+
: state === 'idle'
119+
? 'Define segment start'
120+
: `Define segment end (start: ${startTimecode})`
121+
}
122+
</button>
123+
{state === 'start_captured' && (
124+
<button
125+
className="btn btn-sm btn-outline-danger ms-2"
126+
onClick={handleCancel}
127+
>
128+
Cancel
129+
</button>
130+
)}
131+
</div>
132+
)}
133+
</div>
134+
);
135+
}
136+
137+
export default VideoPlayer;

0 commit comments

Comments
 (0)