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 ( - - - - - - - - 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 diff --git a/frontend/tests/context.js b/frontend/tests/context.js index 0cd53cbd..2f584dff 100644 --- a/frontend/tests/context.js +++ b/frontend/tests/context.js @@ -251,3 +251,28 @@ Soit("un document dont je suis l'auteur affiché comme document principal", () = cy.sign_out(); }); +Soit("une page affichant une vidéo avec l'API YouTube simulée", () => { + cy.stub_youtube_api(); + cy.visit('/4e1a31e14b032f2fa9e161ee9b009125'); + cy.get('iframe[data-video-id]').should('exist'); +}); + +Soit("la vidéo contient une annotation ouverte", () => { + cy.click_on_create(); +}); + +Soit("la vidéo n'a pas d'annotation ouverte", () => { + cy.url().should('not.contain', '#'); +}); + +Soit("une page affichant deux vidéos avec l'API YouTube simulée", () => { + cy.stub_youtube_api(); + cy.sign_in('alice', '/'); + cy.create_document_from_scratch(); + cy.click_on_text('content'); + cy.get('textarea').type( + '{selectAll}![Video1](https://www.youtube.com/watch?v=3CM1_Ji6fJ8)\n\n![Video2](https://www.youtube.com/watch?v=DTtf8L3uoS0)' + ).blur(); + cy.get('iframe[data-video-id="3CM1_Ji6fJ8"]').should('exist'); + cy.get('iframe[data-video-id="DTtf8L3uoS0"]').should('exist'); +}); \ No newline at end of file diff --git a/frontend/tests/event.js b/frontend/tests/event.js index 7beec42b..3b81e8f3 100644 --- a/frontend/tests/event.js +++ b/frontend/tests/event.js @@ -173,3 +173,22 @@ 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 positionne la vidéo à {string}", (timecode) => { + let [hour, min, sec] = timecode.split(/[:.]/); + let seconds = Number(hour) * 3600 + Number(min) * 60 + Number(sec) + Number(timecode.split('.')[1] || 0) / 1000; + cy.set_video_time(seconds); +}); + +Quand("j'appuie sur le bouton {string}", (label) => { + cy.contains('button', label).click(); +}); + +Quand("j'annule la définition du segment", () => { + cy.contains('button', 'Cancel').click(); +}); + +Quand("j'appuie deux fois rapidement sur le bouton {string}", (label) => { + cy.contains('button', label).click(); + cy.contains('button', /Define segment end/).click(); +}); \ No newline at end of file diff --git a/frontend/tests/outcome.js b/frontend/tests/outcome.js index 0720f45f..c789290e 100644 --- a/frontend/tests/outcome.js +++ b/frontend/tests/outcome.js @@ -181,7 +181,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) => { @@ -222,3 +223,39 @@ Alors("le document apparaît une seule fois dans la liste de ma bibliothèque", cy.get('.bookshelf').contains(this.randomName); cy.get('.bookshelf .work').filter(`:contains("${this.randomName}")`).should('have.length', 1); }); + +Alors("le bouton {string} est visible sous la vidéo", (label) => { + cy.get('.segment-selector').should('exist'); + cy.contains('.segment-selector button', label).should('be.visible'); +}); + +Alors("le bouton {string} n'est pas visible", (label) => { + cy.contains('button', label).should('not.exist'); +}); + +Alors("le bouton affiche {string}", (text) => { + cy.get('.segment-selector button').first().should('contain', text); +}); + +Alors("l'éditeur de commentaire s'ouvre avec le timecode {string}", (timecode) => { + cy.get('.scholium textarea').should('contain.value', timecode); +}); + +Alors("l'éditeur de commentaire n'est pas ouvert", () => { + cy.get('.scholium textarea').should('not.exist'); +}); + +Alors("le bouton revient à son état initial {string}", (label) => { + cy.contains('.segment-selector button', label).should('be.visible'); + cy.contains('button', 'Annuler').should('not.exist'); +}); + +Alors("le segment cible la vidéo {string}", (videoId) => { + cy.get('.scholium textarea').invoke('val').should('contain', `@${videoId}`); +}); + +Alors("le clic sur le timecode lance la vidéo {string}", (videoId) => { + cy.get(`iframe[data-video-id="${videoId}"]`) + .invoke('attr', 'src') + .should('contain', 'start='); +}); \ No newline at end of file diff --git a/frontend/tests/support.js b/frontend/tests/support.js index cad3ab0e..3bb991c3 100644 --- a/frontend/tests/support.js +++ b/frontend/tests/support.js @@ -182,3 +182,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; + }); +}); \ No newline at end of file