diff --git a/frontend/scenarios/set_video_segment_by_clicks.feature b/frontend/scenarios/set_video_segment_by_clicks.feature
new file mode 100644
index 00000000..b080cba8
--- /dev/null
+++ b/frontend/scenarios/set_video_segment_by_clicks.feature
@@ -0,0 +1,45 @@
+#language: fr
+
+Fonctionnalité: Définir un segment vidéo par deux clics
+
+ Scénario: Le bouton n'est visible que si la vidéo a une annotation ouverte
+ Soit une page affichant une vidéo avec l'API YouTube simulée
+ Et la vidéo n'a pas d'annotation ouverte
+ Et une session active avec mon compte
+ Alors le bouton "Define segment start" n'est pas visible
+
+ Scénario: Le bouton apparaît quand une annotation est ouverte
+ Soit une page affichant une vidéo avec l'API YouTube simulée
+ Et une session active avec mon compte
+ Et la vidéo contient une annotation ouverte
+ Alors le bouton "Define segment start" est visible sous la vidéo
+
+ Scénario: 1er clic capture le début, 2e clic capture la fin et ouvre l'éditeur
+ Soit une page affichant une vidéo avec l'API YouTube simulée
+ Et une session active avec mon compte
+ Et la vidéo contient une annotation ouverte
+ Quand je positionne la vidéo à "00:03:09.000"
+ Et j'appuie sur le bouton "Define segment start"
+ Alors le bouton affiche "Define segment end (start: 00:03:09.000)"
+ Et l'éditeur de commentaire n'est pas ouvert
+ Quand je positionne la vidéo à "00:03:15.000"
+ Et j'appuie sur le bouton "Define segment end (start: 00:03:09.000)"
+ Alors l'éditeur de commentaire s'ouvre avec le timecode "00:03:09.000 --> 00:03:15.000"
+
+ Scénario: Annuler après le premier clic permet de recommencer
+ Soit une page affichant une vidéo avec l'API YouTube simulée
+ Et une session active avec mon compte
+ Et la vidéo contient une annotation ouverte
+ Quand je positionne la vidéo à "00:01:00.000"
+ Et j'appuie sur le bouton "Define segment start"
+ Alors le bouton affiche "Define segment end (start: 00:01:00.000)"
+ Quand j'annule la définition du segment
+ Alors le bouton revient à son état initial "Define segment start"
+
+ Scénario: Deux clics rapprochés fonctionnent normalement
+ Soit une page affichant une vidéo avec l'API YouTube simulée
+ Et une session active avec mon compte
+ Et la vidéo contient une annotation ouverte
+ Quand je positionne la vidéo à "00:05:00.000"
+ Et j'appuie deux fois rapidement sur le bouton "Define segment start"
+ Alors l'éditeur de commentaire s'ouvre avec le timecode "00:05:00.000 --> 00:05:00.000"
\ No newline at end of file
diff --git a/frontend/src/components/EditableText.jsx b/frontend/src/components/EditableText.jsx
index 19b3452a..744201ab 100644
--- a/frontend/src/components/EditableText.jsx
+++ b/frontend/src/components/EditableText.jsx
@@ -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)
@@ -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()
@@ -128,4 +143,4 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
);
}
-export default EditableText;
+export default EditableText;
\ No newline at end of file
diff --git a/frontend/src/components/FormattedText.jsx b/frontend/src/components/FormattedText.jsx
index 3b89be66..ba2d8ed2 100644
--- a/frontend/src/components/FormattedText.jsx
+++ b/frontend/src/components/FormattedText.jsx
@@ -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) {
@@ -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 (<>
embedVideo(x) || CroppedImage(x),
+ img: (x) => {
+ let videoId = getVideoId(x.src);
+ if (videoId) {
+ return ;
+ }
+ return CroppedImage(x);
+ },
p: (x) => VideoComment(x)
|| FragmentComment({...x, setHighlightedText})
||
{x.children}
,
@@ -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 (
-
- );
- }
- return null;
-}
-
-export default FormattedText;
+export default FormattedText;
\ No newline at end of file
diff --git a/frontend/src/components/Passage.jsx b/frontend/src/components/Passage.jsx
index 7001609e..71889c0a 100644
--- a/frontend/src/components/Passage.jsx
+++ b/frontend/src/components/Passage.jsx
@@ -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';
@@ -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) {
@@ -74,7 +87,7 @@ function PassageSource({children, isComposite, highlightedText, setHighlightedTe
function SelectableFormattedText({children, highlightedText, setHighlightedText, setSelectedText}) {
return (
-
+
{children}
@@ -98,4 +111,4 @@ function PassageMargin({active, scholia, setHighlightedText, fragment, setFragme
);
}
-export default Passage;
+export default Passage;
\ No newline at end of file
diff --git a/frontend/src/components/SegmentContext.js b/frontend/src/components/SegmentContext.js
new file mode 100644
index 00000000..d7b4fe4c
--- /dev/null
+++ b/frontend/src/components/SegmentContext.js
@@ -0,0 +1,3 @@
+import { createContext } from 'react';
+
+export const SegmentContext = createContext({});
\ No newline at end of file
diff --git a/frontend/src/components/VideoComment.jsx b/frontend/src/components/VideoComment.jsx
index de0a058d..97a8b41a 100644
--- a/frontend/src/components/VideoComment.jsx
+++ b/frontend/src/components/VideoComment.jsx
@@ -17,25 +17,39 @@ function VideoComment({ children }) {
}
className="videoComment"
>
- {children[0]}
+ {children[0].replace(/ @\S+$/, '')}
);
- 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;
\ No newline at end of file
diff --git a/frontend/src/components/VideoPlayer.jsx b/frontend/src/components/VideoPlayer.jsx
new file mode 100644
index 00000000..676d7dbe
--- /dev/null
+++ b/frontend/src/components/VideoPlayer.jsx
@@ -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 (
+
+
+ {shouldShowButton && (
+
+
+ {state === 'start_captured' && (
+
+ )}
+
+ )}
+
+ );
+}
+
+export default VideoPlayer;
\ No newline at end of file
diff --git a/frontend/src/routes/Lectern.jsx b/frontend/src/routes/Lectern.jsx
index 6a951062..b34e1065 100644
--- a/frontend/src/routes/Lectern.jsx
+++ b/frontend/src/routes/Lectern.jsx
@@ -10,6 +10,7 @@ import Context from '../context';
import ParallelDocuments from '../parallelDocuments';
import OpenedDocuments from '../components/OpenedDocuments';
import DocumentsCards from '../components/DocumentsCards';
+import { SegmentContext } from '../components/SegmentContext';
function Lectern({backend, user}) {
@@ -19,6 +20,7 @@ function Lectern({backend, user}) {
const [lastUpdate, setLastUpdate] = useState();
const [rawEditMode, setRawEditMode] = useState(false);
const [loading, setLoading] = useState(true);
+ const [segmentTimecode, setSegmentTimecode] = useState(null);
let {id} = useParams();
let margin = useLocation().hash.slice(1);
const getCaption = ({dc_title, dc_spatial}) => [dc_title, dc_spatial].filter(Boolean).join(', ');
@@ -53,24 +55,30 @@ function Lectern({backend, user}) {
])];
return (
-
-
-