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