diff --git a/LICENSE b/LICENSE index 6b2815b471920cca70d28021370fe90db1de3cc7..dcead47785cefd97b62fbad588bd683889749964 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,12 @@ +This project is dual-licensed under the Apache License 2.0 and the MIT license. + +Copyright 2021 Digital Humanities Initiative, Center for Evolving Humanities, Graduate School of Humanities and Sociology, The University of Tokyo +Copyright 2021 International Institute for Digital Humanities +Copyright 2021 Research Institute for Languages and Cultures of Asia and Africa, Tokyo University of Foreign Studies +Copyright 2021 FLX Style + +Includes content from Mirador licensed under the Apache License 2.0. + Copyright 2020 The Board of Trustees of the Leland Stanford Junior University Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/README.md b/README.md index 0799ad78e1d4014da493d0fda6599dc0c9ec2148..65fe992f8829ef5ec99cbcc5af150d887547e385 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ +## Mirador with support for displaying annotations on videos + +### Project +https://dh.l.u-tokyo.ac.jp/activity/iiif/video-annotation + +### Demo +- https://dzkimgs.l.u-tokyo.ac.jp/videos/m3/cat_video.html +- https://dzkimgs.l.u-tokyo.ac.jp/videos/m3/video.html + +### Manifest Sample +- https://dzkimgs.l.u-tokyo.ac.jp/videos/cat2020/manifest.json +- https://dzkimgs.l.u-tokyo.ac.jp/videos/iiif_in_japan_2017/manifest.json + +### Prebuilt +https://dzkimgs.l.u-tokyo.ac.jp/videos/m3/mirador.min.js + +### License +This project is dual-licensed under the Apache License 2.0 and the MIT license. See [LICENSE](LICENSE) for details. + +--- *NOTE: This README reflects the latest version of Mirador, Mirador 3. For previous versions, please reference that release's README directly. Latest 2.x release: [v.2.7.0](https://github.com/ProjectMirador/mirador/tree/v2.7.0)* # Mirador  [](https://codecov.io/gh/ProjectMirador/mirador) diff --git a/src/components/AnnotationsOverlayVideo.js b/src/components/AnnotationsOverlayVideo.js new file mode 100755 index 0000000000000000000000000000000000000000..4f66399cf7f042ce7b9b851a0b86be657ab3eed5 --- /dev/null +++ b/src/components/AnnotationsOverlayVideo.js @@ -0,0 +1,631 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import isEqual from 'lodash/isEqual'; +import debounce from 'lodash/debounce'; +import flatten from 'lodash/flatten'; +import sortBy from 'lodash/sortBy'; +import xor from 'lodash/xor'; +import ResizeObserver from 'react-resize-observer'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import CanvasOverlayVideo from '../lib/CanvasOverlayVideo'; +import CanvasWorld from '../lib/CanvasWorld'; +import CanvasAnnotationDisplay from '../lib/CanvasAnnotationDisplay'; + +/** AnnotationsOverlayVideo - based on AnnotationsOverlay */ +export class AnnotationsOverlayVideo extends Component { + /** + * annotationsMatch - compares previous annotations to current to determine + * whether to add a new updateCanvas method to draw annotations + * @param {Array} currentAnnotations + * @param {Array} prevAnnotations + * @return {Boolean} + */ + static annotationsMatch(currentAnnotations, prevAnnotations) { + if (!currentAnnotations && !prevAnnotations) return true; + if ( + (currentAnnotations && !prevAnnotations) + || (!currentAnnotations && prevAnnotations) + ) return false; + + if (currentAnnotations.length === 0 && prevAnnotations.length === 0) return true; + if (currentAnnotations.length !== prevAnnotations.length) return false; + return currentAnnotations.every((annotation, index) => { + const newIds = annotation.resources.map(r => r.id); + const prevIds = prevAnnotations[index].resources.map(r => r.id); + if (newIds.length === 0 && prevIds.length === 0) return true; + if (newIds.length !== prevIds.length) return false; + + if ((annotation.id === prevAnnotations[index].id) && (isEqual(newIds, prevIds))) { + return true; + } + return false; + }); + } + + /** @private */ + static isAnnotaionInTemporalSegment(resource, time) { + const temporalfragment = resource.temporalfragmentSelector; + if (temporalfragment && temporalfragment.length > 0) { + const start = temporalfragment[0] || 0; + const end = (temporalfragment.length > 1) ? temporalfragment[1] : Number.MAX_VALUE; + if (start <= time && time < end) { + // + } else { + return false; + } + } + return true; + } + + /** + * @param {Object} props + */ + constructor(props) { + super(props); + + this.ref = React.createRef(); + this.canvasOverlay = null; + // An initial value for the updateCanvas method + this.updateCanvas = () => {}; + this.onCanvasClick = this.onCanvasClick.bind(this); + this.onCanvasMouseMove = debounce(this.onCanvasMouseMove.bind(this), 10); + this.onCanvasExit = this.onCanvasExit.bind(this); + + this.onVideoTimeUpdate = this.onVideoTimeUpdate.bind(this); + this.onVideoLoadedMetadata = this.onVideoLoadedMetadata.bind(this); + this.onVideoWaiting = this.onVideoWaiting.bind(this); + this.onVideoPlaying = this.onVideoPlaying.bind(this); + + this.onCanvasResize = this.onCanvasResize.bind(this); + + this.imagesLoading = []; + this.imagesReady = []; + + const { videoTarget: temporalfragment } = this.props; + if (temporalfragment && temporalfragment.length > 0) { + this.temporalOffset = temporalfragment[0] || 0; + } else { + this.temporalOffset = 0; + } + + this.state = { + showProgress: false, + }; + } + + /** + * React lifecycle event + */ + componentDidMount() { + this.initializeViewer(); + } + + /** */ + componentDidUpdate(prevProps) { + const { + canvasWorld, + currentTime, + drawAnnotations, + drawSearchAnnotations, + annotations, searchAnnotations, + hoveredAnnotationIds, selectedAnnotationId, + highlightAllAnnotations, + paused, + setCurrentTime, + setPaused, + } = this.props; + + this.initializeViewer(); + + if (this.video) { + if (this.video.paused && !paused) { + const promise = this.video.play(); + if (promise !== undefined) { + promise.catch((e) => {}); + } + } else if (!this.video.paused && paused) { + this.video.pause(); + } + if (currentTime !== prevProps.currentTime) { + if (paused && this.video.paused) { + this.video.currentTime = currentTime - this.temporalOffset; + } + } + } + + const annotationsUpdated = !AnnotationsOverlayVideo.annotationsMatch( + annotations, prevProps.annotations, + ); + const searchAnnotationsUpdated = !AnnotationsOverlayVideo.annotationsMatch( + searchAnnotations, prevProps.searchAnnotations, + ); + + const hoveredAnnotationsUpdated = ( + xor(hoveredAnnotationIds, prevProps.hoveredAnnotationIds).length > 0 + ); + + if (this.canvasOverlay && this.canvasOverlay.canvas && hoveredAnnotationsUpdated) { + if (hoveredAnnotationIds.length > 0) { + this.canvasOverlay.canvas.style.cursor = 'pointer'; + } else { + this.canvasOverlay.canvas.style.cursor = ''; + } + } + + const selectedAnnotationsUpdated = selectedAnnotationId !== prevProps.selectedAnnotationId; + if (selectedAnnotationsUpdated && selectedAnnotationId) { + setPaused(true); + this.video && this.video.pause(); + annotations.forEach((annotation) => { + annotation.resources.forEach((resource) => { + if (resource.id !== selectedAnnotationId) return; + if (!canvasWorld.canvasIds.includes(resource.targetId)) return; + if (!AnnotationsOverlayVideo.isAnnotaionInTemporalSegment(resource, currentTime)) { + const temporalfragment = resource.temporalfragmentSelector; + if (temporalfragment && temporalfragment.length > 0 && this.video) { + const seekto = temporalfragment[0] || 0; + setPaused(true); + this.video.pause(); + setCurrentTime(seekto); + const videoTime = seekto - this.temporalOffset; + if (videoTime >= 0) { + this.video.currentTime = videoTime; + } + } + } + }); + }); + } + + const redrawAnnotations = drawAnnotations !== prevProps.drawAnnotations + || drawSearchAnnotations !== prevProps.drawSearchAnnotations + || highlightAllAnnotations !== prevProps.highlightAllAnnotations; + + if ( + searchAnnotationsUpdated + || annotationsUpdated + || selectedAnnotationsUpdated + || hoveredAnnotationsUpdated + || redrawAnnotations + ) { + this.updateCanvas = this.canvasUpdateCallback(); + this.updateCanvas(); + } + } + + /** + */ + componentWillUnmount() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.video) { + this.video.removeEventListener('timeupdate', this.onVideoTimeUpdate); + this.video.removeEventListener('loadedmetadata', this.onVideoLoadedMetadata); + this.video.removeEventListener('waiting', this.onVideoWaiting); + this.video.removeEventListener('playing', this.onVideoPlaying); + this.video.removeEventListener('seeked', this.onVideoPlaying); + } + if (this.canvasOverlay && this.canvasOverlay.canvas) { + this.canvasOverlay.canvas.removeEventListener('click', this.onCanvasClick); + this.canvasOverlay.canvas.removeEventListener('mouseleave', this.onCanvasExit); + this.canvasOverlay.canvas.removeEventListener('mousemove', this.onCanvasMouseMove); + } + } + + /** */ + onVideoTimeUpdate(event) { + this.updateCanvas(); + } + + /** */ + onVideoLoadedMetadata(event) { + if (this.video) { + const { currentTime } = this.props; + const { temporalOffset } = this; + this.video.currentTime = currentTime - temporalOffset; + } + } + + /** */ + onVideoPlaying(event) { + if (this.video && this.video.currentTime !== 0) { + const { currentTime } = this.props; + const currentTimeToVideoTime = currentTime - this.temporalOffset; + const diff = Math.abs(currentTimeToVideoTime - this.video.currentTime); + const acceptableDiff = 1; // sec. + if (diff > acceptableDiff) { + const { setCurrentTime, setPaused } = this.props; + // In the flow of pausing, adjusting currentTime, and resuming playback, + // it is necessary to handle cases where the user explicitly pauses playback. + setPaused(true); + setCurrentTime(this.video.currentTime + this.temporalOffset); // rewind time + setPaused(false); + } + } + this.setState({ showProgress: false }); + } + + /** */ + onVideoWaiting(event) { + this.setState({ showProgress: true }); + } + + /** */ + onCanvasClick(event) { + const { canvas: canvas_, canvasWorld, currentTime } = this.props; + + const scale = (this.canvasOverlay ? this.canvasOverlay.scale : 1) || 1; + const point = { x: event.layerX / scale, y: event.layerY / scale }; + + const canvas = this.isCanvasSizeSpecified() ? canvasWorld.canvasAtPoint(point) : canvas_; + if (!canvas) return; + + // get all the annotations that contain the click + // const currentTime = this.video ? this.video.currentTime : undefined; + const annos = this.annotationsAtPoint(canvas, point, currentTime); + + if (annos.length > 0) { + event.preventDefaultAction = true; // eslint-disable-line no-param-reassign + } + + if (annos.length === 1) { + this.toggleAnnotation(annos[0].id); + } else if (annos.length > 0) { + /** + * Try to find the "right" annotation to select after a click. + * + * This is perhaps a naive method, but seems to deal with rectangles and SVG shapes: + * + * - figure out how many points around a circle are inside the annotation shape + * - if there's a shape with the fewest interior points, it's probably the one + * with the closest boundary? + * - if there's a tie, make the circle bigger and try again. + */ + const annosWithClickScore = (radius) => { + const degreesToRadians = Math.PI / 180; + + return (anno) => { + let score = 0; + for (let degrees = 0; degrees < 360; degrees += 1) { + const x = Math.cos(degrees * degreesToRadians) * radius + point.x; + const y = Math.sin(degrees * degreesToRadians) * radius + point.y; + + if (this.isAnnotationAtPoint(anno, canvas, { x, y })) score += 1; + } + + return { anno, score }; + }; + }; + + let annosWithScore = []; + let radius = 1; + annosWithScore = sortBy(annos.map(annosWithClickScore(radius)), 'score'); + + const { width: canvasWidth, height: canvasHeight } = this.getCurrentCanvasSize(); + while (radius < Math.max(canvasWidth, canvasHeight) + && annosWithScore[0].score === annosWithScore[1].score) { + radius *= 2; + annosWithScore = sortBy(annos.map(annosWithClickScore(radius)), 'score'); + } + + this.toggleAnnotation(annosWithScore[0].anno.id); + } + } + + /** */ + onCanvasMouseMove(event) { + const { + annotations, + canvas: canvas_, + canvasWorld, + currentTime, + hoverAnnotation, + hoveredAnnotationIds, + searchAnnotations, + windowId, + } = this.props; + + if (annotations.length === 0 && searchAnnotations.length === 0) return; + + const scale = (this.canvasOverlay ? this.canvasOverlay.scale : 1) || 1; + const point = { x: event.layerX / scale, y: event.layerY / scale }; + + const canvas = this.isCanvasSizeSpecified() ? canvasWorld.canvasAtPoint(point) : canvas_; + if (!canvas) { + hoverAnnotation(windowId, []); + return; + } + + // const currentTime = this.video ? this.video.currentTime : undefined; + const annos = this.annotationsAtPoint(canvas, point, currentTime); + + if (xor(hoveredAnnotationIds, annos.map(a => a.id)).length > 0) { + hoverAnnotation(windowId, annos.map(a => a.id)); + } + } + + /** If the cursor leaves the canvas, wipe out highlights */ + onCanvasExit(event) { + const { hoverAnnotation, windowId } = this.props; + + // a move event may be queued up by the debouncer + this.onCanvasMouseMove.cancel(); + hoverAnnotation(windowId, []); + } + + /** + * Make sure that the annotation position on the Canvas is correct even when the video + * display size is changed by opening and closing the side panel. + */ + onCanvasResize(event) { + this.updateCanvas(); + } + + /** @private */ + getCurrentCanvasSize() { + const { canvas, canvasWorld } = this.props; + const [ + _canvasX, _canvasY, _canvasWidth, _canvasHeight, // eslint-disable-line no-unused-vars + ] = canvasWorld.canvasToWorldCoordinates(canvas.id); + if (_canvasWidth && _canvasHeight) { + return { height: _canvasHeight, width: _canvasWidth }; + } + if (this.video) { + const { videoWidth, videoHeight } = this.video; + return { height: videoHeight, width: videoWidth }; + } + return { height: 0, width: 0 }; + } + + /** @private */ + getResourceImage(resource) { + let imageSource; + if (resource.body && resource.body.length > 0 && resource.body[0].type === 'Image') { + const src = resource.body[0].id; + if (this.imagesReady[src]) { + imageSource = this.imagesReady[src]; + } else if (!this.imagesLoading.includes(src)) { + this.imagesLoading.push(src); + const img = new Image(); + img.addEventListener('load', () => { + this.imagesReady[src] = img; + }, false); + img.src = src; + } + } + return imageSource; + } + + /** @private */ + isCanvasSizeSpecified() { + const { canvas, canvasWorld } = this.props; + const [ + _canvasX, _canvasY, _canvasWidth, _canvasHeight, // eslint-disable-line no-unused-vars + ] = canvasWorld.canvasToWorldCoordinates(canvas.id); + return _canvasWidth && _canvasHeight; + } + + /** @private */ + initializeViewer() { + if (this.canvasOverlay && this.canvasOverlay.canvas) { + this.canvasOverlay.canvas.addEventListener('click', this.onCanvasClick); + this.canvasOverlay.canvas.addEventListener('mouseleave', this.onCanvasExit); + this.canvasOverlay.canvas.addEventListener('mousemove', this.onCanvasMouseMove); + } + if (this.canvasOverlay) return; + + const { videoRef } = this.props; + if (!videoRef.current) return; + this.video = videoRef.current; + this.video.addEventListener('timeupdate', this.onVideoTimeUpdate); + this.video.addEventListener('loadedmetadata', this.onVideoLoadedMetadata); + this.video.addEventListener('waiting', this.onVideoWaiting); + this.video.addEventListener('playing', this.onVideoPlaying); + this.video.addEventListener('seeked', this.onVideoPlaying); + + const { canvas, canvasWorld } = this.props; + const canvasSize = canvasWorld.canvasToWorldCoordinates(canvas.id); + this.canvasOverlay = new CanvasOverlayVideo(this.video, this.ref, canvasSize); + + this.updateCanvas = this.canvasUpdateCallback(); + + // Prefetch annotation images + const { annotations } = this.props; + annotations.forEach((annotation) => { + annotation.resources.forEach((resource) => { + if (!canvasWorld.canvasIds.includes(resource.targetId)) return; + this.getResourceImage(resource); + }); + }); + } + + /** */ + canvasUpdateCallback() { + return () => { + this.canvasOverlay.clear(); + this.canvasOverlay.resize(); + this.canvasOverlay.canvasUpdate(this.renderAnnotations.bind(this)); + }; + } + + /** @private */ + isAnnotationAtPoint(resource, canvas, point, time) { + const { canvasWorld } = this.props; + + const [canvasX, canvasY] = canvasWorld.canvasToWorldCoordinates(canvas.id); + const relativeX = point.x - canvasX; + const relativeY = point.y - canvasY; + + if (resource.temporalfragmentSelector) { + if (!AnnotationsOverlayVideo.isAnnotaionInTemporalSegment(resource, time)) { + return false; + } + } + + if (resource.svgSelector) { + const context = this.canvasOverlay.context2d; + const { svgPaths } = new CanvasAnnotationDisplay({ resource }); + return [...svgPaths].some(path => ( + context.isPointInPath(new Path2D(path.attributes.d.nodeValue), relativeX, relativeY) + )); + } + + if (resource.fragmentSelector) { + const [x, y, w, h] = resource.fragmentSelector; + return (x <= relativeX && relativeX <= (x + w) && y <= relativeY && relativeY <= (y + h)); + } + + // If there is no svgSelector or fragmentSelector, assume that the target is the entire canvas. + return true; + } + + /** @private */ + annotationsAtPoint(canvas, point, time) { + const { annotations, searchAnnotations } = this.props; + + const lists = [...annotations, ...searchAnnotations]; + const annos = flatten(lists.map(l => l.resources)).filter((resource) => { + if (canvas.id !== resource.targetId) return false; + + return this.isAnnotationAtPoint(resource, canvas, point, time); + }); + + return annos; + } + + /** */ + toggleAnnotation(id) { + const { + selectedAnnotationId, + selectAnnotation, + deselectAnnotation, + windowId, + } = this.props; + + if (selectedAnnotationId === id) { + deselectAnnotation(windowId, id); + } else { + selectAnnotation(windowId, id); + } + } + + /** + * annotationsToContext - converts anontations to a canvas context + */ + annotationsToContext(annotations, palette) { + const { + highlightAllAnnotations, hoveredAnnotationIds, selectedAnnotationId, canvasWorld, currentTime, + } = this.props; + const context = this.canvasOverlay.context2d; + const zoomRatio = 1; + annotations.forEach((annotation) => { + annotation.resources.forEach((resource) => { + if (!canvasWorld.canvasIds.includes(resource.targetId)) return; + if (!AnnotationsOverlayVideo.isAnnotaionInTemporalSegment(resource, currentTime)) return; + + const imageSource = this.getResourceImage(resource); + const offset = canvasWorld.offsetByCanvas(resource.targetId); + const canvasSize = canvasWorld.canvasToWorldCoordinates(resource.targetId); + const canvasAnnotationDisplay = new CanvasAnnotationDisplay({ + canvasSize, + hovered: hoveredAnnotationIds.includes(resource.id), + imageSource, + offset, + palette: { + ...palette, + default: { + ...palette.default, + ...(!highlightAllAnnotations && palette.hidden), + }, + }, + resource, + selected: selectedAnnotationId === resource.id, + zoomRatio, + }); + canvasAnnotationDisplay.toContext(context); + }); + }); + } + + /** */ + renderAnnotations() { + const { + annotations, + drawAnnotations, + drawSearchAnnotations, + searchAnnotations, + palette, + } = this.props; + + if (drawSearchAnnotations) { + this.annotationsToContext(searchAnnotations, palette.search); + } + + if (drawAnnotations) { + this.annotationsToContext(annotations, palette.annotations); + } + } + + /** + * Renders things + */ + render() { + const { annotations, searchAnnotations } = this.props; + const { showProgress } = this.state; + const circularProgress = (<CircularProgress style={{ left: '50%', position: 'absolute', top: '50%' }} />); + if (annotations.length === 0 && searchAnnotations.length === 0) { + return (<>{ showProgress && circularProgress }</>); + } + return ( + <> + <canvas ref={this.ref} style={{ left: 0, position: 'absolute', top: 0 }} /> + <ResizeObserver onResize={this.onCanvasResize} /> + { showProgress && circularProgress } + </> + ); + } +} + +AnnotationsOverlayVideo.defaultProps = { + annotations: [], + canvas: {}, + currentTime: 0, + deselectAnnotation: () => {}, + drawAnnotations: true, + drawSearchAnnotations: true, + highlightAllAnnotations: false, + hoverAnnotation: () => {}, + hoveredAnnotationIds: [], + palette: {}, + paused: true, + searchAnnotations: [], + selectAnnotation: () => {}, + selectedAnnotationId: undefined, + setCurrentTime: () => {}, + setPaused: () => {}, + videoTarget: [], +}; + +AnnotationsOverlayVideo.propTypes = { + annotations: PropTypes.arrayOf(PropTypes.object), + canvas: PropTypes.object, // eslint-disable-line react/forbid-prop-types + canvasWorld: PropTypes.instanceOf(CanvasWorld).isRequired, + currentTime: PropTypes.number, + deselectAnnotation: PropTypes.func, + drawAnnotations: PropTypes.bool, + drawSearchAnnotations: PropTypes.bool, + highlightAllAnnotations: PropTypes.bool, + hoverAnnotation: PropTypes.func, + hoveredAnnotationIds: PropTypes.arrayOf(PropTypes.string), + palette: PropTypes.object, // eslint-disable-line react/forbid-prop-types + paused: PropTypes.bool, + searchAnnotations: PropTypes.arrayOf(PropTypes.object), + selectAnnotation: PropTypes.func, + selectedAnnotationId: PropTypes.string, + setCurrentTime: PropTypes.func, + setPaused: PropTypes.func, + videoRef: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + videoTarget: PropTypes.arrayOf(PropTypes.number), + windowId: PropTypes.string.isRequired, +}; diff --git a/src/components/VideoViewer.js b/src/components/VideoViewer.js index 2eec0d0ab30fb358d757b60d15d916d5e3a892f7..31810a21c86cb3e5898829f077409be78b6ed394 100644 --- a/src/components/VideoViewer.js +++ b/src/components/VideoViewer.js @@ -1,26 +1,132 @@ -import React, { Component, Fragment } from 'react'; +import flatten from 'lodash/flatten'; +import flattenDeep from 'lodash/flattenDeep'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import AnnotationItem from '../lib/AnnotationItem'; +import AnnotationsOverlayVideo from '../containers/AnnotationsOverlayVideo'; +import WindowCanvasNavigationControlsVideo from '../containers/WindowCanvasNavigationControlsVideo'; /** */ export class VideoViewer extends Component { + /** */ + constructor(props) { + super(props); + this.videoRef = React.createRef(); + + this.state = { + start: 0, + time: 0, + }; + } + + /** */ + componentDidUpdate(prevProps) { + const { + canvas, currentTime, muted, paused, + setCurrentTime, setPaused, + } = this.props; + + if (paused !== prevProps.paused) { + if (currentTime === 0) { + this.timerReset(); + } + if (paused) { + this.timerStop(); + } else { + this.timerStart(); + } + } + if (currentTime !== prevProps.currentTime) { + const duration = canvas.getDuration(); + if (duration && duration < currentTime) { + if (!paused) { + setPaused(true); + setCurrentTime(0); + this.timerReset(); + } + } + } + const video = this.videoRef.current; + if (video) { + if (video.muted !== muted) { + video.muted = muted; + } + } + } + + /** */ + timerStart() { + const { currentTime } = this.props; + this.setState({ + start: Date.now() - currentTime * 1000, + time: currentTime * 1000, + }); + this.timer = setInterval(() => { + const { setCurrentTime } = this.props; + this.setState(prevState => ({ + time: Date.now() - prevState.start, + })); + const { time } = this.state; + setCurrentTime(time / 1000); + }, 100); + } + + /** */ + timerStop() { + clearInterval(this.timer); + } + + /** */ + timerReset() { + this.setState({ time: 0 }); + } + /* eslint-disable jsx-a11y/media-has-caption */ /** */ render() { - const { captions, classes, videoResources } = this.props; + const { + canvas, classes, currentTime, windowId, + } = this.props; + + const videoResources = flatten( + flattenDeep([ + canvas.getContent().map(annot => { + const annotaion = new AnnotationItem(annot.__jsonld); + const temporalfragment = annotaion.temporalfragmentSelector; + if (temporalfragment && temporalfragment.length > 0) { + const start = temporalfragment[0] || 0; + const end = (temporalfragment.length > 1) ? temporalfragment[1] : Number.MAX_VALUE; + if (start <= currentTime && currentTime < end) { + // + } else { + return {}; + } + } + const body = annot.getBody(); + return { body, temporalfragment }; + }), + ]).filter((resource) => resource.body && resource.body[0].__jsonld && resource.body[0].__jsonld.type === 'Video'), + ); + // Only one video can be displayed at a time in this implementation. + const len = videoResources.length; + const video = len > 0 + ? videoResources[len - 1].body[0] : null; + const videoTargetTemporalfragment = len > 0 + ? videoResources[len - 1].temporalfragment : []; + return ( - <div className={classes.container}> - <video controls className={classes.video}> - {videoResources.map(video => ( - <Fragment key={video.id}> - <source src={video.id} type={video.getFormat()} /> - </Fragment> - ))} - {captions.map(caption => ( - <Fragment key={caption.id}> - <track src={caption.id} label={caption.getLabel()} srcLang={caption.getProperty('language')} /> - </Fragment> - ))} - </video> + <div className={classes.flexContainer}> + <div className={classes.flexFill}> + { video && ( + <> + <video className={classes.video} key={video.id} ref={this.videoRef}> + <source src={video.id} type={video.getFormat()} /> + </video> + <AnnotationsOverlayVideo windowId={windowId} videoRef={this.videoRef} videoTarget={videoTargetTemporalfragment} key={`${windowId} ${video.id}`} /> + </> + )} + <WindowCanvasNavigationControlsVideo windowId={windowId} /> + </div> </div> ); } @@ -28,12 +134,21 @@ export class VideoViewer extends Component { } VideoViewer.propTypes = { - captions: PropTypes.arrayOf(PropTypes.object), + canvas: PropTypes.object, // eslint-disable-line react/forbid-prop-types classes: PropTypes.objectOf(PropTypes.string).isRequired, - videoResources: PropTypes.arrayOf(PropTypes.object), + currentTime: PropTypes.number, + muted: PropTypes.bool, + paused: PropTypes.bool, + setCurrentTime: PropTypes.func, + setPaused: PropTypes.func, + windowId: PropTypes.string.isRequired, }; VideoViewer.defaultProps = { - captions: [], - videoResources: [], + canvas: {}, + currentTime: 0, + muted: false, + paused: true, + setCurrentTime: () => {}, + setPaused: () => {}, }; diff --git a/src/components/ViewerNavigation.js b/src/components/ViewerNavigation.js index b03a6946a807e1fccff31c1ee96e5b52a62b5e4e..a372295f3ab044d37da161a5905bf9dcf1f82941 100644 --- a/src/components/ViewerNavigation.js +++ b/src/components/ViewerNavigation.js @@ -15,6 +15,7 @@ export class ViewerNavigation extends Component { const { hasNextCanvas, hasPreviousCanvas, setNextCanvas, setPreviousCanvas, t, classes, viewingDirection, + beforeClick, } = this.props; let htmlDir = 'ltr'; @@ -48,7 +49,7 @@ export class ViewerNavigation extends Component { aria-label={t('previousCanvas')} className={ns('previous-canvas-button')} disabled={!hasPreviousCanvas} - onClick={() => { hasPreviousCanvas && setPreviousCanvas(); }} + onClick={() => { beforeClick(); hasPreviousCanvas && setPreviousCanvas(); }} > <NavigationIcon style={previousIconStyle} /> </MiradorMenuButton> @@ -56,7 +57,7 @@ export class ViewerNavigation extends Component { aria-label={t('nextCanvas')} className={ns('next-canvas-button')} disabled={!hasNextCanvas} - onClick={() => { hasNextCanvas && setNextCanvas(); }} + onClick={() => { beforeClick(); hasNextCanvas && setNextCanvas(); }} > <NavigationIcon style={nextIconStyle} /> </MiradorMenuButton> @@ -66,6 +67,7 @@ export class ViewerNavigation extends Component { } ViewerNavigation.propTypes = { + beforeClick: PropTypes.func, classes: PropTypes.objectOf(PropTypes.string).isRequired, hasNextCanvas: PropTypes.bool, hasPreviousCanvas: PropTypes.bool, @@ -76,6 +78,7 @@ ViewerNavigation.propTypes = { }; ViewerNavigation.defaultProps = { + beforeClick: () => {}, hasNextCanvas: false, hasPreviousCanvas: false, setNextCanvas: () => {}, diff --git a/src/components/ViewerNavigationVideo.js b/src/components/ViewerNavigationVideo.js new file mode 100755 index 0000000000000000000000000000000000000000..47355af9c80df2f4e3ff646088a156b58523f7dc --- /dev/null +++ b/src/components/ViewerNavigationVideo.js @@ -0,0 +1,107 @@ +import React, { Component } from 'react'; +import PauseRoundedIcon from '@material-ui/icons/PauseRounded'; +import PlayArrowRoundedIcon from '@material-ui/icons/PlayArrowRounded'; +import PropTypes from 'prop-types'; +import Slider from '@material-ui/core/Slider'; +import Typography from '@material-ui/core/Typography'; +import VolumeOffIcon from '@material-ui/icons/VolumeOff'; +import VolumeUpIcon from '@material-ui/icons/VolumeUp'; +import MiradorMenuButton from '../containers/MiradorMenuButton'; +import ns from '../config/css-ns'; + +/** ViewerNavigationVideo - based on ViewerNavigation */ +export class ViewerNavigationVideo extends Component { + /** */ + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + } + + /** */ + handleChange = (event, newValue) => { + const { paused, setCurrentTime, setPaused } = this.props; + if (!paused) { + // In the flow of pausing, adjusting currentTime, and resuming playback, + // it is necessary to handle cases where the user explicitly pauses playback. + setPaused(true); + setCurrentTime(newValue); + setPaused(false); + } else { + setCurrentTime(newValue); + } + }; + + /** + * Renders things + */ + render() { + const { + classes, + currentTime, + duration, + muted, + paused, + setMuted, + setPaused, + } = this.props; + + const start = (duration > 3600 || duration === undefined) ? 11 : 14; + const len = (duration > 3600 || duration === undefined) ? 8 : 5; + let durationLabel = new Date(currentTime * 1000).toISOString().substr(start, len); + let slider = ''; + if (duration !== undefined) { + durationLabel = `${durationLabel} / ${new Date(duration * 1000).toISOString().substr(start, len)}`; + slider = ( + <div className={classes.sliderDiv}> + <Slider value={currentTime} min={0} max={duration} onChange={this.handleChange} /> + </div> + ); + } + return ( + <div className={classes.play_controls}> + <MiradorMenuButton + aria-label={paused ? 'Play' : 'Pause'} + className={paused ? ns('next-canvas-button') : ns('next-canvas-button')} + onClick={() => { setPaused(!paused); }} + > + { paused ? <PlayArrowRoundedIcon /> : <PauseRoundedIcon /> } + </MiradorMenuButton> + {slider} + <span className={classes.timeLabel}> + <Typography variant="caption"> + {durationLabel} + </Typography> + </span> + <MiradorMenuButton + aria-label={muted ? 'Unmute' : 'Mute'} + className={muted ? ns('next-canvas-button') : ns('next-canvas-button')} + onClick={() => { setMuted(!muted); }} + > + { muted ? <VolumeOffIcon /> : <VolumeUpIcon /> } + </MiradorMenuButton> + <span className={classes.divider} /> + </div> + ); + } +} + +ViewerNavigationVideo.propTypes = { + classes: PropTypes.objectOf(PropTypes.string).isRequired, + currentTime: PropTypes.number, + duration: PropTypes.number, + muted: PropTypes.bool, + paused: PropTypes.bool, + setCurrentTime: PropTypes.func, + setMuted: PropTypes.func, + setPaused: PropTypes.func, +}; + +ViewerNavigationVideo.defaultProps = { + currentTime: 0, + duration: undefined, + muted: false, + paused: true, + setCurrentTime: () => {}, + setMuted: () => {}, + setPaused: () => {}, +}; diff --git a/src/components/WindowCanvasNavigationControlsVideo.js b/src/components/WindowCanvasNavigationControlsVideo.js new file mode 100755 index 0000000000000000000000000000000000000000..d29879a1071386b927abf3a88f5fd00feac5d87c --- /dev/null +++ b/src/components/WindowCanvasNavigationControlsVideo.js @@ -0,0 +1,71 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import ViewerInfo from '../containers/ViewerInfo'; +import ViewerNavigation from '../containers/ViewerNavigation'; +import ViewerNavigationVideo from '../containers/ViewerNavigationVideo'; +import ns from '../config/css-ns'; +import { PluginHook } from './PluginHook'; + +/** + * WindowCanvasNavigationControlsVideo - based on WindowCanvasNavigationControls + * Represents the viewer controls in the mirador workspace. + */ +export class WindowCanvasNavigationControlsVideo extends Component { + /** + * Determine if canvasNavControls are stacked (based on a hard-coded width) + */ + canvasNavControlsAreStacked() { + const { size } = this.props; + + return (size && size.width && size.width <= 253); + } + + /** */ + render() { + const { + classes, visible, windowId, setPaused, + } = this.props; + + if (!visible) return (<Typography variant="srOnly" component="div"><ViewerInfo windowId={windowId} /></Typography>); + + return ( + <Paper + square + className={ + classNames( + classes.controls, + ns('canvas-nav'), + classes.canvasNav, + this.canvasNavControlsAreStacked() ? ns('canvas-nav-stacked') : null, + this.canvasNavControlsAreStacked() ? classes.canvasNavStacked : null, + ) + } + elevation={0} + > + + <ViewerNavigation windowId={windowId} beforeClick={setPaused} /> + <ViewerInfo windowId={windowId} /> + <ViewerNavigationVideo windowId={windowId} /> + + <PluginHook {...this.props} /> + </Paper> + ); + } +} + +WindowCanvasNavigationControlsVideo.propTypes = { + classes: PropTypes.objectOf(PropTypes.string), + setPaused: PropTypes.func, + size: PropTypes.shape({ width: PropTypes.number }).isRequired, + visible: PropTypes.bool, + windowId: PropTypes.string.isRequired, +}; + +WindowCanvasNavigationControlsVideo.defaultProps = { + classes: {}, + setPaused: () => {}, + visible: true, +}; diff --git a/src/containers/AnnotationsOverlayVideo.js b/src/containers/AnnotationsOverlayVideo.js new file mode 100755 index 0000000000000000000000000000000000000000..5840561d5dedb024821da17351a371315f04539b --- /dev/null +++ b/src/containers/AnnotationsOverlayVideo.js @@ -0,0 +1,64 @@ +/** AnnotationsOverlayVideo - based on AnnotationsOverlay */ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { withTranslation } from 'react-i18next'; +import { withPlugins } from '../extend/withPlugins'; +import { AnnotationsOverlayVideo } from '../components/AnnotationsOverlayVideo'; +import * as actions from '../state/actions'; +import { + getWindow, + getSearchAnnotationsForWindow, + getCompanionWindowsForContent, + getTheme, + getConfig, + getCurrentCanvas, + getWindowCurrentTime, + getWindowPausedStatus, + getPresentAnnotationsOnSelectedCanvases, + getSelectedAnnotationId, + getCurrentCanvasWorld, +} from '../state/selectors'; + +/** + * mapStateToProps - used to hook up connect to action creators + * @memberof Window + * @private + */ +const mapStateToProps = (state, { windowId }) => ({ + annotations: getPresentAnnotationsOnSelectedCanvases(state, { windowId }), + canvas: (getCurrentCanvas(state, { windowId }) || {}), + canvasWorld: getCurrentCanvasWorld(state, { windowId }), + currentTime: getWindowCurrentTime(state, { windowId }), + drawAnnotations: getConfig(state).window.forceDrawAnnotations || getCompanionWindowsForContent(state, { content: 'annotations', windowId }).length > 0, + drawSearchAnnotations: getConfig(state).window.forceDrawAnnotations || getCompanionWindowsForContent(state, { content: 'search', windowId }).length > 0, + highlightAllAnnotations: getWindow(state, { windowId }).highlightAllAnnotations, + hoveredAnnotationIds: getWindow(state, { windowId }).hoveredAnnotationIds, + palette: getTheme(state).palette, + paused: getWindowPausedStatus(state, { windowId }), + searchAnnotations: getSearchAnnotationsForWindow( + state, + { windowId }, + ), + selectedAnnotationId: getSelectedAnnotationId(state, { windowId }), +}); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = (dispatch, { windowId }) => ({ + deselectAnnotation: (...args) => dispatch(actions.deselectAnnotation(...args)), + hoverAnnotation: (...args) => dispatch(actions.hoverAnnotation(...args)), + selectAnnotation: (...args) => dispatch(actions.selectAnnotation(...args)), + setCurrentTime: (...args) => dispatch(actions.setWindowCurrentTime(windowId, ...args)), + setPaused: (...args) => dispatch(actions.setWindowPaused(windowId, ...args)), +}); + +const enhance = compose( + withTranslation(), + connect(mapStateToProps, mapDispatchToProps), + withPlugins('AnnotationsOverlayVideo'), +); + +export default enhance(AnnotationsOverlayVideo); diff --git a/src/containers/VideoViewer.js b/src/containers/VideoViewer.js index b7d3fa8b4e46500bf1608218cb62a442616f5f56..d75b83509c093b8b7470ded254cb951084683d93 100644 --- a/src/containers/VideoViewer.js +++ b/src/containers/VideoViewer.js @@ -3,26 +3,49 @@ import { compose } from 'redux'; import { withTranslation } from 'react-i18next'; import { withStyles } from '@material-ui/core'; import { withPlugins } from '../extend/withPlugins'; +import * as actions from '../state/actions'; import { VideoViewer } from '../components/VideoViewer'; -import { getVisibleCanvasCaptions, getVisibleCanvasVideoResources } from '../state/selectors'; +import { + getCurrentCanvas, + getCurrentCanvasWorld, + getWindowMutedStatus, + getWindowPausedStatus, + getWindowCurrentTime, +} from '../state/selectors'; /** */ -const mapStateToProps = (state, { windowId }) => ( - { - captions: getVisibleCanvasCaptions(state, { windowId }) || [], - videoResources: getVisibleCanvasVideoResources(state, { windowId }) || [], - } -); +const mapStateToProps = (state, { windowId }) => ({ + canvas: (getCurrentCanvas(state, { windowId }) || {}), + canvasWorld: getCurrentCanvasWorld(state, { windowId }), + currentTime: getWindowCurrentTime(state, { windowId }), + muted: getWindowMutedStatus(state, { windowId }), + paused: getWindowPausedStatus(state, { windowId }), +}); + +/** */ +const mapDispatchToProps = (dispatch, { windowId }) => ({ + setCurrentTime: (...args) => dispatch(actions.setWindowCurrentTime(windowId, ...args)), + setPaused: (...args) => dispatch(actions.setWindowPaused(windowId, ...args)), +}); /** */ const styles = () => ({ - container: { + flexContainer: { alignItems: 'center', display: 'flex', width: '100%', }, + flexFill: { + height: '100%', + position: 'relative', + width: '100%', + }, video: { + height: '100%', maxHeight: '100%', + maxWidth: '100%', + 'object-fit': 'scale-down', + 'object-position': 'left top', width: '100%', }, }); @@ -30,7 +53,7 @@ const styles = () => ({ const enhance = compose( withTranslation(), withStyles(styles), - connect(mapStateToProps, null), + connect(mapStateToProps, mapDispatchToProps), withPlugins('VideoViewer'), ); diff --git a/src/containers/ViewerNavigationVideo.js b/src/containers/ViewerNavigationVideo.js new file mode 100755 index 0000000000000000000000000000000000000000..8f411ae3e2b31b703b55cc11f87457b36b3bc8d9 --- /dev/null +++ b/src/containers/ViewerNavigationVideo.js @@ -0,0 +1,71 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { withTranslation } from 'react-i18next'; +import { withStyles } from '@material-ui/core/styles'; +import { withPlugins } from '../extend/withPlugins'; +import * as actions from '../state/actions'; +import { ViewerNavigationVideo } from '../components/ViewerNavigationVideo'; +import { + getCurrentCanvasDuration, + getWindowCurrentTime, + getWindowMutedStatus, + getWindowPausedStatus, +} from '../state/selectors'; + +/** */ +const mapStateToProps = (state, { windowId }) => ({ + currentTime: getWindowCurrentTime(state, { windowId }), + duration: getCurrentCanvasDuration(state, { windowId }), + muted: getWindowMutedStatus(state, { windowId }), + paused: getWindowPausedStatus(state, { windowId }), +}); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestForm + * @private + */ +const mapDispatchToProps = (dispatch, { windowId }) => ({ + setCurrentTime: (...args) => dispatch(actions.setWindowCurrentTime(windowId, ...args)), + setMuted: (...args) => dispatch(actions.setWindowMuted(windowId, ...args)), + setPaused: (...args) => dispatch(actions.setWindowPaused(windowId, ...args)), +}); + +const styles = { + divider: { + borderRight: '1px solid #808080', + display: 'inline-block', + height: '24px', + margin: '12px 6px', + }, + ListItem: { + paddingBottom: 0, + paddingTop: 0, + }, + play_controls: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + }, + sliderDiv: { + alignItems: 'center', + display: 'flex', + paddingLeft: '10px', + paddingRight: '15px', + width: '200px', + }, + timeLabel: { + alignItems: 'center', + display: 'flex', + }, +}; + +const enhance = compose( + withStyles(styles), + withTranslation(), + connect(mapStateToProps, mapDispatchToProps), + withPlugins('ViewerNavigationVideo'), + // further HOC go here +); + +export default enhance(ViewerNavigationVideo); diff --git a/src/containers/WindowCanvasNavigationControlsVideo.js b/src/containers/WindowCanvasNavigationControlsVideo.js new file mode 100755 index 0000000000000000000000000000000000000000..6c0ed55e85f0ea4579d478be5ac436252afc93ec --- /dev/null +++ b/src/containers/WindowCanvasNavigationControlsVideo.js @@ -0,0 +1,53 @@ +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { withSize } from 'react-sizeme'; +import { withStyles } from '@material-ui/core'; +import { fade } from '@material-ui/core/styles/colorManipulator'; +import { withPlugins } from '../extend/withPlugins'; +import { getWorkspace } from '../state/selectors'; +import { WindowCanvasNavigationControlsVideo } from '../components/WindowCanvasNavigationControlsVideo'; +import * as actions from '../state/actions'; + +/** */ +const mapStateToProps = (state, { windowId }) => ({ + visible: getWorkspace(state).focusedWindowId === windowId, +}); + +/** */ +const mapDispatchToProps = (dispatch, { windowId }) => ({ + setPaused: (...args) => dispatch(actions.setWindowPaused(windowId, ...args)), +}); + +/** + * + * @param theme + */ +const styles = theme => ({ + canvasNav: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + textAlign: 'center', + }, + canvasNavStacked: { + flexDirection: 'column', + }, + controls: { + backgroundColor: fade(theme.palette.background.paper, 0.5), + bottom: 0, + position: 'absolute', + width: '100%', + zIndex: 50, + }, +}); + +const enhance = compose( + connect(mapStateToProps), + withStyles(styles), + withSize(), + connect(mapStateToProps, mapDispatchToProps), + withPlugins('WindowCanvasNavigationControlsVideo'), +); + +export default enhance(WindowCanvasNavigationControlsVideo); diff --git a/src/lib/AnnotationItem.js b/src/lib/AnnotationItem.js index 7a57a7b17b7b158ae6f3207d0cd34065fd00d382..d927ec07189fd880381d806ec074370cad70a6fe 100644 --- a/src/lib/AnnotationItem.js +++ b/src/lib/AnnotationItem.js @@ -27,7 +27,7 @@ export default class AnnotationItem { const target = this.target[0]; switch (typeof target) { case 'string': - return target.replace(/#?xywh=(.*)$/, ''); + return target.replace(/#(.*)$/, ''); case 'object': return (target.source && target.source.id) || target.source || target.id; default: @@ -106,16 +106,50 @@ export default class AnnotationItem { switch (typeof selector) { case 'string': - match = selector.match(/xywh=(.*)$/); + match = selector.match(/xywh=(.*?)(&|$)/); break; case 'object': fragmentSelector = selector.find(s => s.type && s.type === 'FragmentSelector'); - match = fragmentSelector && fragmentSelector.value.match(/xywh=(.*)$/); + match = fragmentSelector && fragmentSelector.value.match(/xywh=(.*?)(&|$)/); break; default: return null; } - return match && match[1].split(',').map(str => parseInt(str, 10)); + if (match) { + const params = match[1].split(','); + if (params.length === 4) { + return params.map(str => parseInt(str, 10)); + } + } + return null; + } + + /** */ + get temporalfragmentSelector() { + const { selector } = this; + + let match; + let temporalfragmentSelector; + + switch (typeof selector) { + case 'string': + match = selector.match(/t=(.*?)(&|$)/); + break; + case 'object': + temporalfragmentSelector = selector.find(s => s.type && s.type === 'FragmentSelector'); + match = temporalfragmentSelector && temporalfragmentSelector.value.match(/t=(.*?)(&|$)/); + break; + default: + return null; + } + + if (match) { + const params = match[1].split(','); + if (params.length < 3) { + return params.map(str => parseFloat(str)); + } + } + return null; } } diff --git a/src/lib/AnnotationResource.js b/src/lib/AnnotationResource.js index 8834403d19f96c17e33788ced355d9d92a8a1a7f..ce2167c424a610a443aead1df3314f6357849cb0 100644 --- a/src/lib/AnnotationResource.js +++ b/src/lib/AnnotationResource.js @@ -28,9 +28,9 @@ export default class AnnotationResource { const on = this.on[0]; switch (typeof on) { case 'string': - return on.replace(/#?xywh=(.*)$/, ''); + return on.replace(/#(.*)$/, ''); case 'object': - return on.full.replace(/#?xywh=(.*)$/, ''); + return on.full.replace(/#(.*)$/, ''); default: return null; } @@ -109,15 +109,47 @@ export default class AnnotationResource { switch (typeof selector) { case 'string': - match = selector.match(/xywh=(.*)$/); + match = selector.match(/xywh=(.*?)(&|$)/); break; case 'object': - match = selector.value.match(/xywh=(.*)$/); + match = selector.value.match(/xywh=(.*?)(&|$)/); break; default: return null; } - return match && match[1].split(',').map(str => parseInt(str, 10)); + if (match) { + const params = match[1].split(','); + if (params.length === 4) { + return params.map(str => parseInt(str, 10)); + } + } + return null; + } + + /** */ + get temporalfragmentSelector() { + const { selector } = this; + + let match; + + switch (typeof selector) { + case 'string': + match = selector.match(/t=(.*?)(&|$)/); + break; + case 'object': + match = selector.value.match(/t=(.*?)(&|$)/); + break; + default: + return null; + } + + if (match) { + const params = match[1].split(','); + if (params.length < 3) { + return params.map(str => parseFloat(str)); + } + } + return null; } } diff --git a/src/lib/CanvasAnnotationDisplay.js b/src/lib/CanvasAnnotationDisplay.js index 8f949eae8f85a94045a5336dc1cb44b7eee2b929..46be9292df09febf2c845e966fa86c3bcbcf65d1 100644 --- a/src/lib/CanvasAnnotationDisplay.js +++ b/src/lib/CanvasAnnotationDisplay.js @@ -5,7 +5,7 @@ export default class CanvasAnnotationDisplay { /** */ constructor({ - resource, palette, zoomRatio, offset, selected, hovered, + resource, palette, zoomRatio, offset, selected, hovered, imageSource, canvasSize, }) { this.resource = resource; this.palette = palette; @@ -13,6 +13,8 @@ export default class CanvasAnnotationDisplay { this.offset = offset; this.selected = selected; this.hovered = hovered; + this.imageSource = imageSource; + this.canvasSize = canvasSize; } /** */ @@ -104,7 +106,8 @@ export default class CanvasAnnotationDisplay { /** */ fragmentContext() { - const fragment = this.resource.fragmentSelector; + const fragment = this.resource.fragmentSelector || this.canvasSize; + if (!fragment) { return; } fragment[0] += this.offset.x; fragment[1] += this.offset.y; @@ -131,6 +134,10 @@ export default class CanvasAnnotationDisplay { this.context.strokeRect(...fragment); } + if (this.imageSource) { + this.context.drawImage(this.imageSource, ...fragment); + } + this.context.restore(); } diff --git a/src/lib/CanvasOverlayVideo.js b/src/lib/CanvasOverlayVideo.js new file mode 100755 index 0000000000000000000000000000000000000000..5c4e1af5860a4470c458e8804d1529f611b9c187 --- /dev/null +++ b/src/lib/CanvasOverlayVideo.js @@ -0,0 +1,109 @@ +/** + * CanvasOverlayVideo - based on the framework of OpenSeadragonCanvasOverlay + * + * OpenSeadragonCanvasOverlay - adapted from https://github.com/altert/OpenSeadragonCanvasOverlay + * Code ported from https://github.com/altert/OpenSeadragonCanvasOverlay + * carries a BSD 3-Clause license originally authored by @altert from + * https://github.com/altert/OpenseadragonFabricjsOverlay + */ +export default class CanvasOverlayVideo { + /** + * constructor - sets up the Canvas overlay container + * @param canvasSize {Array} IIIF Canvas size + * If the width and height properties are not specified in the Canvas in the Manifest, + * canvasSize will be [0, 0, 0, 0]. + */ + constructor(video, ref, canvasSize) { + this.video = video; + this.ref = ref; + const [ + _canvasX, _canvasY, canvasWidth, canvasHeight, // eslint-disable-line no-unused-vars + ] = canvasSize; + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + + this.containerWidth = 0; + this.containerHeight = 0; + } + + /** */ + get canvas() { + return this.ref.current; + } + + /** */ + get context2d() { + return this.canvas ? this.canvas.getContext('2d') : null; + } + + /** + * scale - get the display scaling factor of the HTML5 canvas. + * It is assumed that the size of the Canvas in the Manifest is equal to the size of the video. + * This will not work correctly if multiple videos are placed on the Canvas. + */ + get scale() { + let ratio = 1; + + if (this.video) { + const { videoWidth, videoHeight } = this.video; + if (videoWidth && videoHeight) { + const ratioWidth = this.containerWidth / videoWidth; + const rationHeight = this.containerHeight / videoHeight; + ratio = Math.min(ratioWidth, rationHeight); + if (ratio > 1) { + const objectFit = getComputedStyle(this.video, null).getPropertyValue('object-fit'); + if (objectFit === 'scale-down' || objectFit === 'none') { + ratio = 1; + } + } + } + } else if (this.canvasWidth && this.canvasHeight) { + // video is not loaded yet & canvas size is specified + const ratioWidth = this.containerWidth / this.canvasWidth; + const rationHeight = this.containerHeight / this.canvasHeight; + ratio = Math.min(ratioWidth, rationHeight); + if (ratio > 1) { + ratio = 1; + } + } + + return ratio; + } + + /** */ + clear() { + if (this.context2d) { + this.context2d.clearRect(0, 0, this.containerWidth, this.containerHeight); + } + } + + /** + * resize - resizes the added Canvas overlay. + */ + resize() { + if (!this.video || !this.canvas) { return; } + + if (this.containerWidth !== this.video.clientWidth) { + this.containerWidth = this.video.clientWidth; + this.canvas.setAttribute('width', this.containerWidth); + } + + if (this.containerHeight !== this.video.clientHeight) { + this.containerHeight = this.video.clientHeight; + this.canvas.setAttribute('height', this.containerHeight); + } + } + + /** + * canvasUpdate + * @param {Function} update + */ + canvasUpdate(update) { + if (!this.context2d) { return; } + + const ratio = this.scale; + this.context2d.scale(ratio, ratio); + update(); + this.context2d.setTransform(1, 0, 0, 1, 0, 0); + } +} diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index c1f124472712257053c1c755913de1a2e0d09099..9c610f53b2ddfd3b1b6af92f981d502bb143d840 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -72,6 +72,10 @@ const ActionTypes = { REMOVE_RESOURCE: 'mirador/REMOVE_RESOURCE', SHOW_COLLECTION_DIALOG: 'mirador/SHOW_COLLECTION_DIALOG', HIDE_COLLECTION_DIALOG: 'mirador/HIDE_COLLECTION_DIALOG', + + SET_CURRENT_TIME: 'mirador/SET_CURRENT_TIME', + SET_VIDEO_PAUSED: 'mirador/SET_VIDEO_PAUSED', + SET_VIDEO_MUTED: 'mirador/SET_VIDEO_MUTED', }; export default ActionTypes; diff --git a/src/state/actions/window.js b/src/state/actions/window.js index 1534e5403efbe87a6c5c4412aecf5b1e55247f08..52d9b4fdb2c10158084a47815d603ff49a1dee5b 100644 --- a/src/state/actions/window.js +++ b/src/state/actions/window.js @@ -204,3 +204,36 @@ export function hideCollectionDialog(windowId) { windowId, }; } + +/** */ +export function setWindowCurrentTime(windowId, currentTime) { + return ((dispatch) => { + dispatch({ + currentTime, + type: ActionTypes.SET_CURRENT_TIME, + windowId, + }); + }); +} + +/** */ +export function setWindowPaused(windowId, paused) { + return ((dispatch) => { + dispatch({ + paused: (paused === undefined) ? true : paused, + type: ActionTypes.SET_VIDEO_PAUSED, + windowId, + }); + }); +} + +/** */ +export function setWindowMuted(windowId, muted) { + return ((dispatch) => { + dispatch({ + muted: (muted === undefined) ? false : muted, + type: ActionTypes.SET_VIDEO_MUTED, + windowId, + }); + }); +} diff --git a/src/state/reducers/windows.js b/src/state/reducers/windows.js index 33853fb8c7af27ec4c23fddf27571a69a660dac3..3734acdba81c15620e758ac5fa1819e3b81793b9 100644 --- a/src/state/reducers/windows.js +++ b/src/state/reducers/windows.js @@ -75,6 +75,7 @@ export const windowsReducer = (state = {}, action) => { { ...(orig || {}), canvasId: action.canvasId, + currentTime: 0, visibleCanvases: action.visibleCanvases || [], }), state); case ActionTypes.ADD_COMPANION_WINDOW: @@ -168,6 +169,30 @@ export const windowsReducer = (state = {}, action) => { collectionDialogOn: false, }, }; + case ActionTypes.SET_CURRENT_TIME: + return { + ...state, + [action.windowId]: { + ...state[action.windowId], + currentTime: action.currentTime, + }, + }; + case ActionTypes.SET_VIDEO_PAUSED: + return { + ...state, + [action.windowId]: { + ...state[action.windowId], + paused: !!action.paused, + }, + }; + case ActionTypes.SET_VIDEO_MUTED: + return { + ...state, + [action.windowId]: { + ...state[action.windowId], + muted: !!action.muted, + }, + }; default: return state; } diff --git a/src/state/selectors/canvases.js b/src/state/selectors/canvases.js index 8078bf823c8385a81582e75b9d9e8f13f961294c..91674d44d935c24372db22a52073e7304ed851ba 100644 --- a/src/state/selectors/canvases.js +++ b/src/state/selectors/canvases.js @@ -229,3 +229,15 @@ export const selectInfoResponse = createSelector( && infoResponses[iiifServiceId]; }, ); + +export const getCurrentCanvasDuration = createSelector( + [ + getCurrentCanvas, + ], + (canvas) => { + if (canvas && canvas.__jsonld && 'duration' in canvas.__jsonld) { + return canvas.__jsonld.duration; + } + return undefined; + }, +); diff --git a/src/state/selectors/index.js b/src/state/selectors/index.js index bad2660352779524c7324cf25d3f33f32ec1cef9..60f94ef782ccd389d587dab61a3c0f5fa3966e9f 100644 --- a/src/state/selectors/index.js +++ b/src/state/selectors/index.js @@ -13,3 +13,4 @@ export * from './sequences'; export * from './auth'; export * from './utils'; export * from './viewer'; +export * from './window'; diff --git a/src/state/selectors/window.js b/src/state/selectors/window.js new file mode 100755 index 0000000000000000000000000000000000000000..ebf7082a65a3965a8fa50903012a3eed6c9673b5 --- /dev/null +++ b/src/state/selectors/window.js @@ -0,0 +1,35 @@ +import { createSelector } from 'reselect'; +import { getWindow } from './getters'; + +export const getWindowCurrentTime = createSelector( + [ + getWindow, + ], + (window) => { + if (!window) return undefined; + + return window.currentTime; + }, +); + +export const getWindowPausedStatus = createSelector( + [ + getWindow, + ], + (window) => { + if (!window) return undefined; + + return window.paused; + }, +); + +export const getWindowMutedStatus = createSelector( + [ + getWindow, + ], + (window) => { + if (!window) return undefined; + + return window.muted; + }, +);