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
 ![Node.js CI](https://github.com/ProjectMirador/mirador/workflows/Node.js%20CI/badge.svg) [![codecov](https://codecov.io/gh/ProjectMirador/mirador/branch/master/graph/badge.svg)](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;
+  },
+);