Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/scenarios/comment_fragment.feature
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,14 @@ Scénario: de texte
[plusieurs personnes se présentent à moi. Ayant identifié que je suis nouveau, elles me souhaitent la bienvenue]
"""

Scénario: de vidéo

Soit "Vidéo Sherlock Jr. (Buster Keaton)" le document principal
Et avec un document reconnaissable dont je suis l'auteur affiché comme glose
Et une session active avec mon compte
Quand je sélectionne le fragment de vidéo de "00:03:09.000" à "00:03:15.000"
Alors la glose est ouverte en mode édition et contient :
"""
00:03:09.000 --> 00:03:15.000
"""
21 changes: 18 additions & 3 deletions frontend/src/components/EditableText.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import '../styles/EditableText.css';

import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useContext } from 'react';
import FormattedText from './FormattedText';
import DiscreeteDropdown from './DiscreeteDropdown';
import PictureUploadAction from '../menu-items/PictureUploadAction';
import {v4 as uuid} from 'uuid';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { SegmentContext } from './SegmentContext';

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

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

useEffect(() => {
if (segmentTimecode && isSegmentReceiver) {
updateEditedDocument()
.then((x) => {
let existingText = parseFirstPassage(x.text);
setEditedText((existingText && `${existingText}\n\n`) + segmentTimecode);
setBeingEdited(true);
setSegmentTimecode(null);
setHasBeenChanged(true);
});
}
}, [segmentTimecode, isSegmentReceiver, parseFirstPassage, setSegmentTimecode, updateEditedDocument]);

useEffect(() => {
if (rawEditMode) {
updateEditedDocument()
Expand Down Expand Up @@ -128,4 +143,4 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
);
}

export default EditableText;
export default EditableText;
36 changes: 16 additions & 20 deletions frontend/src/components/FormattedText.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { remarkDefinitionList, defListHastHandlers } from 'remark-definition-lis
import CroppedImage from './CroppedImage';
import VideoComment from './VideoComment';
import FragmentComment from './FragmentComment';
import VideoPlayer from './VideoPlayer';

function FormattedText({children, setHighlightedText, selectable, setSelectedText}) {
function FormattedText({children, setHighlightedText, selectable, setSelectedText, showSegmentControls}) {

const handleMouseUp = () => {
if (selectable) {
Expand All @@ -16,11 +17,23 @@ function FormattedText({children, setHighlightedText, selectable, setSelectedTex
}
};

function getVideoId(src) {
const regExp = /^.*(?:youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]{11})/;
const match = src.match(regExp);
return match ? match[1] : null;
}

return (<>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkDefinitionList, remarkUnwrapImages]}
components={{
img: (x) => embedVideo(x) || CroppedImage(x),
img: (x) => {
let videoId = getVideoId(x.src);
if (videoId) {
return <VideoPlayer videoId={videoId} showSegmentControls={showSegmentControls} />;
}
return CroppedImage(x);
},
p: (x) => VideoComment(x)
|| FragmentComment({...x, setHighlightedText})
|| <p onMouseUp={handleMouseUp}>{x.children}</p>,
Expand All @@ -35,21 +48,4 @@ function FormattedText({children, setHighlightedText, selectable, setSelectedTex
</>);
}

function getId(text) {
const regExp = /^.*(?:youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]{11})/;
const match = text.match(regExp);
return match ? match[1] : null;
}

function embedVideo({src}) {
const videoId = getId(src);
if (videoId) {
const embedLink = `https://www.youtube.com/embed/${videoId}`;
return (
<iframe width="80%" height="300" src={embedLink} frameBorder="0" allowFullScreen></iframe>
);
}
return null;
}

export default FormattedText;
export default FormattedText;
19 changes: 16 additions & 3 deletions frontend/src/components/Passage.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import '../styles/Passage.css';

import { useState } from 'react';
import { useState, useEffect, useContext } from 'react';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
Expand All @@ -9,12 +9,25 @@ import FormattedText from './FormattedText';
import EditableText from '../components/EditableText';
import DiscreeteDropdown from './DiscreeteDropdown';
import CommentFragmentAction from '../menu-items/CommentFragmentAction';
import { SegmentContext } from './SegmentContext';

function Passage({source, rubric, scholia, margin, sourceId, isComposite, rawEditMode, setRawEditMode, backend, setLastUpdate}) {
const [selectedText, setSelectedText] = useState();
const [highlightedText, setHighlightedText] = useState('');
const [fragment, setFragment] = useState();
const isFromScratch = margin === sourceId;
const { segmentTimecode, setSegmentTimecode } = useContext(SegmentContext);

const hasVideo = source.some(
chunk => /(?:youtube\.com\/watch\?v=|youtu\.be\/)/.test(chunk)
);

useEffect(() => {
if (segmentTimecode && hasVideo) {
setFragment(segmentTimecode + '\n\n');
setSegmentTimecode(null);
}
}, [segmentTimecode, hasVideo, setFragment, setSegmentTimecode]);

scholia = scholia.filter(x => (x.isPartOf === margin));
if (!scholia.length) {
Expand Down Expand Up @@ -74,7 +87,7 @@ function PassageSource({children, isComposite, highlightedText, setHighlightedTe
function SelectableFormattedText({children, highlightedText, setHighlightedText, setSelectedText}) {
return (
<Marker mark={highlightedText} options={({separateWordSearch: false})}>
<FormattedText selectable="true" {...{setSelectedText, setHighlightedText}}>
<FormattedText selectable="true" showSegmentControls={true} {...{setSelectedText, setHighlightedText}}>
{children}
</FormattedText>
</Marker>
Expand All @@ -98,4 +111,4 @@ function PassageMargin({active, scholia, setHighlightedText, fragment, setFragme
);
}

export default Passage;
export default Passage;
3 changes: 3 additions & 0 deletions frontend/src/components/SegmentContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createContext } from 'react';

export const SegmentContext = createContext({});
34 changes: 24 additions & 10 deletions frontend/src/components/VideoComment.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,39 @@ function VideoComment({ children }) {
}
className="videoComment"
>
{children[0]}
{children[0].replace(/ @\S+$/, '')}
</p>
</OverlayTrigger>
);

function playVideoAt(timecode) {
let [start, end] = timecode.split('-->');
function playVideoAt(timecodeString) {
let videoIdMatch = timecodeString.match(/@(\S+)/);
let targetVideoId = videoIdMatch ? videoIdMatch[1] : null;

let [start, end] = timecodeString.split('-->');
let [hour, min, sec] = start.split(/[:.]/);
let startTime = Number(hour * 3600) + Number(min * 60) + Number(sec);
[hour, min, sec] = end.split(/[:.]/);
let endTime = Number(hour * 3600) + Number(min * 60) + Number(sec);
let iframe = document.getElementsByTagName('iframe');
if (iframe.length != 0) {
let youTubeLink = new URL(iframe[0].src);
let youTubeBaseLink = youTubeLink.origin + youTubeLink.pathname;
let targetLink = `${youTubeBaseLink}?start=${startTime}&end=${endTime}&autoplay=1&mute=1`;
iframe[0].src = targetLink;

let iframes = document.getElementsByTagName('iframe');
let iframe;

if (targetVideoId) {
iframe = Array.from(iframes).find(
f => f.getAttribute('data-video-id') === targetVideoId
);
}
if (!iframe && iframes.length !== 0) {
iframe = iframes[0];
}

if (iframe) {
let videoIdForUrl = targetVideoId || new URL(iframe.src).pathname.split('/').pop();
let targetLink = `https://www.youtube.com/embed/${videoIdForUrl}?start=${startTime}&end=${endTime}&autoplay=1&mute=1`;
iframe.src = targetLink;
}
}
}

export default VideoComment;
export default VideoComment;
137 changes: 137 additions & 0 deletions frontend/src/components/VideoPlayer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useState, useEffect, useRef, useContext } from 'react';
import { SegmentContext } from './SegmentContext';

let apiLoaded = false;
let apiCallbacks = [];

function ensureYouTubeAPI() {
if (window.YT && window.YT.Player) {
apiLoaded = true;
return;
}
if (document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) return;
let script = document.createElement('script');
script.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(script);
window.onYouTubeIframeAPIReady = () => {
apiLoaded = true;
apiCallbacks.forEach(cb => cb());
apiCallbacks = [];
};
}

function onAPIReady(cb) {
if (apiLoaded) {
cb();
} else {
apiCallbacks.push(cb);
}
}

function formatTimecode(seconds) {
let h = Math.floor(seconds / 3600);
let m = Math.floor((seconds % 3600) / 60);
let s = Math.floor(seconds % 60);
let ms = Math.round((seconds % 1) * 1000);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`;
}

function VideoPlayer({ videoId, showSegmentControls }) {
let containerRef = useRef(null);
let playerRef = useRef(null);
let [ready, setReady] = useState(false);
let [state, setState] = useState('idle');
let [startTimecode, setStartTimecode] = useState(null);
let { showSegmentButton, setSegmentTimecode } = useContext(SegmentContext);

useEffect(() => {
let mounted = true;
let container = containerRef.current;

let playerDiv = document.createElement('div');
container.appendChild(playerDiv);

ensureYouTubeAPI();
onAPIReady(() => {
if (!mounted) return;
playerRef.current = new window.YT.Player(playerDiv, {
videoId,
width: '100%',
height: '300',
events: {
onReady: () => {
if (!mounted) return;
let iframe = playerRef.current.getIframe();
iframe.setAttribute('data-video-id', videoId);
setReady(true);
}
}
});
});

return () => {
mounted = false;
if (playerRef.current && playerRef.current.destroy) {
playerRef.current.destroy();
playerRef.current = null;
}
while (container.firstChild) {
container.removeChild(container.firstChild);
}
setReady(false);
};
}, [videoId]);

let handleClick = () => {
if (!ready || !playerRef.current) return;
let current = formatTimecode(playerRef.current.getCurrentTime());

if (state === 'idle') {
setStartTimecode(current);
setState('start_captured');
} else {
setSegmentTimecode(`${startTimecode} --> ${current} @${videoId}`);
setState('idle');
setStartTimecode(null);
}
};

let handleCancel = () => {
setState('idle');
setStartTimecode(null);
};

let shouldShowButton = showSegmentButton && showSegmentControls;

return (
<div className="video-player-container">
<div ref={containerRef}/>
{shouldShowButton && (
<div className="segment-selector justify-content-end">
<button
className={`btn btn-sm ${state === 'idle' ? 'btn-outline-danger' : 'btn-warning'}`}
onClick={handleClick}
disabled={!ready}
>
{!ready
? 'Loading the player…'
: state === 'idle'
? 'Define segment start'
: `Define segment end (start: ${startTimecode})`
}
</button>
{state === 'start_captured' && (
<button
className="btn btn-sm btn-outline-danger ms-2"
onClick={handleCancel}
>
Cancel
</button>
)}
</div>
)}
</div>
);
}

export default VideoPlayer;
Loading
Loading