From 2b2a3bb0d8bf08502fe09130e64a7071e0f0955f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=B4me=20Peyrelongue?=
Date: Tue, 9 Jun 2026 15:21:23 +0200
Subject: [PATCH 1/3] SCENARIO: Split a video should be interactive (see #366).
---
frontend/scenarios/comment_fragment.feature | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/frontend/scenarios/comment_fragment.feature b/frontend/scenarios/comment_fragment.feature
index d404f9b7..3debbcaa 100644
--- a/frontend/scenarios/comment_fragment.feature
+++ b/frontend/scenarios/comment_fragment.feature
@@ -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
+ """
\ No newline at end of file
From 3674f68272eeba11dd8289ed97e0d5562d21a586 Mon Sep 17 00:00:00 2001
From: LeMouj
Date: Tue, 9 Jun 2026 16:21:53 +0200
Subject: [PATCH 2/3] TEST: Split a video should be interactive (see #366).
---
frontend/tests/context.js | 2 +-
frontend/tests/event.js | 17 +++++++++++++++++
frontend/tests/outcome.js | 4 +++-
frontend/tests/support.js | 37 +++++++++++++++++++++++++++++++++++++
4 files changed, 58 insertions(+), 2 deletions(-)
diff --git a/frontend/tests/context.js b/frontend/tests/context.js
index 0cd53cbd..cd12c9b0 100644
--- a/frontend/tests/context.js
+++ b/frontend/tests/context.js
@@ -1,3 +1,4 @@
+
import { Given as Soit, Step } from '@badeball/cypress-cucumber-preprocessor';
import { parseStrToObject } from './support';
@@ -250,4 +251,3 @@ Soit("un document dont je suis l'auteur affiché comme document principal", () =
cy.get('.bookmark').click();
cy.sign_out();
});
-
diff --git a/frontend/tests/event.js b/frontend/tests/event.js
index 7beec42b..c3753527 100644
--- a/frontend/tests/event.js
+++ b/frontend/tests/event.js
@@ -1,3 +1,4 @@
+
import { When as Quand } from '@badeball/cypress-cucumber-preprocessor';
import { parseStrToObject } from './support';
@@ -173,3 +174,19 @@ Quand("j'essaie de créer une glose qui soit découpée en passages", () => {
cy.click_on_create();
cy.get('.scholium .focus').click();
});
+
+Quand("je sélectionne le fragment de vidéo de {string} à {string}", (start, end) => {
+ function timecodeToSeconds(tc) {
+ let [h, m, s] = tc.split(/[:.]/);
+ return Number(h) * 3600 + Number(m) * 60 + Number(s) + Number(tc.split('.')[1] || 0) / 1000;
+ }
+
+ cy.stub_youtube_api();
+ cy.reload();
+ cy.get('iframe[data-video-id]').should('exist');
+
+ cy.set_video_time(timecodeToSeconds(start));
+ cy.contains('button', 'Define segment start').click();
+ cy.set_video_time(timecodeToSeconds(end));
+ cy.contains('button', /Define segment end/).click();
+});
diff --git a/frontend/tests/outcome.js b/frontend/tests/outcome.js
index 0720f45f..c289950b 100644
--- a/frontend/tests/outcome.js
+++ b/frontend/tests/outcome.js
@@ -1,3 +1,4 @@
+
import { Then as Alors, Step } from '@badeball/cypress-cucumber-preprocessor';
import { parseStrToObject } from './support';
@@ -181,7 +182,8 @@ Alors("les références au document principal contenues dans la glose ne sont pl
});
Alors("le document comporte la vidéo {string}", (videoUrl) => {
- cy.get(`iframe[src="${videoUrl}"]`).should('exist');
+ let videoId = videoUrl.split('/').pop();
+ cy.get(`iframe[data-video-id="${videoId}"]`).should('exist');
});
Alors("le nom de la licence de la glose est {string}", (name) => {
diff --git a/frontend/tests/support.js b/frontend/tests/support.js
index cad3ab0e..144a463f 100644
--- a/frontend/tests/support.js
+++ b/frontend/tests/support.js
@@ -1,3 +1,4 @@
+
export function getPassword(username) {
switch (username) {
case 'alice':
@@ -182,3 +183,39 @@ Cypress.Commands.add("editable_metadata_contains", (metadata) => {
});
});
});
+
+Cypress.Commands.add('stub_youtube_api', () => {
+ cy.intercept('GET', 'https://www.youtube.com/iframe_api', {
+ body: `
+ window.YT = {
+ Player: function(element, config) {
+ var iframe = document.createElement('iframe');
+ iframe.setAttribute('data-video-id', config.videoId || '');
+ iframe.src = 'https://www.youtube.com/embed/' + (config.videoId || '');
+ iframe.width = config.width || '100%';
+ iframe.height = config.height || '300';
+ if (element.parentNode) {
+ element.parentNode.replaceChild(iframe, element);
+ }
+ this._iframe = iframe;
+ this.getIframe = function() { return this._iframe; };
+ this.getCurrentTime = function() { return window.__ytMockCurrentTime || 0; };
+ this.destroy = function() { if (this._iframe) this._iframe.remove(); };
+ var self = this;
+ if (config.events && config.events.onReady) {
+ setTimeout(function() { config.events.onReady({ target: self }); }, 50);
+ }
+ }
+ };
+ if (window.onYouTubeIframeAPIReady) {
+ window.onYouTubeIframeAPIReady();
+ }
+ `
+ }).as('youtubeApi');
+});
+
+Cypress.Commands.add('set_video_time', (seconds) => {
+ cy.window().then(win => {
+ win.__ytMockCurrentTime = seconds;
+ });
+});
From 0ce921fff237b72acdf88d8f5cbd9d75638ba7dd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=B4me=20Peyrelongue?=
Date: Tue, 2 Jun 2026 14:33:34 +0200
Subject: [PATCH 3/3] FEATURE: Split a video should be interactive (fixes
#366).
---
frontend/src/components/EditableText.jsx | 21 +++-
frontend/src/components/FormattedText.jsx | 36 +++---
frontend/src/components/Passage.jsx | 19 ++-
frontend/src/components/SegmentContext.js | 3 +
frontend/src/components/VideoComment.jsx | 34 ++++--
frontend/src/components/VideoPlayer.jsx | 137 ++++++++++++++++++++++
frontend/src/routes/Lectern.jsx | 46 +++++---
frontend/src/styles/EditableText.css | 8 +-
8 files changed, 248 insertions(+), 56 deletions(-)
create mode 100644 frontend/src/components/SegmentContext.js
create mode 100644 frontend/src/components/VideoPlayer.jsx
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 (
-
-
-
-
-
-
-
- 0}
- {...{id, margin, metadata, parallelDocuments, user, rawEditMode, setRawEditMode, backend, setLastUpdate, content}}
- />
-
-
-
-
-
+
+
+
+
+
+
+
+
+ 0}
+ {...{id, margin, metadata, parallelDocuments, user, rawEditMode, setRawEditMode, backend, setLastUpdate, content}}
+ />
+
+
+
+
+
+
);
}
@@ -85,4 +93,4 @@ function References({metadata, active, createOn, setLastUpdate, backend, user})
);
}
-export default Lectern;
+export default Lectern;
\ No newline at end of file
diff --git a/frontend/src/styles/EditableText.css b/frontend/src/styles/EditableText.css
index 5632ffd2..9287b4c8 100644
--- a/frontend/src/styles/EditableText.css
+++ b/frontend/src/styles/EditableText.css
@@ -12,4 +12,10 @@
border-color: black;
}
-
+/* --- Segment Selector --- */
+.segment-selector {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+}
\ No newline at end of file