From 5a57a5db1925c736ba27d6ef2d8221947d4cab16 Mon Sep 17 00:00:00 2001 From: mistic100 Date: Thu, 15 Apr 2021 21:11:35 +0200 Subject: [PATCH 01/12] Close #128 video support --- docs/.jsdoc/index.md | 7 +- docs/.vuepress/config.js | 5 +- .../.vuepress/public/assets/cubemap-video.png | Bin 0 -> 10646 bytes docs/README.md | 4 +- docs/guide/adapters/README.md | 2 + docs/guide/adapters/cubemap-tiles.md | 16 +- docs/guide/adapters/cubemap-video.md | 65 ++++ docs/guide/adapters/cubemap.md | 4 - docs/guide/adapters/equirectangular-tiles.md | 6 +- docs/guide/adapters/equirectangular-video.md | 58 ++++ docs/guide/config.md | 6 +- docs/guide/events.md | 2 +- docs/guide/methods.md | 4 +- docs/guide/navbar.md | 4 +- docs/plugins/plugin-video.md | 128 ++++++++ example/cubemap-video.html | 70 +++++ example/equirectangular-video.html | 72 +++++ example/index.html | 3 + rollup.config.js | 2 +- src/Viewer.js | 8 +- src/adapters/cubemap-tiles/index.js | 7 +- src/adapters/cubemap-video/index.js | 184 +++++++++++ src/adapters/cubemap/index.js | 1 + src/adapters/equirectangular-tiles/index.js | 17 +- src/adapters/equirectangular-video/index.js | 97 ++++++ src/adapters/equirectangular/index.js | 12 +- src/adapters/shared/AbstractVideoAdapter.js | 229 ++++++++++++++ .../{tiles-shared => shared}/Queue.js | 0 src/adapters/{tiles-shared => shared}/Task.js | 0 .../utils.js => shared/tiles-utils.js} | 0 src/buttons/AbstractButton.js | 8 + src/buttons/AbstractMoveButton.js | 2 + src/buttons/AbstractZoomButton.js | 2 + src/buttons/ZoomRangeButton.js | 112 ++----- src/components/Navbar.js | 34 +- src/components/NavbarCaption.js | 9 +- src/data/constants.js | 8 + src/index.js | 19 +- src/plugins/settings/constants.js | 13 +- src/plugins/settings/index.js | 2 - src/plugins/settings/style.scss | 9 + src/plugins/video/PauseOverlay.js | 69 +++++ src/plugins/video/PlayPauseButton.js | 82 +++++ src/plugins/video/ProgressBar.js | 147 +++++++++ src/plugins/video/TimeCaption.js | 73 +++++ src/plugins/video/VolumeButton.js | 172 +++++++++++ src/plugins/video/constants.js | 35 +++ src/plugins/video/index.js | 290 ++++++++++++++++++ src/plugins/video/pause.svg | 1 + src/plugins/video/play.svg | 1 + src/plugins/video/style.scss | 199 ++++++++++++ src/plugins/video/utils.js | 8 + src/plugins/video/volume.svg | 1 + .../virtual-tour/AbstractDatasource.js | 2 +- .../virtual-tour/ClientSideDatasource.js | 2 +- .../virtual-tour/ServerSideDatasource.js | 2 +- src/services/EventsHandler.js | 7 +- src/services/Renderer.js | 4 +- src/styles/navbar.scss | 2 +- src/styles/panel.scss | 8 +- src/{ => utils}/Animation.js | 13 +- src/utils/Dynamic.js | 3 +- src/utils/DynamicXD.js | 4 +- src/utils/MultiDynamic.js | 9 +- src/utils/PressHandler.js | 3 +- src/utils/Slider.js | 187 +++++++++++ src/utils/index.js | 5 + types/Viewer.d.ts | 11 +- types/adapters/AbstractAdapter.d.ts | 2 +- types/adapters/cubemap-video/index.d.ts | 23 ++ .../adapters/equirectangular-video/index.d.ts | 23 ++ types/buttons/AbstractButton.d.ts | 5 + types/index.d.ts | 18 +- types/plugins/video/index.d.ts | 73 +++++ types/{ => utils}/Animation.d.ts | 0 types/utils/index.d.ts | 2 + 76 files changed, 2498 insertions(+), 219 deletions(-) create mode 100644 docs/.vuepress/public/assets/cubemap-video.png create mode 100644 docs/guide/adapters/cubemap-video.md create mode 100644 docs/guide/adapters/equirectangular-video.md create mode 100644 docs/plugins/plugin-video.md create mode 100644 example/cubemap-video.html create mode 100644 example/equirectangular-video.html create mode 100644 src/adapters/cubemap-video/index.js create mode 100644 src/adapters/equirectangular-video/index.js create mode 100644 src/adapters/shared/AbstractVideoAdapter.js rename src/adapters/{tiles-shared => shared}/Queue.js (100%) rename src/adapters/{tiles-shared => shared}/Task.js (100%) rename src/adapters/{tiles-shared/utils.js => shared/tiles-utils.js} (100%) create mode 100644 src/plugins/video/PauseOverlay.js create mode 100644 src/plugins/video/PlayPauseButton.js create mode 100644 src/plugins/video/ProgressBar.js create mode 100644 src/plugins/video/TimeCaption.js create mode 100644 src/plugins/video/VolumeButton.js create mode 100644 src/plugins/video/constants.js create mode 100644 src/plugins/video/index.js create mode 100644 src/plugins/video/pause.svg create mode 100644 src/plugins/video/play.svg create mode 100644 src/plugins/video/style.scss create mode 100644 src/plugins/video/utils.js create mode 100644 src/plugins/video/volume.svg rename src/{ => utils}/Animation.js (94%) create mode 100644 src/utils/Slider.js create mode 100644 types/adapters/cubemap-video/index.d.ts create mode 100644 types/adapters/equirectangular-video/index.d.ts create mode 100644 types/plugins/video/index.d.ts rename types/{ => utils}/Animation.d.ts (100%) diff --git a/docs/.jsdoc/index.md b/docs/.jsdoc/index.md index 9b2498530..b203acc4e 100644 --- a/docs/.jsdoc/index.md +++ b/docs/.jsdoc/index.md @@ -16,13 +16,12 @@ ## Exported members - [AbstractAdapter](PSV.adapters.AbstractAdapter.html) - Base class for render adapters -- [AbstractButton](PSV.buttons.AbstractButton.html) - Base class for plugins buttons +- [AbstractButton](PSV.buttons.AbstractButton.html) - Base class for buttons - [AbstractPlugin](PSV.plugins.AbstractPlugin.html) - Base class for plugins -- [Animation](PSV.Animation.html) - Animations manager - [CONSTANTS](PSV.constants.html) - All internal constants - [DEFAULTS](PSV.html#.DEFAULTS) - Default configuration - [PSVError](PSV.PSVError.html) - Generic error -- [registerButton](PSV.html#.registerButton) - Helper for registering plugins buttons +- [registerButton](PSV.html#.registerButton) - Helper for registering buttons - [SYSTEM](PSV.html#.SYSTEM) - System informations -- [Viewer](PSV.Viewer.html) - Base class - [utils](PSV.utils.html) - Various utilities +- [Viewer](PSV.Viewer.html) - Base class diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index b80af97e9..5c2222f82 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -48,8 +48,10 @@ module.exports = { children : [ 'adapters/equirectangular', 'adapters/equirectangular-tiles', + ['adapters/equirectangular-video', 'Equirectangular videos (NEW)'], 'adapters/cubemap', - ['adapters/cubemap-tiles', 'Cubemap tiles (NEW)'], + 'adapters/cubemap-tiles', + ['adapters/cubemap-video', 'Cubemap videos (NEW)'], ], }, { @@ -88,6 +90,7 @@ module.exports = { 'plugin-resolution', 'plugin-settings', 'plugin-stereo', + ['plugin-video', 'Video (NEW)'], 'plugin-virtual-tour', 'plugin-visible-range', ], diff --git a/docs/.vuepress/public/assets/cubemap-video.png b/docs/.vuepress/public/assets/cubemap-video.png new file mode 100644 index 0000000000000000000000000000000000000000..65989b2f5f9f4a7863469616569b772324360cd8 GIT binary patch literal 10646 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 +# Cubemap tiles > Reduce the initial loading time and used bandwidth by slicing big cubemap panoramas into many small tiles. @@ -11,11 +11,11 @@ new PhotoSphereViewer.Viewer({ faceSize: 6000, nbTiles: 8, baseUrl: { - left : 'left_low.jpg', - front : 'front_low.jpg', - right : 'right_low.jpg', - back : 'back_low.jpg', - top : 'top_low.jpg', + left : 'left_low.jpg', + front : 'front_low.jpg', + right : 'right_low.jpg', + back : 'back_low.jpg', + top : 'top_low.jpg', bottom: 'bottom_low.jpg', }, tileUrl: (face, col, row) => { @@ -25,10 +25,6 @@ new PhotoSphereViewer.Viewer({ }); ``` -::: warning -This adapter does not use `panoData` option. You can use `sphereCorrection` if the tilt/roll/pan needs to be corrected. -::: - ## Example diff --git a/docs/guide/adapters/cubemap-video.md b/docs/guide/adapters/cubemap-video.md new file mode 100644 index 000000000..82c18f483 --- /dev/null +++ b/docs/guide/adapters/cubemap-video.md @@ -0,0 +1,65 @@ +# Cubemap video + +```js +new PhotoSphereViewer.Viewer({ + adapter: [PhotoSphereViewer.CubemapVideoAdapter, { + autoplay: false, // default + muted: false, // default + }], + panorama: { + source: 'path/video.mp4', // also supports webm + }, + plugins: [ + PhotoSphereViewer.VideoPlugin, + ], +}); +``` + +::: warning +This adapter requires to use the [VideoPlugin](../../plugins/plugin-video.md). +::: + + +## Example + + + + +## Configuration + +#### `autoplay` +- type: `boolean` +- default: `false` + +Automatically starts the video on load. + +#### `muted` +- type: `boolean` +- default: `false` (`true` if `autoplay=true`) + +Mute the video by default. + +#### `equiangular` +- type: `boolean` +- default: `true` + +Set to `true` when using an equiangular cubemap (EAC), which is the format used by Youtube. Set to `false` when using a standard cubemap. + + +## Panorama options + +When using this adapter the `panorama` option and the `setPanorama()` method accept an object to configure the video. + +#### `source` (required) +- type: `string` + +Path of the video file. The video must not be larger than 4096 pixels or it won't be displayed on handled devices. + + +### Video format + +This adapter supports video files consisting of a grid of the six faces of the cube, as used by Youtube for example. + +The layout of a frame must be as follow: + +![cubemap-video](/assets/cubemap-video.png) diff --git a/docs/guide/adapters/cubemap.md b/docs/guide/adapters/cubemap.md index edd4951ef..29b65ab4f 100644 --- a/docs/guide/adapters/cubemap.md +++ b/docs/guide/adapters/cubemap.md @@ -20,10 +20,6 @@ new PhotoSphereViewer.Viewer({ }); ``` -::: warning -This adapter does not use `panoData` option. You can use `sphereCorrection` if the tilt/roll/pan needs to be corrected. -::: - ## Example diff --git a/docs/guide/adapters/equirectangular-tiles.md b/docs/guide/adapters/equirectangular-tiles.md index 0ff3acce1..6f6d1dc41 100644 --- a/docs/guide/adapters/equirectangular-tiles.md +++ b/docs/guide/adapters/equirectangular-tiles.md @@ -19,10 +19,6 @@ new PhotoSphereViewer.Viewer({ }); ``` -::: warning -This adapter does not use `panoData` option. You can use `sphereCorrection` if the tilt/roll/pan needs to be corrected. -::: - ## Example @@ -47,7 +43,7 @@ Shows a warning sign on tiles that cannot be loaded. - type: `number` - default: `64` -The number of faces of the sphere geometry used to display the panorama, higher values can reduce deformations on straight lines at the cost of performances. +The number of faces of the sphere geometry used to display the panorama, higher values can reduce deformations on straight lines at the cost of performances. _Note: the actual number of faces is `resolution² / 2`._ diff --git a/docs/guide/adapters/equirectangular-video.md b/docs/guide/adapters/equirectangular-video.md new file mode 100644 index 000000000..c6f6236fc --- /dev/null +++ b/docs/guide/adapters/equirectangular-video.md @@ -0,0 +1,58 @@ +# Equirectangular video + +```js +new PhotoSphereViewer.Viewer({ + adapter: [PhotoSphereViewer.EquirectangularVideoAdapter, { + autoplay: false, // default + muted: false, // default + }], + panorama: { + source: 'path/video.mp4', // also supports webm + }, + plugins: [ + PhotoSphereViewer.VideoPlugin, + ], +}); +``` + +::: warning +This adapter requires to use the [VideoPlugin](../../plugins/plugin-video.md). +::: + + +## Example + + + + +## Configuration + +#### `autoplay` +- type: `boolean` +- default: `false` + +Automatically starts the video on load. + +#### `muted` +- type: `boolean` +- default: `false` (`true` if `autoplay=true`) + +Mute the video by default. + +#### `resolution` +- type: `number` +- default: `64` + +The number of faces of the sphere geometry used to display the panorama, higher values can reduce deformations on straight lines at the cost of performances. + +_Note: the actual number of faces is `resolution² / 2`._ + + +## Panorama options + +When using this adapter the `panorama` option and the `setPanorama()` method accept an object to configure the video. + +#### `source` (required) +- type: `string` + +Path of the video file. The video must not be larger than 4096 pixels or it won't be displayed on handled devices. diff --git a/docs/guide/config.md b/docs/guide/config.md index f6cd5b590..8b2d8ee40 100644 --- a/docs/guide/config.md +++ b/docs/guide/config.md @@ -189,7 +189,7 @@ Allows to rotate the panorama sphere. Angles are in radians. **Note** : if the XMP data and/or `panoData` contains heading/pitch/roll data, they will be applied before `sphereCorrection`. -![pan-tilt-toll](/assets//pan-tilt-roll.png) +![pan-tilt-toll](/assets/pan-tilt-roll.png) #### `moveSpeed` - type: `double` @@ -244,6 +244,10 @@ panoData: (image) => ({ **Note** : if the XMP data and/or `panoData` contains heading/pitch/roll data, they will be applied before `sphereCorrection`. +::: warning +Only the default `equirectangular` adapter supports `panoData`, for other adapters you can only use [`sphereCorrection`](#spherecorrection) if the tilt/roll/pan needs to be corrected. +::: + #### `requestHeaders` - type: `object | function` diff --git a/docs/guide/events.md b/docs/guide/events.md index 4053f1e8c..c6723623d 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -10,7 +10,7 @@ Event listeners take an `Event` object as first parameter, this object is genera ## Main events -This section describes the most useful events available, remember to check the for a full list. +This section describes the most useful events available. ### `click(data)` | `dblclick(data)` diff --git a/docs/guide/methods.md b/docs/guide/methods.md index 50a909ba6..01ae4696d 100644 --- a/docs/guide/methods.md +++ b/docs/guide/methods.md @@ -28,7 +28,7 @@ viewer.once('ready', () => { ## Main methods -This section describes the most useful methods available, remember to check the for a full list. +This section describes the most useful methods available. ### `animate(options): Animation` @@ -39,7 +39,7 @@ viewer.animate({ longitude: Math.PI / 2, latitude: '20deg', zoom: 50, - speed: '-2rpm', + speed: '2rpm', }) .then(() => /* animation complete */); ``` diff --git a/docs/guide/navbar.md b/docs/guide/navbar.md index 82542439d..43bcce614 100644 --- a/docs/guide/navbar.md +++ b/docs/guide/navbar.md @@ -26,7 +26,7 @@ Some [plugins](../plugins/) add new buttons to the navbar and will be automatica ## Custom buttons -You can also add as many custom buttons you want. A Custom buttons is an object with the following options. +You can also add as many custom buttons you want. A custom button is an object with the following options: #### `content` (required) - type : `string` @@ -83,7 +83,7 @@ new PhotoSphereViewer.Viewer({ 'zoom', { id: 'my-button', - content: 'Custom', + content: '', title: 'Hello world', className: 'custom-button', onClick: () => { diff --git a/docs/plugins/plugin-video.md b/docs/plugins/plugin-video.md new file mode 100644 index 000000000..ce3aa7032 --- /dev/null +++ b/docs/plugins/plugin-video.md @@ -0,0 +1,128 @@ +# VideoPlugin + + + +> Adds controls to the video [adapters](../guide/adapters). + +This plugin is available in the core `photo-sphere-viewer` package in `dist/plugins/video.js` and `dist/plugins/video.css`. + + +## Usage + +To use this plugin you must also load one of the video adapters : [equirectangular](../guide/adapters/equirectangular-video.md) or [cubemap](../guide/adapters/cubemap-video.md). + +Once enabled it will add various elements to the viewer: + +- Play/pause button +- Volume button +- time indicator in the navbar +- Progress bar above the navbar +- Play button in the center of the viewer + +```js +const viewer = new PhotoSphereViewer.Viewer({ + adapter: PhotoSphereViewer.EquirectangularVideoAdapter, + panorama: { + source: 'path/video.mp4', + }, + plugins: [ + [PhotoSphereViewer.VideoPlugin, {}], + ], +}); +``` + +### Multi resolution + +You can offer multiple resolutions of your video with the [ResolutionPlugin](./plugin-resolution.md). + +```js +const viewer = new PhotoSphereViewer.Viewer({ + adapter: PhotoSphereViewer.EquirectangularVideoAdapter, + panorama: { + source: 'path/video-fhd.mp4', + }, + plugins: [ + PhotoSphereViewer.VideoPlugin, + PhotoSphereViewer.SettingsPlugin, + [PhotoSphereViewer.ResolutionPlugin, { + resolutions: [ + { + id : 'UHD', + label : 'Ultra high', + panorama: { source: 'path/video-uhd.mp4' }, + }, + { + id : 'FHD', + label : 'High', + panorama: { source: 'path/video-fhd.mp4' }, + }, + { + id : 'HD', + label : 'Standard', + panorama: { source: 'path/video-hd.mp4' }, + }, + ], + }], + ], +}); +``` + +## Example + + + + +## Configuration + +#### `progressbar` +- type: `boolean` +- default: `true` + +Displays a progressbar on top of the navbar. + +#### `bigbutton` +- type: `boolean` +- default: `true` + +Displays a big "play" button in the center of the viewer. + +#### `lang` +- type: `object` +- default: +```js +lang: { + videoPlay : 'Play/Pause', + videoVolume: 'Volume', +} +``` + +_Note: this option is not part of the plugin but is merged with the main [`lang`](../guide/config.md#lang) object._ + + +## Events + +#### `play` + +Triggered when the video starts playing. + +#### `pause` + +Triggered when the video is paused. + +#### `volume-change(volume)` + +Triggered when the video volume changes. + +#### `progress({ time, duration, progress })` + +Triggered when the video play progression changes. + + +## Buttons + +This plugin adds buttons to the default navbar: +- `videoPlay` allows to play/pause the video +- `videoVolume` allows to change the volume/mute the video +- `videoTime` shows the video time and duration (not a button) + +If you use a [custom navbar](../guide/navbar.md) you will need to manually add the buttons to the list. diff --git a/example/cubemap-video.html b/example/cubemap-video.html new file mode 100644 index 000000000..a97986820 --- /dev/null +++ b/example/cubemap-video.html @@ -0,0 +1,70 @@ + + + + + + PhotoSphereViewer - cubemap video demo + + + + + + + + +
+ + + + + + + + + + + + diff --git a/example/equirectangular-video.html b/example/equirectangular-video.html new file mode 100644 index 000000000..5325ca6cb --- /dev/null +++ b/example/equirectangular-video.html @@ -0,0 +1,72 @@ + + + + + + PhotoSphereViewer - equirectangular video demo + + + + + + + + +
+ + + + + + + + + + + + diff --git a/example/index.html b/example/index.html index 8dad0acee..dccaea002 100644 --- a/example/index.html +++ b/example/index.html @@ -28,8 +28,10 @@
Adapters
@@ -46,6 +48,7 @@
Plugins
Markers (cubemap) Resolution Settings + Video (equirectangular) Virtual Tour Visible Range diff --git a/rollup.config.js b/rollup.config.js index b7cf94c30..22b36b196 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -15,7 +15,7 @@ const plugins = fs.readdirSync(path.join(__dirname, 'src/plugins')) const adapters = fs.readdirSync(path.join(__dirname, 'src/adapters')) .filter(p => fs.lstatSync(`src/adapters/${p}`).isDirectory()) - .filter(p => p !== 'equirectangular' && p !== 'tiles-shared'); + .filter(p => p !== 'equirectangular' && p !== 'shared'); const banner = `/*! * Photo Sphere Viewer ${pkg.version} diff --git a/src/Viewer.js b/src/Viewer.js index f8fc5bf10..e687253ce 100644 --- a/src/Viewer.js +++ b/src/Viewer.js @@ -1,6 +1,5 @@ import * as THREE from 'three'; import { EventEmitter } from 'uevent'; -import { Animation } from './Animation'; import { Loader } from './components/Loader'; import { Navbar } from './components/Navbar'; import { Notification } from './components/Notification'; @@ -18,6 +17,8 @@ import { Renderer } from './services/Renderer'; import { TextureLoader } from './services/TextureLoader'; import { TooltipRenderer } from './services/TooltipRenderer'; import { + Animation, + Dynamic, each, exitFullscreen, getAbortError, @@ -27,13 +28,12 @@ import { isExtendedPosition, isFullscreenEnabled, logWarn, + MultiDynamic, pluginInterop, requestFullscreen, throttle, toggleClass } from './utils'; -import { Dynamic } from './utils/Dynamic'; -import { MultiDynamic } from './utils/MultiDynamic'; THREE.Cache.enabled = true; @@ -223,7 +223,7 @@ export class Viewer extends EventEmitter { this.overlay = new Overlay(this); /** - * @member {Record} + * @member {Record} * @package */ this.dynamics = { diff --git a/src/adapters/cubemap-tiles/index.js b/src/adapters/cubemap-tiles/index.js index 4eb512d83..e12946afd 100644 --- a/src/adapters/cubemap-tiles/index.js +++ b/src/adapters/cubemap-tiles/index.js @@ -1,9 +1,9 @@ import * as THREE from 'three'; import { CONSTANTS, PSVError, utils } from '../..'; import { CUBE_HASHMAP, CubemapAdapter } from '../cubemap'; -import { Queue } from '../tiles-shared/Queue'; -import { Task } from '../tiles-shared/Task'; -import { buildErrorMaterial, createBaseTexture } from '../tiles-shared/utils'; +import { Queue } from '../shared/Queue'; +import { Task } from '../shared/Task'; +import { buildErrorMaterial, createBaseTexture } from '../shared/tiles-utils'; if (!CubemapAdapter) { throw new PSVError('CubemapAdapter is missing, please load cubemap.js before cubemap-tiles.js'); @@ -67,6 +67,7 @@ const vertexPosition = new THREE.Vector3(); /** * @summary Adapter for tiled cubemaps * @memberof PSV.adapters + * @extends PSV.adapters.AbstractAdapter */ export class CubemapTilesAdapter extends CubemapAdapter { diff --git a/src/adapters/cubemap-video/index.js b/src/adapters/cubemap-video/index.js new file mode 100644 index 000000000..e19452148 --- /dev/null +++ b/src/adapters/cubemap-video/index.js @@ -0,0 +1,184 @@ +import * as THREE from 'three'; +import { CONSTANTS } from '../..'; +import { AbstractVideoAdapter } from '../shared/AbstractVideoAdapter'; + +/** + * @typedef {Object} PSV.adapters.CubemapVideoAdapter.Video + * @summary Object defining a video + * @property {string} source + */ + +/** + * @typedef {Object} PSV.adapters.CubemapVideoAdapter.Options + * @property {boolean} [autoplay=false] - automatically start the video + * @property {boolean} [muted=autoplay] - initially mute the video + * @property {number} [equiangular=true] - if the video is an equiangular cubemap (EAC) + */ + + +/** + * @summary Adapter for cubemap videos + * @memberof PSV.adapters + * @extends PSV.adapters.AbstractAdapter + */ +export class CubemapVideoAdapter extends AbstractVideoAdapter { + + static id = 'cubemap-video'; + + /** + * @param {PSV.Viewer} psv + * @param {PSV.adapters.CubemapVideoAdapter.Options} options + */ + constructor(psv, options) { + super(psv, { + equiangular: true, + ...options, + }); + } + + /** + * @override + * @param {PSV.adapters.CubemapVideoAdapter.Video} panorama + * @returns {Promise.} + */ + loadTexture(panorama) { + return super.loadTexture(panorama); + } + + /** + * @override + */ + createMesh(scale = 1) { + const cubeSize = CONSTANTS.SPHERE_RADIUS * 2 * scale; + const geometry = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize) + .scale(1, 1, -1) + .toNonIndexed(); + + geometry.clearGroups(); + + const uvs = geometry.getAttribute('uv'); + + /* + Structure of a frame + + 1 +---------+---------+---------+ + | | | | + | Left | Front | Right | + | | | | + 1/2 +---------+---------+---------+ + | | | | + | Bottom | Back | Top | + | | | | + 0 +---------+---------+---------+ + 0 1/3 2/3 1 + + Bottom, Back and Top are rotated 90° clockwise + */ + + // columns + const a = 0; + const b = 1 / 3; + const c = 2 / 3; + const d = 1; + + // lines + const A = 1; + const B = 1 / 2; + const C = 0; + + // left + uvs.setXY(0, a, A); + uvs.setXY(1, a, B); + uvs.setXY(2, b, A); + uvs.setXY(3, a, B); + uvs.setXY(4, b, B); + uvs.setXY(5, b, A); + + // right + uvs.setXY(6, c, A); + uvs.setXY(7, c, B); + uvs.setXY(8, d, A); + uvs.setXY(9, c, B); + uvs.setXY(10, d, B); + uvs.setXY(11, d, A); + + // top + uvs.setXY(12, d, B); + uvs.setXY(13, c, B); + uvs.setXY(14, d, C); + uvs.setXY(15, c, B); + uvs.setXY(16, c, C); + uvs.setXY(17, d, C); + + // bottom + uvs.setXY(18, b, B); + uvs.setXY(19, a, B); + uvs.setXY(20, b, C); + uvs.setXY(21, a, B); + uvs.setXY(22, a, C); + uvs.setXY(23, b, C); + + // back + uvs.setXY(24, c, B); + uvs.setXY(25, b, B); + uvs.setXY(26, c, C); + uvs.setXY(27, b, B); + uvs.setXY(28, b, C); + uvs.setXY(29, c, C); + + // front + uvs.setXY(30, b, A); + uvs.setXY(31, b, B); + uvs.setXY(32, c, A); + uvs.setXY(33, b, B); + uvs.setXY(34, c, B); + uvs.setXY(35, c, A); + + // shamelessly copied from https://github.com/videojs/videojs-vr + const material = new THREE.ShaderMaterial({ + uniforms : { + mapped : { value: null }, + contCorrect: { value: 1 }, + faceWH : { value: new THREE.Vector2(1 / 3, 1 / 2) }, + vidWH : { value: new THREE.Vector2(1, 1) }, + }, + vertexShader : ` +varying vec2 vUv; +void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); +}`, + fragmentShader: ` +varying vec2 vUv; +uniform sampler2D mapped; +uniform vec2 faceWH; +uniform vec2 vidWH; +uniform float contCorrect; +const float PI = 3.1415926535897932384626433832795; +void main() { + vec2 corner = vUv - mod(vUv, faceWH) + vec2(0, contCorrect / vidWH.y); + vec2 faceWHadj = faceWH - vec2(0, contCorrect * 2. / vidWH.y); + vec2 p = (vUv - corner) / faceWHadj - .5; + vec2 q = ${this.config.equiangular ? '2. / PI * atan(2. * p) + .5' : 'p + .5'}; + vec2 eUv = corner + q * faceWHadj; + gl_FragColor = texture2D(mapped, eUv); +}`, + }); + + return new THREE.Mesh(geometry, material); + } + + /** + * @override + */ + setTexture(mesh, textureData) { + const { texture } = textureData; + + mesh.material.uniforms.mapped.value?.dispose(); + mesh.material.uniforms.mapped.value = texture; + mesh.material.uniforms.vidWH.value.set(texture.image.videoWidth, texture.image.videoHeight); + + this.__switchVideo(textureData.texture); + } + +} diff --git a/src/adapters/cubemap/index.js b/src/adapters/cubemap/index.js index c5228c000..f90f62f54 100644 --- a/src/adapters/cubemap/index.js +++ b/src/adapters/cubemap/index.js @@ -28,6 +28,7 @@ export const CUBE_HASHMAP = ['left', 'right', 'top', 'bottom', 'back', 'front']; /** * @summary Adapter for cubemaps * @memberof PSV.adapters + * @extends PSV.adapters.AbstractAdapter */ export class CubemapAdapter extends AbstractAdapter { diff --git a/src/adapters/equirectangular-tiles/index.js b/src/adapters/equirectangular-tiles/index.js index c2bcd741f..14d308a1f 100644 --- a/src/adapters/equirectangular-tiles/index.js +++ b/src/adapters/equirectangular-tiles/index.js @@ -1,8 +1,8 @@ import * as THREE from 'three'; import { CONSTANTS, EquirectangularAdapter, PSVError, utils } from '../..'; -import { Queue } from '../tiles-shared/Queue'; -import { Task } from '../tiles-shared/Task'; -import { buildErrorMaterial, createBaseTexture } from '../tiles-shared/utils'; +import { Queue } from '../shared/Queue'; +import { Task } from '../shared/Task'; +import { buildErrorMaterial, createBaseTexture } from '../shared/tiles-utils'; /** @@ -87,6 +87,7 @@ const vertexPosition = new THREE.Vector3(); /** * @summary Adapter for tiled panoramas * @memberof PSV.adapters + * @extends PSV.adapters.AbstractAdapter */ export class EquirectangularTilesAdapter extends EquirectangularAdapter { @@ -259,6 +260,9 @@ export class EquirectangularTilesAdapter extends EquirectangularAdapter { croppedHeight: panorama.width / 2, croppedX : 0, croppedY : 0, + poseHeading : 0, + posePitch : 0, + poseRoll : 0, }; if (panorama.baseUrl) { @@ -278,7 +282,12 @@ export class EquirectangularTilesAdapter extends EquirectangularAdapter { * @override */ createMesh(scale = 1) { - const geometry = new THREE.SphereGeometry(CONSTANTS.SPHERE_RADIUS * scale, this.SPHERE_SEGMENTS, this.SPHERE_HORIZONTAL_SEGMENTS, -Math.PI / 2) + const geometry = new THREE.SphereGeometry( + CONSTANTS.SPHERE_RADIUS * scale, + this.SPHERE_SEGMENTS, + this.SPHERE_HORIZONTAL_SEGMENTS, + -Math.PI / 2 + ) .scale(-1, 1, 1) .toNonIndexed(); diff --git a/src/adapters/equirectangular-video/index.js b/src/adapters/equirectangular-video/index.js new file mode 100644 index 000000000..980761382 --- /dev/null +++ b/src/adapters/equirectangular-video/index.js @@ -0,0 +1,97 @@ +import * as THREE from 'three'; +import { CONSTANTS, PSVError, utils } from '../..'; +import { AbstractVideoAdapter } from '../shared/AbstractVideoAdapter'; + +/** + * @typedef {Object} PSV.adapters.EquirectangularVideoAdapter.Video + * @summary Object defining a video + * @property {string} source + */ + +/** + * @typedef {Object} PSV.adapters.EquirectangularVideoAdapter.Options + * @property {boolean} [autoplay=false] - automatically start the video + * @property {boolean} [muted=autoplay] - initially mute the video + * @property {number} [resolution=64] - number of faces of the sphere geometry, higher values may decrease performances + */ + + +/** + * @summary Adapter for equirectangular videos + * @memberof PSV.adapters + * @extends PSV.adapters.AbstractAdapter + */ +export class EquirectangularVideoAdapter extends AbstractVideoAdapter { + + static id = 'equirectangular-video'; + + /** + * @param {PSV.Viewer} psv + * @param {PSV.adapters.EquirectangularVideoAdapter.Options} options + */ + constructor(psv, options) { + super(psv, { + resolution: 64, + ...options, + }); + + if (!utils.isPowerOfTwo(this.config.resolution)) { + throw new PSVError('EquirectangularVideoAdapter resolution must be power of two'); + } + + this.SPHERE_SEGMENTS = this.config.resolution; + this.SPHERE_HORIZONTAL_SEGMENTS = this.SPHERE_SEGMENTS / 2; + } + + /** + * @override + * @param {PSV.adapters.EquirectangularVideoAdapter.Video} panorama + * @returns {Promise.} + */ + loadTexture(panorama) { + return super.loadTexture(panorama) + .then(({ texture }) => { + const panoData = { + fullWidth : texture.image.width, + fullHeight : texture.image.height, + croppedWidth : texture.image.width, + croppedHeight: texture.image.height, + croppedX : 0, + croppedY : 0, + poseHeading : 0, + posePitch : 0, + poseRoll : 0, + }; + + return { panorama, texture, panoData }; + }); + } + + /** + * @override + */ + createMesh(scale = 1) { + const geometry = new THREE.SphereGeometry( + CONSTANTS.SPHERE_RADIUS * scale, + this.SPHERE_SEGMENTS, + this.SPHERE_HORIZONTAL_SEGMENTS, + -Math.PI / 2 + ) + .scale(-1, 1, 1); + + const material = new THREE.MeshBasicMaterial(); + + return new THREE.Mesh(geometry, material); + } + + /** + * @override + */ + setTexture(mesh, textureData) { + mesh.material.map?.dispose(); + mesh.material.map = textureData.texture; + + this.__switchVideo(textureData.texture); + } + +} diff --git a/src/adapters/equirectangular/index.js b/src/adapters/equirectangular/index.js index 56795194e..f1585f414 100644 --- a/src/adapters/equirectangular/index.js +++ b/src/adapters/equirectangular/index.js @@ -15,6 +15,7 @@ import { AbstractAdapter } from '../AbstractAdapter'; /** * @summary Adapter for equirectangular panoramas * @memberof PSV.adapters + * @extends PSV.adapters.AbstractAdapter */ export class EquirectangularAdapter extends AbstractAdapter { @@ -205,7 +206,12 @@ export class EquirectangularAdapter extends AbstractAdapter { */ createMesh(scale = 1) { // The middle of the panorama is placed at longitude=0 - const geometry = new THREE.SphereGeometry(SPHERE_RADIUS * scale, this.SPHERE_SEGMENTS, this.SPHERE_HORIZONTAL_SEGMENTS, -Math.PI / 2) + const geometry = new THREE.SphereGeometry( + SPHERE_RADIUS * scale, + this.SPHERE_SEGMENTS, + this.SPHERE_HORIZONTAL_SEGMENTS, + -Math.PI / 2 + ) .scale(-1, 1, 1); const material = new THREE.MeshBasicMaterial(); @@ -217,10 +223,8 @@ export class EquirectangularAdapter extends AbstractAdapter { * @override */ setTexture(mesh, textureData) { - const { texture } = textureData; - mesh.material.map?.dispose(); - mesh.material.map = texture; + mesh.material.map = textureData.texture; } /** diff --git a/src/adapters/shared/AbstractVideoAdapter.js b/src/adapters/shared/AbstractVideoAdapter.js new file mode 100644 index 000000000..96a505591 --- /dev/null +++ b/src/adapters/shared/AbstractVideoAdapter.js @@ -0,0 +1,229 @@ +import * as THREE from 'three'; +import { AbstractAdapter, CONSTANTS, PSVError } from '../..'; + +/** + * @typedef {Object} PSV.adapters.AbstractVideoAdapter.Video + * @summary Object defining a video + * @property {string} source + */ + +/** + * @typedef {Object} PSV.adapters.AbstractVideoAdapter.Options + * @property {boolean} [autoplay=false] - automatically start the video + * @property {boolean} [muted=autoplay] - initially mute the video + */ + +/** + * @summary Base video adapters class + * @memberof PSV.adapters + * @abstract + * @private + */ +export class AbstractVideoAdapter extends AbstractAdapter { + + static supportsTransition = false; + static supportsPreload = false; + static supportsDownload = true; + + constructor(psv, options) { + super(psv); + + /** + * @member {PSV.adapters.AbstractVideoAdapter.Options} + * @private + */ + this.config = { + autoplay: false, + muted : options?.autoplay ?? false, + ...options, + }; + + /** + * @member {HTMLVideoElement} + * @private + */ + this.video = null; + + this.psv.on(CONSTANTS.EVENTS.BEFORE_RENDER, this); + } + + /** + * @override + */ + destroy() { + this.psv.off(CONSTANTS.EVENTS.BEFORE_RENDER, this); + + this.__removeVideo(); + + super.destroy(); + } + + /** + * @private + */ + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + case CONSTANTS.EVENTS.BEFORE_RENDER: + if (this.video) { + this.psv.needsUpdate(); + } + break; + } + /* eslint-enable */ + } + + /** + * @override + * @param {PSV.adapters.AbstractVideoAdapter.Video} panorama + * @returns {Promise.} + */ + loadTexture(panorama) { + if (typeof panorama !== 'object' || !panorama.source) { + return Promise.reject(new PSVError('Invalid panorama configuration, are you using the right adapter?')); + } + + if (!this.psv.getPlugin('video')) { + return Promise.reject(new PSVError('Video adapters require VideoPlugin to be loaded too.')); + } + + const video = this.__createVideo(panorama.source); + + return this.__videoLoadPromise(video) + .then(() => { + const texture = new THREE.VideoTexture(video); + return { panorama, texture }; + }); + } + + /** + * @override + */ + __switchVideo(texture) { + let currentTime; + let duration; + let paused = !this.config.autoplay; + let muted = this.config.muted; + let volume = 1; + if (this.video) { + ({ currentTime, duration, paused, muted, volume } = this.video); + } + + this.__removeVideo(); + this.video = texture.image; + + // keep current time when switching resolution + if (this.video.duration === duration) { + this.video.currentTime = currentTime; + } + + // keep volume + this.video.muted = muted; + this.video.volume = volume; + + // play + if (!paused) { + this.video.play(); + } + } + + /** + * @override + */ + disposeTexture(textureData) { + if (textureData.texture) { + const video = textureData.texture.image; + video.pause(); + this.psv.container.removeChild(video); + } + textureData.texture?.dispose(); + } + + /** + * @summary Removes the current video element + * @private + */ + __removeVideo() { + if (this.video) { + this.video.pause(); + this.psv.container.removeChild(this.video); + delete this.video; + } + } + + /** + * @summary Creates a new video element + * @memberOf PSV.adapters + * @param {string} src + * @return {HTMLVideoElement} + * @private + */ + __createVideo(src) { + const video = document.createElement('video'); + video.crossOrigin = this.psv.config.withCredentials ? 'use-credentials' : 'anonymous'; + video.loop = true; + video.style.display = 'none'; + video.muted = this.config.muted; + video.src = src; + video.preload = 'metadata'; + + this.psv.container.appendChild(video); + + return video; + } + + /** + * @private + */ + __videoLoadPromise(video) { + const self = this; + + return new Promise((resolve, reject) => { + video.addEventListener('loadedmetadata', function onLoaded() { + if (this.video && video.duration === this.video.duration) { + resolve(self.__videoBufferPromise(video, this.video.currentTime)); + } + else { + resolve(); + } + video.removeEventListener('loadedmetadata', onLoaded); + }); + + video.addEventListener('error', function onError(err) { + reject(err); + video.removeEventListener('error', onError); + }); + }); + } + + /** + * @private + */ + __videoBufferPromise(video, currentTime) { + return new Promise((resolve) => { + function onBuffer() { + const buffer = video.buffered; + for (let i = 0, l = buffer.length; i < l; i++) { + if (buffer.start(i) <= video.currentTime && buffer.end(i) >= video.currentTime) { + video.pause(); + video.removeEventListener('buffer', onBuffer); + video.removeEventListener('progress', onBuffer); + resolve(); + break; + } + } + } + + // try to reduce the switching time by preloading in advance + // FIXME find a better way ? + video.currentTime = Math.min(currentTime + 2000, video.duration.currentTime); + video.muted = true; + + video.addEventListener('buffer', onBuffer); + video.addEventListener('progress', onBuffer); + + video.play(); + }); + } + +} diff --git a/src/adapters/tiles-shared/Queue.js b/src/adapters/shared/Queue.js similarity index 100% rename from src/adapters/tiles-shared/Queue.js rename to src/adapters/shared/Queue.js diff --git a/src/adapters/tiles-shared/Task.js b/src/adapters/shared/Task.js similarity index 100% rename from src/adapters/tiles-shared/Task.js rename to src/adapters/shared/Task.js diff --git a/src/adapters/tiles-shared/utils.js b/src/adapters/shared/tiles-utils.js similarity index 100% rename from src/adapters/tiles-shared/utils.js rename to src/adapters/shared/tiles-utils.js diff --git a/src/buttons/AbstractButton.js b/src/buttons/AbstractButton.js index c7f6a19b1..e9733652b 100644 --- a/src/buttons/AbstractButton.js +++ b/src/buttons/AbstractButton.js @@ -23,6 +23,14 @@ export class AbstractButton extends AbstractComponent { */ static id = null; + /** + * @summary Identifier to declare a group of buttons + * @member {string} + * @readonly + * @static + */ + static groupId = null; + /** * @summary SVG icon name injected in the button * @member {string} diff --git a/src/buttons/AbstractMoveButton.js b/src/buttons/AbstractMoveButton.js index 3f6adfae5..2a4e1c625 100644 --- a/src/buttons/AbstractMoveButton.js +++ b/src/buttons/AbstractMoveButton.js @@ -26,6 +26,8 @@ export function getOrientedArrow(direction) { */ export class AbstractMoveButton extends AbstractButton { + static groupId = 'move'; + /** * @param {PSV.components.Navbar} navbar * @param {number} value diff --git a/src/buttons/AbstractZoomButton.js b/src/buttons/AbstractZoomButton.js index a4a26bdfe..86aa1bc93 100644 --- a/src/buttons/AbstractZoomButton.js +++ b/src/buttons/AbstractZoomButton.js @@ -11,6 +11,8 @@ import { AbstractButton } from './AbstractButton'; */ export class AbstractZoomButton extends AbstractButton { + static groupId = 'zoom'; + /** * @param {PSV.components.Navbar} navbar * @param {number} value diff --git a/src/buttons/ZoomRangeButton.js b/src/buttons/ZoomRangeButton.js index c6e69529f..84df738eb 100644 --- a/src/buttons/ZoomRangeButton.js +++ b/src/buttons/ZoomRangeButton.js @@ -1,6 +1,6 @@ import { EVENTS } from '../data/constants'; import { SYSTEM } from '../data/system'; -import { getStyle } from '../utils'; +import { getStyle, Slider } from '../utils'; import { AbstractButton } from './AbstractButton'; /** @@ -11,6 +11,7 @@ import { AbstractButton } from './AbstractButton'; export class ZoomRangeButton extends AbstractButton { static id = 'zoomRange'; + static groupId = 'zoom'; /** * @param {PSV.components.Navbar} navbar @@ -20,12 +21,10 @@ export class ZoomRangeButton extends AbstractButton { /** * @override - * @property {boolean} mousedown * @property {number} mediaMinWidth */ this.prop = { ...this.prop, - mousedown : false, mediaMinWidth: 0, }; @@ -47,17 +46,20 @@ export class ZoomRangeButton extends AbstractButton { this.zoomValue.className = 'psv-zoom-range-handle'; this.zoomRange.appendChild(this.zoomValue); - this.prop.mediaMinWidth = parseInt(getStyle(this.container, 'maxWidth'), 10); + /** + * @member {PSV.Slider} + * @readonly + * @private + */ + this.slider = new Slider({ + container: this.container, + direction: Slider.HORIZONTAL, + onUpdate : e => this.__onSliderUpdate(e), + }); - this.container.addEventListener('mousedown', this); - this.container.addEventListener('touchstart', this); - this.psv.container.addEventListener('mousemove', this); - this.psv.container.addEventListener('touchmove', this); - this.psv.container.addEventListener('mouseup', this); - this.psv.container.addEventListener('touchend', this); + this.prop.mediaMinWidth = parseInt(getStyle(this.container, 'maxWidth'), 10); this.psv.on(EVENTS.ZOOM_UPDATED, this); - if (this.psv.prop.ready) { this.__moveZoomValue(this.psv.getZoomLevel()); } @@ -72,12 +74,7 @@ export class ZoomRangeButton extends AbstractButton { * @override */ destroy() { - this.__stopZoomChange(); - - this.psv.container.removeEventListener('mousemove', this); - this.psv.container.removeEventListener('touchmove', this); - this.psv.container.removeEventListener('mouseup', this); - this.psv.container.removeEventListener('touchend', this); + this.slider.destroy(); delete this.zoomRange; delete this.zoomValue; @@ -96,12 +93,6 @@ export class ZoomRangeButton extends AbstractButton { /* eslint-disable */ switch (e.type) { // @formatter:off - case 'mousedown': this.__initZoomChangeWithMouse(e); break; - case 'touchstart': this.__initZoomChangeByTouch(e); break; - case 'mousemove': this.__changeZoomWithMouse(e); break; - case 'touchmove': this.__changeZoomByTouch(e); break; - case 'mouseup': this.__stopZoomChange(e); break; - case 'touchend': this.__stopZoomChange(e); break; case EVENTS.ZOOM_UPDATED: this.__moveZoomValue(e.args[0]); break; case EVENTS.READY: this.__moveZoomValue(this.psv.getZoomLevel()); break; // @formatter:on @@ -146,82 +137,15 @@ export class ZoomRangeButton extends AbstractButton { this.zoomValue.style.left = (level / 100 * this.zoomRange.offsetWidth - this.zoomValue.offsetWidth / 2) + 'px'; } - /** - * @summary Handles mouse down events - * @param {MouseEvent} evt - * @private - */ - __initZoomChangeWithMouse(evt) { - if (!this.prop.enabled) { - return; - } - - this.prop.mousedown = true; - this.__changeZoom(evt.clientX); - } - - /** - * @summary Handles touch events - * @param {TouchEvent} evt - * @private - */ - __initZoomChangeByTouch(evt) { - if (!this.prop.enabled) { - return; - } - - this.prop.mousedown = true; - this.__changeZoom(evt.changedTouches[0].clientX); - } - - /** - * @summary Handles mouse up events - * @private - */ - __stopZoomChange() { - if (!this.prop.enabled) { - return; - } - - this.prop.mousedown = false; - this.prop.buttondown = false; - } - - /** - * @summary Handles mouse move events - * @param {MouseEvent} evt - * @private - */ - __changeZoomWithMouse(evt) { - if (!this.prop.enabled || !this.prop.mousedown) { - return; - } - - evt.preventDefault(); - this.__changeZoom(evt.clientX); - } - - /** - * @summary Handles touch move events - * @param {TouchEvent} evt - * @private - */ - __changeZoomByTouch(evt) { - if (!this.prop.enabled || !this.prop.mousedown) { - return; - } - this.__changeZoom(evt.changedTouches[0].clientX); - } /** * @summary Zoom change - * @param {number} x - mouse/touch position * @private */ - __changeZoom(x) { - const userInput = x - this.zoomRange.getBoundingClientRect().left; - const zoomLevel = userInput / this.zoomRange.offsetWidth * 100; - this.psv.zoom(zoomLevel); + __onSliderUpdate(e) { + if (e.mousedown) { + this.psv.zoom(e.value * 100); + } } } diff --git a/src/components/Navbar.js b/src/components/Navbar.js index 9095791ca..059290b0c 100644 --- a/src/components/Navbar.js +++ b/src/components/Navbar.js @@ -23,6 +23,13 @@ import { NavbarCaption } from './NavbarCaption'; */ const AVAILABLE_BUTTONS = {}; +/** + * @summary List of available buttons + * @type {Object>>} + * @private + */ +const AVAILABLE_GROUPS = {}; + /** * @summary Register a new button available for all viewers * @param {Class} button @@ -37,6 +44,11 @@ export function registerButton(button, defaultPosition) { AVAILABLE_BUTTONS[button.id] = button; + if (button.groupId) { + AVAILABLE_GROUPS[button.groupId] = AVAILABLE_GROUPS[button.groupId] || []; + AVAILABLE_GROUPS[button.groupId].push(button); + } + if (typeof defaultPosition === 'string') { switch (defaultPosition) { case 'start': @@ -54,13 +66,13 @@ export function registerButton(button, defaultPosition) { [ AutorotateButton, - ZoomInButton, - ZoomRangeButton, ZoomOutButton, + ZoomRangeButton, + ZoomInButton, DownloadButton, FullscreenButton, - MoveRightButton, MoveLeftButton, + MoveRightButton, MoveUpButton, MoveDownButton, ].forEach(registerButton); @@ -110,19 +122,11 @@ export class Navbar extends AbstractComponent { else if (AVAILABLE_BUTTONS[button]) { new AVAILABLE_BUTTONS[button](this); } - else if (button === 'caption') { - new NavbarCaption(this, this.psv.config.caption); - } - else if (button === 'zoom') { - new ZoomOutButton(this); - new ZoomRangeButton(this); - new ZoomInButton(this); + else if (AVAILABLE_GROUPS[button]) { + AVAILABLE_GROUPS[button].forEach(buttonCtor => new buttonCtor(this)); // eslint-disable-line new-cap } - else if (button === 'move') { - new MoveLeftButton(this); - new MoveRightButton(this); - new MoveUpButton(this); - new MoveDownButton(this); + else if (button === NavbarCaption.id) { + new NavbarCaption(this, this.psv.config.caption); } else { throw new PSVError('Unknown button ' + button); diff --git a/src/components/NavbarCaption.js b/src/components/NavbarCaption.js index 47befcaa9..81e890cd2 100644 --- a/src/components/NavbarCaption.js +++ b/src/components/NavbarCaption.js @@ -75,15 +75,8 @@ export class NavbarCaption extends AbstractComponent { this.content.innerHTML = this.prop.caption; if (html) { - this.show(false); - - this.content.style.display = ''; this.prop.contentWidth = this.content.offsetWidth; - - this.refreshUi(); - } - else { - this.hide(); + this.refreshUi('caption change'); } } diff --git a/src/data/constants.js b/src/data/constants.js index cd5006ba3..c4cc4ea0d 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -182,6 +182,13 @@ export const EVENTS = { * @param {*} Data associated to this tooltip */ HIDE_TOOLTIP : 'hide-tooltip', + /** + * @event key-press + * @memberof PSV + * @summary Triggered when a key is pressed, can be cancelled + * @param {string} key + */ + KEY_PRESS : 'key-press', /** * @event load-progress * @memberof PSV @@ -200,6 +207,7 @@ export const EVENTS = { * @event panorama-loaded * @memberof PSV * @summary Triggered when a panorama image has been loaded + * @param {PSV.TextureData} textureData */ PANORAMA_LOADED : 'panorama-loaded', /** diff --git a/src/index.js b/src/index.js index d8e73c3cf..0aa8bae96 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,7 @@ -import { Animation } from './Animation'; +import { AbstractAdapter } from './adapters/AbstractAdapter'; +import { EquirectangularAdapter } from './adapters/equirectangular'; import { AbstractButton } from './buttons/AbstractButton'; +import { AbstractComponent } from './components/AbstractComponent'; import { registerButton } from './components/Navbar'; import { DEFAULTS } from './data/config'; import * as CONSTANTS from './data/constants'; @@ -7,16 +9,19 @@ import './data/constants'; // for jsdoc import { SYSTEM } from './data/system'; import { AbstractPlugin } from './plugins/AbstractPlugin'; import { PSVError } from './PSVError'; -import { AbstractAdapter } from './adapters/AbstractAdapter'; -import { EquirectangularAdapter } from './adapters/equirectangular'; -import './styles/index.scss'; import * as utils from './utils'; +import { Animation } from './utils/Animation'; import { Viewer } from './Viewer'; +import './styles/index.scss'; export { + AbstractAdapter, AbstractButton, + AbstractComponent, AbstractPlugin, - AbstractAdapter, + /** + * @deprecated use `utils.Animation` + */ Animation, CONSTANTS, DEFAULTS, @@ -24,8 +29,8 @@ export { PSVError, registerButton, SYSTEM, - Viewer, - utils + utils, + Viewer }; diff --git a/src/plugins/settings/constants.js b/src/plugins/settings/constants.js index c54dafeea..5a01cd9f6 100644 --- a/src/plugins/settings/constants.js +++ b/src/plugins/settings/constants.js @@ -1,6 +1,5 @@ import check from './check.svg'; import chevron from './chevron.svg'; -import icon from './settings.svg'; import switchOff from './switch-off.svg'; import switchOn from './switch-on.svg'; @@ -58,16 +57,14 @@ export const SETTINGS_TEMPLATE_ = { /** * @summary Settings list template * @param {PSV.plugins.SettingsPlugin.Setting[]} settings - * @param {string} title * @param {string} dataKey * @param {function} optionsCurrent * @returns {string} * @constant * @private */ -export const SETTINGS_TEMPLATE = (settings, title, dataKey, optionsCurrent) => ` -
-

${icon} ${title}

+export const SETTINGS_TEMPLATE = (settings, dataKey, optionsCurrent) => ` +
    ${settings.map(s => `
  • @@ -81,16 +78,14 @@ export const SETTINGS_TEMPLATE = (settings, title, dataKey, optionsCurrent) => ` /** * @summary Settings options template * @param {PSV.plugins.SettingsPlugin.OptionsSetting} setting - * @param {string} title * @param {string} dataKey * @param {function} optionActive * @returns {string} * @constant * @private */ -export const SETTING_OPTIONS_TEMPLATE = (setting, title, dataKey, optionActive) => ` -
    -

    ${icon} ${title}

    +export const SETTING_OPTIONS_TEMPLATE = (setting, dataKey, optionActive) => ` +
    • ${chevron} diff --git a/src/plugins/settings/index.js b/src/plugins/settings/index.js index 64a63a962..fc09efb23 100644 --- a/src/plugins/settings/index.js +++ b/src/plugins/settings/index.js @@ -174,7 +174,6 @@ export class SettingsPlugin extends AbstractPlugin { id : ID_PANEL, content : SETTINGS_TEMPLATE( this.settings, - this.psv.config.lang[SettingsButton.id], utils.dasherize(SETTING_DATA), (setting) => { // retrocompatibility with "current" returning a label const current = setting.current(); @@ -221,7 +220,6 @@ export class SettingsPlugin extends AbstractPlugin { id : ID_PANEL, content : SETTING_OPTIONS_TEMPLATE( setting, - this.psv.config.lang[SettingsButton.id], utils.dasherize(SETTING_DATA), (option) => { // retrocompatibility with options having an "active" flag return 'active' in option ? option.active : option.id === current; diff --git a/src/plugins/settings/style.scss b/src/plugins/settings/style.scss index 9e07518df..6b3d7a75b 100644 --- a/src/plugins/settings/style.scss +++ b/src/plugins/settings/style.scss @@ -53,3 +53,12 @@ $psv-settings-badge-text-color: white !default; color: $psv-settings-badge-text-color; font: $psv-settings-badge-font; } + +.psv-settings-menu { + justify-content: flex-end; + + .psv-panel-menu-list { + flex: none; + margin-bottom: $psv-panel-menu-item-height; + } +} diff --git a/src/plugins/video/PauseOverlay.js b/src/plugins/video/PauseOverlay.js new file mode 100644 index 000000000..4e328f045 --- /dev/null +++ b/src/plugins/video/PauseOverlay.js @@ -0,0 +1,69 @@ +import { AbstractComponent, CONSTANTS, utils } from '../..'; +import { EVENTS } from './constants'; +import playIcon from './play.svg'; + +/** + * @private + */ +export class PauseOverlay extends AbstractComponent { + + constructor(plugin) { + super(plugin.psv, 'psv-video-overlay'); + + /** + * @type {PSV.plugins.VideoPlugin} + * @private + * @readonly + */ + this.plugin = plugin; + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.button = document.createElement('button'); + this.button.className = 'psv-video-bigbutton'; + this.button.innerHTML = playIcon; + this.container.appendChild(this.button); + + this.psv.on(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.on(EVENTS.PLAY, this); + this.plugin.on(EVENTS.PAUSE, this); + this.button.addEventListener('click', this); + } + + /** + * @private + */ + destroy() { + this.psv.off(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.off(EVENTS.PLAY, this); + this.plugin.off(EVENTS.PAUSE, this); + + delete this.plugin; + + super.destroy(); + } + + /** + * @summary Handles events + * @param {Event} e + * @private + */ + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + case CONSTANTS.EVENTS.PANORAMA_LOADED: + case EVENTS.PLAY: + case EVENTS.PAUSE: + utils.toggleClass(this.button, 'psv-video-bigbutton--pause', !this.plugin.isPlaying()); + break; + case 'click': + this.plugin.playPause(); + break; + } + /* eslint-enable */ + } + +} diff --git a/src/plugins/video/PlayPauseButton.js b/src/plugins/video/PlayPauseButton.js new file mode 100644 index 000000000..fc7b34f7f --- /dev/null +++ b/src/plugins/video/PlayPauseButton.js @@ -0,0 +1,82 @@ +import { AbstractButton } from '../..'; +import { EVENTS } from './constants'; +import pauseIcon from './pause.svg'; +import playIcon from './play.svg'; + +/** + * @summary Navigation bar video play/pause button + * @extends PSV.buttons.AbstractButton + * @memberof PSV.buttons + */ +export class PlayPauseButton extends AbstractButton { + + static id = 'videoPlay'; + static groupId = 'video'; + static icon = playIcon; + static iconActive = pauseIcon; + + /** + * @param {PSV.components.Navbar} navbar + */ + constructor(navbar) { + super(navbar, 'psv-button--hover-scale psv-video-play-button', true); + + /** + * @type {PSV.plugins.VideoPlugin} + * @private + * @readonly + */ + this.plugin = this.psv.getPlugin('video'); + + if (this.plugin) { + this.plugin.on(EVENTS.PLAY, this); + this.plugin.on(EVENTS.PAUSE, this); + } + } + + /** + * @override + */ + destroy() { + if (this.plugin) { + this.plugin.off(EVENTS.PLAY, this); + this.plugin.off(EVENTS.PAUSE, this); + } + + delete this.plugin; + + super.destroy(); + } + + /** + * @override + */ + isSupported() { + return !!this.plugin; + } + + /** + * @summary Handles events + * @param {Event} e + * @private + */ + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + case EVENTS.PLAY: + case EVENTS.PAUSE: + this.toggleActive(this.plugin.isPlaying()); + break; + } + /* eslint-enable */ + } + + /** + * @override + * @description Toggles video playback + */ + onClick() { + this.plugin.playPause(); + } + +} diff --git a/src/plugins/video/ProgressBar.js b/src/plugins/video/ProgressBar.js new file mode 100644 index 000000000..19e0c1bc1 --- /dev/null +++ b/src/plugins/video/ProgressBar.js @@ -0,0 +1,147 @@ +import { AbstractComponent, CONSTANTS, utils } from '../..'; +import { EVENTS } from './constants'; +import { formatTime } from './utils'; + +/** + * @private + */ +export class ProgressBar extends AbstractComponent { + + constructor(plugin) { + super(plugin.psv, 'psv-video-progressbar'); + + /** + * @type {PSV.plugins.VideoPlugin} + * @private + * @readonly + */ + this.plugin = plugin; + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.bufferElt = document.createElement('div'); + this.bufferElt.className = 'psv-video-progressbar__buffer'; + this.container.appendChild(this.bufferElt); + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.progressElt = document.createElement('div'); + this.progressElt.className = 'psv-video-progressbar__progress'; + this.container.appendChild(this.progressElt); + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.handleElt = document.createElement('div'); + this.handleElt.className = 'psv-video-progressbar__handle'; + this.container.appendChild(this.handleElt); + + /** + * @type {PSV.utils.Slider} + * @private + * @readonly + */ + this.slider = new utils.Slider({ + psv : this.psv, + container: this.container, + direction: utils.Slider.HORIZONTAL, + onUpdate : e => this.__onSliderUpdate(e), + }); + + this.psv.on(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.on(EVENTS.BUFFER, this); + this.plugin.on(EVENTS.PROGRESS, this); + + this.prop.req = window.requestAnimationFrame(() => this.__updateProgress()); + + this.hide(); + } + + /** + * @override + */ + destroy() { + this.psv.off(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.off(EVENTS.BUFFER, this); + this.plugin.off(EVENTS.PROGRESS, this); + + this.slider.destroy(); + this.prop.tooltip?.hide(); + window.cancelAnimationFrame(this.prop.req); + + delete this.prop.tooltip; + delete this.slider; + delete this.plugin; + + super.destroy(); + } + + /** + * @private + */ + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + case CONSTANTS.EVENTS.PANORAMA_LOADED: + case EVENTS.BUFFER: + case EVENTS.PROGRESS: + this.bufferElt.style.width = `${this.plugin.getBufferProgress() * 100}%`; + break; + } + /* eslint-enable */ + } + + /** + * @private + */ + __updateProgress() { + this.progressElt.style.width = `${this.plugin.getProgress() * 100}%`; + + this.prop.req = window.requestAnimationFrame(() => this.__updateProgress()); + } + + /** + * @private + */ + __onSliderUpdate(e) { + if (e.mouseover) { + this.handleElt.style.display = 'block'; + this.handleElt.style.left = `${e.value * 100}%`; + + const time = formatTime(this.plugin.getDuration() * e.value); + + if (!this.prop.tooltip) { + this.prop.tooltip = this.psv.tooltip.create({ + top : e.cursor.clientY, + left : e.cursor.clientX, + content: time, + }); + } + else { + this.prop.tooltip.content.innerHTML = time; + this.prop.tooltip.move({ + top : e.cursor.clientY, + left: e.cursor.clientX, + }); + } + } + else { + this.handleElt.style.display = 'none'; + + this.prop.tooltip?.hide(); + delete this.prop.tooltip; + } + if (e.click) { + this.plugin.setProgress(e.value); + } + } + +} diff --git a/src/plugins/video/TimeCaption.js b/src/plugins/video/TimeCaption.js new file mode 100644 index 000000000..8d0f09203 --- /dev/null +++ b/src/plugins/video/TimeCaption.js @@ -0,0 +1,73 @@ +import { AbstractComponent, CONSTANTS } from '../..'; +import { EVENTS } from './constants'; +import { formatTime } from './utils'; + +/** + * @summary Navigation bar video time display + * @extends PSV.buttons.AbstractButton + * @memberof PSV.buttons + */ +export class TimeCaption extends AbstractComponent { + + static id = 'videoTime'; + static groupId = 'video'; + + /** + * @param {PSV.components.Navbar} navbar + */ + constructor(navbar) { + super(navbar, 'psv-caption psv-video-time'); + + /** + * @member {HTMLElement} + * @readonly + * @private + */ + this.content = document.createElement('div'); + this.content.className = 'psv-caption-content'; + this.container.appendChild(this.content); + + /** + * @type {PSV.plugins.VideoPlugin} + * @private + * @readonly + */ + this.plugin = this.psv.getPlugin('video'); + + if (this.plugin) { + this.psv.on(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.on(EVENTS.PROGRESS, this); + } + } + + /** + * @override + */ + destroy() { + if (this.plugin) { + this.psv.off(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.off(EVENTS.PROGRESS, this); + } + + delete this.plugin; + + super.destroy(); + } + + /** + * @summary Handles events + * @param {Event} e + * @private + */ + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + case CONSTANTS.EVENTS.PANORAMA_LOADED: + case EVENTS.PROGRESS: + this.content.innerHTML = `${formatTime(this.plugin.getTime())} / ${formatTime(this.plugin.getDuration())}`; + break; + } + /* eslint-enable */ + } + +} diff --git a/src/plugins/video/VolumeButton.js b/src/plugins/video/VolumeButton.js new file mode 100644 index 000000000..a12af9e88 --- /dev/null +++ b/src/plugins/video/VolumeButton.js @@ -0,0 +1,172 @@ +import { AbstractButton, CONSTANTS, utils } from '../..'; +import { EVENTS } from './constants'; +import volumeIcon from './volume.svg'; + +/** + * @summary Navigation bar video volume button + * @extends PSV.buttons.AbstractButton + * @memberof PSV.buttons + */ +export class VolumeButton extends AbstractButton { + + static id = 'videoVolume'; + static groupId = 'video'; + static icon = volumeIcon; + + /** + * @param {PSV.components.Navbar} navbar + */ + constructor(navbar) { + super(navbar, 'psv-button--hover-scale psv-video-volume-button', true); + + /** + * @type {PSV.plugins.VideoPlugin} + * @private + * @readonly + */ + this.plugin = this.psv.getPlugin('video'); + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.rangeContainer = document.createElement('div'); + this.rangeContainer.className = 'psv-video-volume__container'; + this.container.appendChild(this.rangeContainer); + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.range = document.createElement('div'); + this.range.className = 'psv-video-volume__range'; + this.rangeContainer.appendChild(this.range); + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.trackElt = document.createElement('div'); + this.trackElt.className = 'psv-video-volume__track'; + this.range.appendChild(this.trackElt); + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.progressElt = document.createElement('div'); + this.progressElt.className = 'psv-video-volume__progress'; + this.range.appendChild(this.progressElt); + + /** + * @type {HTMLElement} + * @private + * @readonly + */ + this.handleElt = document.createElement('div'); + this.handleElt.className = 'psv-video-volume__handle'; + this.range.appendChild(this.handleElt); + + /** + * @type {PSV.utils.Slider} + * @private + * @readonly + */ + this.slider = new utils.Slider({ + psv : this.psv, + container: this.range, + direction: utils.Slider.VERTICAL, + onUpdate : e => this.__onSliderUpdate(e), + }); + + if (this.plugin) { + this.psv.on(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.on(EVENTS.PLAY, this); + this.plugin.on(EVENTS.VOLUME_CHANGE, this); + + this.__setVolume(0); + } + } + + /** + * @override + */ + destroy() { + if (this.plugin) { + this.psv.off(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.plugin.off(EVENTS.PLAY, this); + this.plugin.off(EVENTS.VOLUME_CHANGE, this); + } + + this.slider.destroy(); + + delete this.plugin; + + super.destroy(); + } + + /** + * @override + */ + isSupported() { + return !!this.plugin; + } + + /** + * @summary Handles events + * @param {Event} e + * @private + */ + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + case CONSTANTS.EVENTS.PANORAMA_LOADED: + case EVENTS.PLAY: + case EVENTS.VOLUME_CHANGE: + this.__setVolume(this.plugin.getVolume()); + break; + } + /* eslint-enable */ + } + + /** + * @override + * @description Toggles video muted + */ + onClick() { + this.plugin.setMute(); + } + + /** + * @private + */ + __onSliderUpdate(e) { + if (e.mousedown) { + this.plugin.setVolume(e.value); + } + } + + /** + * @private + */ + __setVolume(volume) { + let level; + if (volume === 0) level = 0; + else if (volume < 0.333) level = 1; + else if (volume < 0.666) level = 2; + else level = 3; + + utils.toggleClass(this.container, 'psv-video-volume-button--0', level === 0); + utils.toggleClass(this.container, 'psv-video-volume-button--1', level === 1); + utils.toggleClass(this.container, 'psv-video-volume-button--2', level === 2); + utils.toggleClass(this.container, 'psv-video-volume-button--3', level === 3); + + this.handleElt.style.bottom = `${volume * 100}%`; + this.progressElt.style.height = `${volume * 100}%`; + } + +} diff --git a/src/plugins/video/constants.js b/src/plugins/video/constants.js new file mode 100644 index 000000000..b9021d012 --- /dev/null +++ b/src/plugins/video/constants.js @@ -0,0 +1,35 @@ +export const EVENTS = { + /** + * @event play + * @memberof PSV.plugins.VideoPlugin + * @summary Triggered when the video starts playing + */ + PLAY : 'play', + /** + * @event pause + * @memberof PSV.plugins.VideoPlugin + * @summary Triggered when the video is paused + */ + PAUSE : 'pause', + /** + * @event volume-change + * @memberof PSV.plugins.VideoPlugin + * @summary Triggered when the video volume changes + * @param {number} volume + */ + VOLUME_CHANGE: 'volume-change', + /** + * @event progress + * @memberof PSV.plugins.VideoPlugin + * @summary Triggered when the video play progression changes + * @param {{time: number, duration: number, progress: number}} data + */ + PROGRESS : 'progress', + /** + * @event buffer + * @memberof PSV.plugins.VideoPlugin + * @summary Triggered when the video buffer changes + * @param {number} maxBuffer + */ + BUFFER : 'buffer', +}; diff --git a/src/plugins/video/index.js b/src/plugins/video/index.js new file mode 100644 index 000000000..52e6b25ab --- /dev/null +++ b/src/plugins/video/index.js @@ -0,0 +1,290 @@ +import { AbstractPlugin, CONSTANTS, DEFAULTS, PSVError, registerButton } from '../..'; +import { EVENTS } from './constants'; +import { PauseOverlay } from './PauseOverlay'; +import { PlayPauseButton } from './PlayPauseButton'; +import { ProgressBar } from './ProgressBar'; +import { TimeCaption } from './TimeCaption'; +import { VolumeButton } from './VolumeButton'; +import './style.scss'; + + +/** + * @typedef {Object} PSV.plugins.VideoPlugin.Options + * @property {boolean} [progressbar=true] - displays a progressbar on top of the navbar + * @property {boolean} [bigbutton=true] - displays a big "play" button in the center of the viewer + */ + + +// add video buttons +DEFAULTS.lang[PlayPauseButton.id] = 'Play/Pause'; +DEFAULTS.lang[VolumeButton.id] = 'Volume'; +registerButton(PlayPauseButton); +registerButton(VolumeButton); +registerButton(TimeCaption); +DEFAULTS.navbar.unshift(PlayPauseButton.groupId); + + +export { EVENTS } from './constants'; + + +/** + * @summary Controls a video adapter + * @extends PSV.plugins.AbstractPlugin + * @memberof PSV.plugins + */ +export class VideoPlugin extends AbstractPlugin { + + static id = 'video'; + + /** + * @param {PSV.Viewer} psv + * @param {PSV.plugins.VideoPlugin.Options} options + */ + constructor(psv, options) { + super(psv); + + if (this.psv.adapter.constructor.id.indexOf('video') === -1) { + throw new PSVError('VideoPlugin can only be used with a video adapter.'); + } + + /** + * @member {Object} + * @private + */ + this.prop = {}; + + /** + * @member {PSV.plugins.VideoPlugin.Options} + * @private + */ + this.config = { + progressbar: true, + bigbutton : true, + ...options, + }; + + if (this.config.progressbar) { + this.progressbar = new ProgressBar(this); + } + + if (this.config.bigbutton) { + this.overlay = new PauseOverlay(this); + } + } + + /** + * @package + */ + init() { + super.init(); + + this.psv.on(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.psv.on(CONSTANTS.EVENTS.KEY_PRESS, this); + } + + /** + * @package + */ + destroy() { + this.psv.off(CONSTANTS.EVENTS.PANORAMA_LOADED, this); + this.psv.off(CONSTANTS.EVENTS.KEY_PRESS, this); + + this.progressbar?.destroy(); + this.overlay?.destroy(); + + delete this.progressbar; + delete this.overlay; + + super.destroy(); + } + + /** + * @private + */ + handleEvent(e) { + /* eslint-disable */ + // @formatter:off + switch (e.type) { + case CONSTANTS.EVENTS.PANORAMA_LOADED: + this.__bindVideo(e.args[0]); + this.progressbar?.show(); + break; + case CONSTANTS.EVENTS.KEY_PRESS: + this.__onKeyPress(e, e.args[0]); + break; + case 'play': this.trigger(EVENTS.PLAY); break; + case 'pause': this.trigger(EVENTS.PAUSE); break; + case 'progress': this.trigger(EVENTS.BUFFER, this.getBufferProgress()); break; + case 'volumechange': this.trigger(EVENTS.VOLUME_CHANGE, this.getVolume()); break; + case 'timeupdate': + this.trigger(EVENTS.PROGRESS, { + time : this.getTime(), + duration: this.getDuration(), + progress: this.getProgress(), + }); + break; + } + // @formatter:on + /* eslint-enable */ + } + + /** + * @private + */ + __bindVideo(textureData) { + this.video = textureData.texture.image; + + this.video.addEventListener('play', this); + this.video.addEventListener('pause', this); + this.video.addEventListener('progress', this); + this.video.addEventListener('volumechange', this); + this.video.addEventListener('timeupdate', this); + } + + /** + * @private + */ + __onKeyPress(e, key) { + if (key === CONSTANTS.KEY_CODES.Space) { + this.playPause(); + e.preventDefault(); + } + } + + /** + * @summary Returns the durection of the video + * @returns {number} + */ + getDuration() { + return this.video?.duration ?? 0; + } + + /** + * @summary Returns the current time of the video + * @returns {number} + */ + getTime() { + return this.video?.currentTime ?? 0; + } + + /** + * @summary Returns the play progression of the video + * @returns {number} 0-1 + */ + getProgress() { + return this.video ? this.video.currentTime / this.video.duration : 0; + } + + /** + * @summary Returns if the video is playing + * @returns {boolean} + */ + isPlaying() { + return this.video ? !this.video.paused : false; + } + + /** + * @summary Returns the video volume + * @returns {number} + */ + getVolume() { + return this.video?.muted ? 0 : this.video?.volume ?? 0; + } + + /** + * @summary Starts or pause the video + */ + playPause() { + if (this.video) { + if (this.video.paused) { + this.video.play(); + } + else { + this.video.pause(); + } + } + } + + /** + * @summary Starts the video if paused + */ + play() { + if (this.video && this.video.paused) { + this.video.play(); + } + } + + /** + * @summary Pauses the cideo if playing + */ + pause() { + if (this.video && !this.video.paused) { + this.video.pause(); + } + } + + /** + * @summary Sets the volume of the video + * @param {number} volume + */ + setVolume(volume) { + if (this.video) { + this.video.muted = false; + this.video.volume = volume; + } + } + + /** + * @summary (Un)mutes the video + * @param {boolean} [mute] - toggle if undefined + */ + setMute(mute) { + if (this.video) { + this.video.muted = mute === undefined ? !this.video.muted : mute; + if (!this.video.muted && this.video.volume === 0) { + this.video.volume = 0.1; + } + } + } + + /** + * @summary Changes the current time of the video + * @param {number} time + */ + setTime(time) { + if (this.video) { + this.video.currentTime = time; + } + } + + /** + * @summary Changes the progression of the video + * @param {number} progress 0-1 + */ + setProgress(progress) { + if (this.video) { + this.video.currentTime = this.video.duration * progress; + } + } + + getBufferProgress() { + if (this.video) { + let maxBuffer = 0; + + const buffer = this.video.buffered; + + for (let i = 0, l = buffer.length; i < l; i++) { + if (buffer.start(i) <= this.video.currentTime && buffer.end(i) >= this.video.currentTime) { + maxBuffer = buffer.end(i); + break; + } + } + + return Math.max(this.video.currentTime, maxBuffer) / this.video.duration; + } + else { + return 0; + } + } + +} diff --git a/src/plugins/video/pause.svg b/src/plugins/video/pause.svg new file mode 100644 index 000000000..22a3936a7 --- /dev/null +++ b/src/plugins/video/pause.svg @@ -0,0 +1 @@ + diff --git a/src/plugins/video/play.svg b/src/plugins/video/play.svg new file mode 100644 index 000000000..5eebb16e0 --- /dev/null +++ b/src/plugins/video/play.svg @@ -0,0 +1 @@ + diff --git a/src/plugins/video/style.scss b/src/plugins/video/style.scss new file mode 100644 index 000000000..5e19513ed --- /dev/null +++ b/src/plugins/video/style.scss @@ -0,0 +1,199 @@ +@use 'sass:map'; +@import '../../styles/vars'; + +$psv-progressbar-height: 3px !default; +$psv-progressbar-height-active: 5px !default; +$psv-progressbar-container: 20px !default; +$psv-progressbar-progress-color: $psv-buttons-color !default; +$psv-progressbar-buffer-color: $psv-buttons-active-background !default; +$psv-progressbar-handle-size: 9px !default; +$psv-progressbar-handle-color: white !default; + +$psv-volume-height: 80px !default; +$psv-volume-width: $psv-progressbar-height-active !default; +$psv-volume-bar-color: $psv-progressbar-progress-color !default; +$psv-volume-track-color: $psv-progressbar-buffer-color !default; +$psv-volume-handle-size: $psv-progressbar-handle-size !default; +$psv-volume-handle-color: $psv-progressbar-handle-color !default; + +$psv-video-bigbutton-size: (portrait: 20vw, landscape: 10vw) !default; +$psv-video-bigbutton-color: $psv-buttons-color !default; + +.psv-video { + &-progressbar { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: $psv-progressbar-container; + cursor: pointer; + z-index: $psv-navbar-zindex - 1; + + @at-root .psv--has-navbar & { + bottom: $psv-navbar-height; + } + + & > * { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: $psv-progressbar-height; + transition: height .2s linear; + } + + &:hover > * { + height: $psv-progressbar-height-active; + } + + &__progress { + background-color: $psv-progressbar-progress-color; + } + + &__buffer { + background-color: $psv-progressbar-buffer-color; + } + + &__handle { + display: none; + height: $psv-progressbar-handle-size !important; + width: $psv-progressbar-handle-size; + border-radius: 50%; + margin-bottom: #{- ($psv-progressbar-handle-size - $psv-progressbar-height-active) * .5}; + margin-left: #{- $psv-progressbar-handle-size * .5}; + background: $psv-progressbar-handle-color; + } + } + + &-time { + flex: 0 0 auto; + + .psv-caption-content { + min-width: 6em; + text-align: center; + } + } + + &-volume { + &__container { + position: absolute; + left: 0; + bottom: $psv-navbar-height; + padding: $psv-buttons-height 0; + width: $psv-navbar-height; + height: 0; + opacity: 0; + background: $psv-navbar-background; + transition: opacity .2s linear, height .3s step-end; + } + + &__range { + position: relative; + height: $psv-volume-height; + } + + &__progress, + &__track { + position: absolute; + bottom: 0; + left: #{($psv-navbar-height - $psv-volume-width) * .5}; + width: $psv-volume-width; + background: $psv-volume-bar-color; + } + + &__track { + height: 100%; + background: $psv-volume-track-color; + } + + &__handle { + position: absolute; + left: #{($psv-navbar-height - $psv-volume-width) * .5}; + height: $psv-volume-handle-size; + width: $psv-volume-handle-size; + border-radius: 50%; + margin-left: #{- ($psv-volume-handle-size - $psv-volume-width) * .5}; + margin-bottom: #{- $psv-volume-handle-size * .5}; + background: $psv-volume-handle-color; + } + } + + &-volume-button { + position: relative; + + &:hover .psv-video-volume__container { + height: $psv-volume-height; + opacity: 1; + transition-timing-function: linear, step-start; + } + + &--0 .psv-button-svg { + #lvl1, + #lvl2, + #lvl3 { + fill: none; + } + } + + &--1 .psv-button-svg { + #lvl0, + #lvl2, + #lvl3 { + fill: none; + } + } + + &--2 .psv-button-svg { + #lvl0, + #lvl3 { + fill: none; + } + } + + &--3 .psv-button-svg { + #lvl0 { + fill: none; + } + } + } + + &-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: $psv-overlay-zindex; + pointer-events: none; + } + + &-bigbutton { + display: block; + border: none; + background: none; + padding: 0; + color: $psv-video-bigbutton-color; + pointer-events: auto; + cursor: pointer; + opacity: 0; + width: 0; + transition: opacity .2s linear, width .3s step-end; + + &--pause { + width: map.get($psv-video-bigbutton-size, portrait); + opacity: 1; + transition-timing-function: linear, step-start; + + @media (orientation: landscape) { + width: map.get($psv-video-bigbutton-size, landscape); + } + } + + svg { + width: 100%; + } + } +} diff --git a/src/plugins/video/utils.js b/src/plugins/video/utils.js new file mode 100644 index 000000000..5428e93de --- /dev/null +++ b/src/plugins/video/utils.js @@ -0,0 +1,8 @@ +/** + * @private + */ +export function formatTime(time) { + const seconds = Math.round(time % 60); + const minutes = Math.round(time - seconds) / 60; + return `${minutes}:${('0' + seconds).slice(-2)}`; +} diff --git a/src/plugins/video/volume.svg b/src/plugins/video/volume.svg new file mode 100644 index 000000000..d9b1861a8 --- /dev/null +++ b/src/plugins/video/volume.svg @@ -0,0 +1 @@ + diff --git a/src/plugins/virtual-tour/AbstractDatasource.js b/src/plugins/virtual-tour/AbstractDatasource.js index 0cd9eac32..8c6f216b7 100644 --- a/src/plugins/virtual-tour/AbstractDatasource.js +++ b/src/plugins/virtual-tour/AbstractDatasource.js @@ -2,7 +2,7 @@ import { PSVError } from 'photo-sphere-viewer'; /** * @memberOf PSV.plugins.VirtualTourPlugin - * @package + * @private */ export class AbstractDatasource { diff --git a/src/plugins/virtual-tour/ClientSideDatasource.js b/src/plugins/virtual-tour/ClientSideDatasource.js index 1ae243a1b..75553804a 100644 --- a/src/plugins/virtual-tour/ClientSideDatasource.js +++ b/src/plugins/virtual-tour/ClientSideDatasource.js @@ -4,7 +4,7 @@ import { checkLink, checkNode } from './utils'; /** * @memberOf PSV.plugins.VirtualTourPlugin - * @package + * @private */ export class ClientSideDatasource extends AbstractDatasource { diff --git a/src/plugins/virtual-tour/ServerSideDatasource.js b/src/plugins/virtual-tour/ServerSideDatasource.js index 8600237fb..bcc61b425 100644 --- a/src/plugins/virtual-tour/ServerSideDatasource.js +++ b/src/plugins/virtual-tour/ServerSideDatasource.js @@ -4,7 +4,7 @@ import { checkLink, checkNode } from './utils'; /** * @memberOf PSV.plugins.VirtualTourPlugin - * @package + * @private */ export class ServerSideDatasource extends AbstractDatasource { diff --git a/src/services/EventsHandler.js b/src/services/EventsHandler.js index 9b2dffd00..194d9411b 100644 --- a/src/services/EventsHandler.js +++ b/src/services/EventsHandler.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { Animation } from '../Animation'; +import { Animation } from '../utils/Animation'; import { ACTIONS, CTRLZOOM_TIMEOUT, @@ -215,6 +215,11 @@ export class EventsHandler extends AbstractService { } } + const e = this.psv.trigger(EVENTS.KEY_PRESS, key); + if (e.isDefaultPrevented()) { + return; + } + if (!this.state.keyboardEnabled) { return; } diff --git a/src/services/Renderer.js b/src/services/Renderer.js index 65baa2281..f7b7e751c 100644 --- a/src/services/Renderer.js +++ b/src/services/Renderer.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { Animation } from '../Animation'; +import { Animation } from '../utils/Animation'; import { EVENTS, MESH_USER_DATA, SPHERE_RADIUS } from '../data/constants'; import { SYSTEM } from '../data/system'; import { each, isExtendedPosition } from '../utils'; @@ -242,7 +242,7 @@ export class Renderer extends AbstractService { this.psv.needsUpdate(); - this.psv.trigger(EVENTS.PANORAMA_LOADED); + this.psv.trigger(EVENTS.PANORAMA_LOADED, textureData); } /** diff --git a/src/styles/navbar.scss b/src/styles/navbar.scss index 92ed99673..28e23dd45 100644 --- a/src/styles/navbar.scss +++ b/src/styles/navbar.scss @@ -64,7 +64,7 @@ opacity: $psv-buttons-disabled-opacity; } - .psv-button-svg { + &-svg { width: 100%; transform: scale(1); transition: transform $psv-buttons-hover-scale-delay ease; diff --git a/src/styles/panel.scss b/src/styles/panel.scss index 2f5465f53..3606aa7e1 100644 --- a/src/styles/panel.scss +++ b/src/styles/panel.scss @@ -144,7 +144,12 @@ } .psv-panel-menu { + height: 100%; + display: flex; + flex-direction: column; + &-title { + flex: none; display: flex; align-items: center; font: $psv-panel-title-font; @@ -158,10 +163,11 @@ } &-list { + flex: 1; list-style: none; margin: 0; padding: 0; - overflow: hidden; + overflow-x: hidden; } &-item { diff --git a/src/Animation.js b/src/utils/Animation.js similarity index 94% rename from src/Animation.js rename to src/utils/Animation.js index c61ced057..f8158c6db 100644 --- a/src/Animation.js +++ b/src/utils/Animation.js @@ -1,17 +1,18 @@ -import { EASINGS } from './data/constants'; -import { each, logWarn } from './utils'; +import { EASINGS } from '../data/constants'; +import { each } from './misc'; +import { logWarn } from './psv'; /** * @callback OnTick * @summary Function called for each animation frame with computed properties - * @memberOf PSV.Animation + * @memberOf PSV.utils.Animation * @param {Object.} properties - current values * @param {float} progress - 0 to 1 */ /** * @summary Interpolation helper for animations - * @memberOf PSV + * @memberOf PSV.utils * @description * Implements the Promise API with an additional "cancel" method. * The promise is resolved with `true` when the animation is completed and `false` if the animation is cancelled. @@ -38,7 +39,7 @@ export class Animation { * @param {number} options.duration * @param {number} [options.delay=0] * @param {string} [options.easing='linear'] - * @param {PSV.Animation.OnTick} options.onTick - called on each frame + * @param {PSV.utils.Animation.OnTick} options.onTick - called on each frame */ constructor(options) { this.__callbacks = []; @@ -120,7 +121,7 @@ export class Animation { * @summary Promise chaining * @param {Function} [onFulfilled] - Called when the animation is complete (true) or cancelled (false) * @param {Function} [onRejected] - deprecated - * @returns {PSV.Promise} + * @returns {Promise} */ then(onFulfilled = null, onRejected = null) { if (onRejected) { diff --git a/src/utils/Dynamic.js b/src/utils/Dynamic.js index 88825e67d..f089bb38c 100644 --- a/src/utils/Dynamic.js +++ b/src/utils/Dynamic.js @@ -2,8 +2,7 @@ import { bound } from './index'; /** * @summary Represents a variable that can dynamically change with time (using requestAnimationFrame) - * @memberOf PSV - * @package + * @memberOf PSV.utils */ export class Dynamic { diff --git a/src/utils/DynamicXD.js b/src/utils/DynamicXD.js index 1b90b2000..4117adcaf 100644 --- a/src/utils/DynamicXD.js +++ b/src/utils/DynamicXD.js @@ -2,8 +2,8 @@ import { bound } from './math'; import { each } from './misc'; /** - * @summary Implementation of {@link PSV.Dynamic} for any number of variables, unused - * @memberOf PSV + * @summary Implementation of {@link PSV.utils.Dynamic} for any number of variables, unused + * @memberOf PSV.utils * @private */ export class DynamicXD { diff --git a/src/utils/MultiDynamic.js b/src/utils/MultiDynamic.js index cb91a6ceb..1b36f15ec 100644 --- a/src/utils/MultiDynamic.js +++ b/src/utils/MultiDynamic.js @@ -1,9 +1,8 @@ import { each } from './index'; /** - * @summary Wrapper for multiple {@link PSV.Dynamic} evolving together - * @memberOf PSV - * @package + * @summary Wrapper for multiple {@link PSV.utils.Dynamic} evolving together + * @memberOf PSV.utils */ export class MultiDynamic { @@ -20,7 +19,7 @@ export class MultiDynamic { } /** - * @param {Record} dynamics + * @param {Record} dynamics * @param {Function} [fn] Callback function */ constructor(dynamics, fn) { @@ -32,7 +31,7 @@ export class MultiDynamic { this.fn = fn; /** - * @type {Record} + * @type {Record} * @private * @readonly */ diff --git a/src/utils/PressHandler.js b/src/utils/PressHandler.js index e07184ab1..387edf907 100644 --- a/src/utils/PressHandler.js +++ b/src/utils/PressHandler.js @@ -1,8 +1,7 @@ /** * @summary Helper for pressable things (buttons, keyboard) * @description When the pressed thing goes up and was not pressed long enough, wait a bit more before execution - * @package - * @package + * @private */ export class PressHandler { diff --git a/src/utils/Slider.js b/src/utils/Slider.js new file mode 100644 index 000000000..9296a03ef --- /dev/null +++ b/src/utils/Slider.js @@ -0,0 +1,187 @@ +import { EventEmitter } from 'uevent'; + +/** + * @summary Helper to make sliders elements + * @memberOf PSV.utils + */ +export class Slider extends EventEmitter { + + static VERTICAL = 1; + static HORIZONTAL = 2; + + /** + * @type {boolean} + * @readonly + */ + get vertical() { + return this.prop.direction === Slider.VERTICAL; + } + + constructor({ psv, container, direction, onUpdate }) { + super(); + + /** + * @summary Reference to main controller + * @type {PSV.Viewer} + * @readonly + */ + this.psv = psv; + + /** + * @member {HTMLElement} + * @readonly + */ + this.container = container; + + /** + * @summary Internal properties + * @member {Object} + * @protected + * @property {boolean} mousedown + * @property {number} mediaMinWidth + */ + this.prop = { + onUpdate : onUpdate, + direction: direction, + mousedown: false, + mouseover: false, + }; + + this.container.addEventListener('click', this); + this.container.addEventListener('mousedown', this); + this.container.addEventListener('mouseenter', this); + this.container.addEventListener('mouseleave', this); + this.container.addEventListener('touchstart', this); + this.container.addEventListener('mousemove', this, true); + this.container.addEventListener('touchmove', this, true); + window.addEventListener('mouseup', this); + window.addEventListener('touchend', this); + } + + /** + * @protected + */ + destroy() { + window.removeEventListener('mouseup', this); + window.removeEventListener('touchend', this); + } + + /** + * @summary Handles events + * @param {Event} e + * @private + */ + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + // @formatter:off + case 'click': e.stopPropagation(); break; + case 'mousedown': this.__onMouseDown(e); break; + case 'mouseenter': this.__onMouseEnter(e); break; + case 'mouseleave': this.__onMouseLeave(e); break; + case 'touchstart': this.__onTouchStart(e); break; + case 'mousemove': this.__onMouseMove(e); break; + case 'touchmove': this.__onTouchMove(e); break; + case 'mouseup': this.__onMouseUp(e); break; + case 'touchend': this.__onTouchEnd(e); break; + // @formatter:on + } + /* eslint-enable */ + } + + /** + * @private + */ + __onMouseDown(evt) { + this.prop.mousedown = true; + this.__update(evt, true); + } + + /** + * @private + */ + __onMouseEnter(evt) { + this.prop.mouseover = true; + this.__update(evt, true); + } + + /** + * @private + */ + __onTouchStart(evt) { + this.prop.mouseover = true; + this.prop.mousedown = true; + this.__update(evt.changedTouches[0], true); + } + + /** + * @private + */ + __onMouseMove(evt) { + if (this.prop.mousedown || this.prop.mouseover) { + evt.stopPropagation(); + this.__update(evt, true); + } + } + + /** + * @private + */ + __onTouchMove(evt) { + if (this.prop.mousedown || this.prop.mouseover) { + evt.stopPropagation(); + this.__update(evt.changedTouches[0], true); + } + } + + /** + * @private + */ + __onMouseUp(evt) { + if (this.prop.mousedown) { + this.prop.mousedown = false; + this.__update(evt, false); + } + } + + /** + * @private + */ + __onMouseLeave(evt) { + if (this.prop.mouseover) { + this.prop.mouseover = false; + this.__update(evt, true); + } + } + + /** + * @private + */ + __onTouchEnd(evt) { + if (this.prop.mousedown) { + this.prop.mouseover = false; + this.prop.mousedown = false; + this.__update(evt.changedTouches[0], false); + } + } + + /** + * @private + */ + __update(evt, moving) { + const boundingClientRect = this.container.getBoundingClientRect(); + const cursor = evt[this.vertical ? 'clientY' : 'clientX']; + const pos = boundingClientRect[this.vertical ? 'bottom' : 'left']; + const size = boundingClientRect[this.vertical ? 'height' : 'width']; + const val = Math.abs((pos - cursor) / size); + + this.prop.onUpdate({ + value : val, + click : !moving, + mousedown: this.prop.mousedown, + mouseover: this.prop.mouseover, + cursor : evt, + }); + } + +} diff --git a/src/utils/index.js b/src/utils/index.js index 54ea944e5..09323ac48 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -6,3 +6,8 @@ export * from './browser'; export * from './math'; export * from './misc'; export * from './psv'; + +export * from './Animation'; +export * from './Dynamic'; +export * from './MultiDynamic'; +export * from './Slider'; diff --git a/types/Viewer.d.ts b/types/Viewer.d.ts index 9fb400e46..95a23a412 100644 --- a/types/Viewer.d.ts +++ b/types/Viewer.d.ts @@ -1,7 +1,7 @@ import { Vector3 } from 'three'; import { Event, EventEmitter } from 'uevent'; import { AdapterConstructor } from './adapters/AbstractAdapter'; -import { Animation } from './Animation'; +import { Animation } from './utils/Animation'; import { Loader } from './components/Loader'; import { Navbar } from './components/Navbar'; import { Notification } from './components/Notification'; @@ -18,7 +18,8 @@ import { PanoDataProvider, PanoramaOptions, Position, - Size + Size, + TextureData } from './models'; import { AbstractPlugin, PluginConstructor } from './plugins/AbstractPlugin'; import { DataHelper } from './services/DataHelper'; @@ -348,6 +349,10 @@ export class Viewer extends EventEmitter { * @summary Triggered when the tooltip is hidden */ on(e: 'hide-tooltip', cb: (e: Event, data: any) => void): this; + /** + * @summary Triggered when a key is pressed, can be cancelled + */ + on(e: 'key-press', cb: (e: Event, key: string) => void): this; /** * @summary Triggered when the loader value changes */ @@ -359,7 +364,7 @@ export class Viewer extends EventEmitter { /** * @summary Triggered when a panorama image has been loaded */ - on(e: 'panorama-loaded', cb: (e: Event) => void): this; + on(e: 'panorama-loaded', cb: (e: Event, textureData: TextureData) => void): this; /** * @summary Triggered when the view longitude and/or latitude changes */ diff --git a/types/adapters/AbstractAdapter.d.ts b/types/adapters/AbstractAdapter.d.ts index 3742cbf69..598062164 100644 --- a/types/adapters/AbstractAdapter.d.ts +++ b/types/adapters/AbstractAdapter.d.ts @@ -57,7 +57,7 @@ export abstract class AbstractAdapter { setTextureOpacity(mesh: Mesh, opacity: number); /** - * @abstract + * @summary Cleanup a loaded texture, used on load abort */ disposeTexture(textureData: TextureData); diff --git a/types/adapters/cubemap-video/index.d.ts b/types/adapters/cubemap-video/index.d.ts new file mode 100644 index 000000000..eaa4104bd --- /dev/null +++ b/types/adapters/cubemap-video/index.d.ts @@ -0,0 +1,23 @@ +import { AbstractAdapter, Viewer } from '../..'; + +/** + * @summary Configuration of a cubemap video + */ +export type CubemapVideoPanorama = { + source: string; +}; + +export type CubemapVideoAdapterOptions = { + autoplay?: boolean; + muted?: boolean; + equiangular?: boolean; +} + +/** + * @summary Adapter for cubemap videos + */ +export class CubemapVideoAdapter extends AbstractAdapter { + + constructor(psv: Viewer, options: CubemapVideoAdapterOptions); + +} diff --git a/types/adapters/equirectangular-video/index.d.ts b/types/adapters/equirectangular-video/index.d.ts new file mode 100644 index 000000000..6a726ec42 --- /dev/null +++ b/types/adapters/equirectangular-video/index.d.ts @@ -0,0 +1,23 @@ +import { AbstractAdapter, Viewer } from '../..'; + +/** + * @summary Configuration of an equirectangular video + */ +export type EquirectangularVideoPanorama = { + source: string; +}; + +export type EquirectangularVideoAdapterOptions = { + autoplay?: boolean; + muted?: boolean; + resolution?: number; +} + +/** + * @summary Adapter for equirectangular videos + */ +export class EquirectangularVideoAdapter extends AbstractAdapter { + + constructor(psv: Viewer, options: EquirectangularVideoAdapterOptions); + +} diff --git a/types/buttons/AbstractButton.d.ts b/types/buttons/AbstractButton.d.ts index 5ec85cb92..497c3078c 100644 --- a/types/buttons/AbstractButton.d.ts +++ b/types/buttons/AbstractButton.d.ts @@ -11,6 +11,11 @@ export abstract class AbstractButton extends AbstractComponent { */ static id: string; + /** + * @summary Identifier to declare a group of buttons + */ + static groupId?: string; + /** * @summary SVG icon name injected in the button */ diff --git a/types/index.d.ts b/types/index.d.ts index 75a002617..6e89b299d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,23 +1,27 @@ import * as CONSTANTS from './data/constants'; import * as utils from './utils'; -export * from './models'; -export * from './data/config'; -export * from './data/system'; export * from './adapters/AbstractAdapter'; export * from './adapters/equirectangular'; export * from './buttons/AbstractButton'; -export * from './plugins/AbstractPlugin'; -export * from './Animation'; -export * from './PSVError'; -export * from './components/Navbar'; +export * from './components/AbstractComponent'; export * from './components/Loader'; +export * from './components/Navbar'; export * from './components/Notification'; export * from './components/Overlay'; export * from './components/Panel'; export * from './components/Tooltip'; +export * from './data/config'; +export * from './data/system'; +export * from './models'; +export * from './plugins/AbstractPlugin'; +export * from './PSVError'; export * from './services/DataHelper'; export * from './services/TextureLoader'; export * from './services/TooltipRenderer'; +/** + * @deprecated use `utils.Animation` + */ +export * from './utils/Animation'; export * from './Viewer'; export { CONSTANTS, utils }; diff --git a/types/plugins/video/index.d.ts b/types/plugins/video/index.d.ts new file mode 100644 index 000000000..24c463110 --- /dev/null +++ b/types/plugins/video/index.d.ts @@ -0,0 +1,73 @@ +import { AbstractPlugin, Viewer } from '../..'; +import { Event } from 'uevent'; + +export const EVENTS: { + PLAY: 'play', + PAUSE: 'pause', + VOLUME_CHANGE: 'volume-change', + PROGRESS: 'progress', + BUFFER: 'buffer', +}; + +export type VideoPluginOptions = { + progressbar?: boolean; + progressbar?: boolean; +}; + +/** + * @summary Controls a video adapter + */ +export class VideoPlugin extends AbstractPlugin { + + constructor(psv: Viewer, options: VideoPluginOptions); + + getDuration(): number; + + getTime(): number; + + getProgress(): number; + + isPlaying(): boolean; + + getVolume(): number; + + playPause(): void; + + play(): void; + + pause(): void; + + setVolume(volume: number): void; + + setMute(mute?: boolean): void; + + setTime(time: number): void; + + setProgress(progress: number): void; + + /** + * @summary Triggered when the video starts playing + */ + on(e: 'play', cb: (e: Event) => void): this; + + /** + * @summary Triggered when the video is paused + */ + on(e: 'pause', cb: (e: Event) => void): this; + + /** + * @summary Triggered when the video volume changes + */ + on(e: 'volume-change', cb: (e: Event, volume: number) => void): this; + + /** + * @summary Triggered when the video play progression changes + */ + on(e: 'progress', cb: (e: Event, data: { time: number, duration: number, progress: number }) => void): this; + + /** + * @summary Triggered when the video buffer changes + */ + on(e: 'buffer', cb: (e: Event, maxBuffer: number) => void): this; + +} diff --git a/types/Animation.d.ts b/types/utils/Animation.d.ts similarity index 100% rename from types/Animation.d.ts rename to types/utils/Animation.d.ts diff --git a/types/utils/index.d.ts b/types/utils/index.d.ts index ac75c05e7..5fa8d1bf1 100644 --- a/types/utils/index.d.ts +++ b/types/utils/index.d.ts @@ -2,3 +2,5 @@ export * from './browser'; export * from './math'; export * from './misc'; export * from './psv'; + +export * from './Animation'; From 3fc84244b831d982df0a64a1fdbbae8099e8b284 Mon Sep 17 00:00:00 2001 From: mistic100 Date: Mon, 14 Mar 2022 19:35:39 +0100 Subject: [PATCH 02/12] autorotate keypoints: use THREE spline --- src/plugins/autorotate-keypoints/index.js | 108 +++++++--------------- 1 file changed, 33 insertions(+), 75 deletions(-) diff --git a/src/plugins/autorotate-keypoints/index.js b/src/plugins/autorotate-keypoints/index.js index ba51c7eb7..d2942e62a 100644 --- a/src/plugins/autorotate-keypoints/index.js +++ b/src/plugins/autorotate-keypoints/index.js @@ -1,19 +1,23 @@ +import * as THREE from 'three'; import { AbstractPlugin, CONSTANTS, PSVError, utils } from '../..'; - /** - * @typedef {PSV.ExtendedPosition|string|Object} PSV.plugins.AutorotateKeypointsPlugin.Keypoints - * @summary Definition of keypoints for automatic rotation, can be a position object, a marker id or an object with the following properties - * @property {string} [markerId] + * @typedef {Object} PSV.plugins.AutorotateKeypointsPlugin.KeypointObject * @property {PSV.ExtendedPosition} [position] + * @property {string} [markerId] - use the position and tooltip of a marker + * @property {number} [pause=0] - pause the animation when reaching this point, will display the tooltip if available * @property {string|{content: string, position: string}} [tooltip] - * @property {number} [pause=0] + */ + +/** + * @typedef {PSV.ExtendedPosition|string|PSV.plugins.AutorotateKeypointsPlugin.KeypointObject} PSV.plugins.AutorotateKeypointsPlugin.Keypoint + * @summary Definition of keypoints for automatic rotation, can be a position object, a marker id or an keypoint object */ /** * @typedef {Object} PSV.plugins.AutorotateKeypointsPlugin.Options * @property {boolean} [startFromClosest=true] - start from the closest keypoint instead of the first keypoint - * @property {PSV.plugins.AutorotateKeypointsPlugin.Keypoints[]} keypoints + * @property {PSV.plugins.AutorotateKeypointsPlugin.Keypoint[]} keypoints */ @@ -44,8 +48,8 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { * @member {Object} * @property {number} idx - current index in keypoints * @property {number[][]} curve - curve between idx and idx + 1 - * @property {number[]} startPt - start point of the current step - * @property {number[]} endPt - end point of the current step + * @property {number[]} startStep - start point of the current step + * @property {number[]} endStep - end point of the current step * @property {number} startTime - start time of the current step * @property {number} stepDuration - expected duration of the step * @property {number} remainingPause - time remaining for the pause @@ -65,7 +69,7 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { }; /** - * @type {PSV.plugins.AutorotateKeypointsPlugin.Keypoints[]} keypoints + * @type {PSV.plugins.AutorotateKeypointsPlugin.Keypoint[]} keypoints */ this.keypoints = null; @@ -120,7 +124,7 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { /** * @summary Changes the keypoints - * @param {PSV.plugins.AutorotateKeypointsPlugin.Keypoints[]} keypoints + * @param {PSV.plugins.AutorotateKeypointsPlugin.Keypoint[]} keypoints */ setKeypoints(keypoints) { if (keypoints?.length < 2) { @@ -139,7 +143,7 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { } if (pt.markerId) { if (!this.markers) { - throw new PSVError(`Keypoint #${i} references a marker but markers plugin is not loaded`); + throw new PSVError(`Keypoint #${i} references a marker but the markers plugin is not loaded`); } const marker = this.markers.getMarker(pt.markerId); pt.position = serializePt(marker.props.position); @@ -178,8 +182,8 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { this.state = { idx : -1, curve : [], - startPt : null, - endPt : null, + startStep : null, + endStep : null, startTime : null, stepDuration : null, remainingPause: null, @@ -204,7 +208,7 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { if (this.psv.isAutorotateEnabled()) { // initialisation if (!this.state.startTime) { - this.state.endPt = serializePt(this.psv.getPosition()); + this.state.endStep = serializePt(this.psv.getPosition()); this.__nextStep(); this.state.startTime = timestamp; @@ -272,7 +276,7 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { */ __nextPoint() { // get the 4 points necessary to compute the current movement - // one point before and two points after the current + // the two points of the current segments and one point before and after const workPoints = []; if (this.state.idx === -1) { const currentPosition = serializePt(this.psv.getPosition()); @@ -293,7 +297,7 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { } // apply offsets to avoid crossing the origin - const workPoints2 = [workPoints[0].slice(0)]; + const workVectors = [new THREE.Vector2(...workPoints[0])]; let k = 0; for (let i = 1; i <= 3; i++) { @@ -306,17 +310,20 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { } if (k !== 0 && i === 1) { // do not modify first point, apply the reverse offset the the previous point instead - workPoints2[0][0] -= k * 2 * Math.PI; + workVectors[0].x -= k * 2 * Math.PI; k = 0; } - workPoints2.push([workPoints[i][0] + k * 2 * Math.PI, workPoints[i][1]]); + workVectors.push(new THREE.Vector2(workPoints[i][0] + k * 2 * Math.PI, workPoints[i][1])); } - const curve = this.__getCurvePoints(workPoints2, 0.6, NUM_STEPS); + const curve = new THREE.SplineCurve(workVectors) + .getPoints(NUM_STEPS * 3) + .map(p => ([p.x, p.y])); + // __debugCurve(this.markers, curve); // only keep the curve for the current movement - this.state.curve = curve.slice(NUM_STEPS, NUM_STEPS * 2); + this.state.curve = curve.slice(NUM_STEPS + 1, NUM_STEPS * 2 + 1); if (this.state.idx !== -1) { this.state.remainingPause = this.keypoints[this.state.idx].pause; @@ -341,15 +348,15 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { this.__nextPoint(); // reset transformation made to the previous point - this.state.endPt[0] = utils.parseAngle(this.state.endPt[0]); + this.state.endStep[0] = utils.parseAngle(this.state.endStep[0]); } // target next point - this.state.startPt = this.state.endPt; - this.state.endPt = this.state.curve.shift(); + this.state.startStep = this.state.endStep; + this.state.endStep = this.state.curve.shift(); // compute duration from distance and speed - const distance = utils.greatArcDistance(this.state.startPt, this.state.endPt); + const distance = utils.greatArcDistance(this.state.startStep, this.state.endStep); this.state.stepDuration = distance * 1000 / Math.abs(this.psv.config.autorotateSpeed); if (distance === 0) { // edge case @@ -385,63 +392,14 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { } this.psv.rotate({ - longitude: this.state.startPt[0] + (this.state.endPt[0] - this.state.startPt[0]) * progress, - latitude : this.state.startPt[1] + (this.state.endPt[1] - this.state.startPt[1]) * progress, + longitude: this.state.startStep[0] + (this.state.endStep[0] - this.state.startStep[0]) * progress, + latitude : this.state.startStep[1] + (this.state.endStep[1] - this.state.startStep[1]) * progress, }); } /** - * @summary Interpolate curvature points using cardinal spline - * {@link https://stackoverflow.com/a/15528789/1207670} - * @param {number[][]} pts - * @param {number} [tension=0.5] - * @param {number} [numOfSegments=16] - * @returns {number[][]} * @private */ - __getCurvePoints(pts, tension = 0.5, numOfSegments = 16) { - const res = []; - - // The algorithm require a previous and next point to the actual point array. - const _pts = pts.slice(0); - _pts.unshift(pts[0]); - _pts.push(pts[pts.length - 1]); - - // 1. loop through each point - // 2. loop through each segment - for (let i = 1; i < _pts.length - 2; i++) { - // calc tension vectors - const t1x = (_pts[i + 1][0] - _pts[i - 1][0]) * tension; - const t2x = (_pts[i + 2][0] - _pts[i][0]) * tension; - - const t1y = (_pts[i + 1][1] - _pts[i - 1][1]) * tension; - const t2y = (_pts[i + 2][1] - _pts[i][1]) * tension; - - for (let t = 1; t <= numOfSegments; t++) { - // calc step - const st = t / numOfSegments; - - const st3 = Math.pow(st, 3); - const st2 = Math.pow(st, 2); - - // calc cardinals - const c1 = 2 * st3 - 3 * st2 + 1; - const c2 = -2 * st3 + 3 * st2; - const c3 = st3 - 2 * st2 + st; - const c4 = st3 - st2; - - // calc x and y cords with common control vectors - const x = c1 * _pts[i][0] + c2 * _pts[i + 1][0] + c3 * t1x + c4 * t2x; - const y = c1 * _pts[i][1] + c2 * _pts[i + 1][1] + c3 * t1y + c4 * t2y; - - // store points in array - res.push([x, y]); - } - } - - return res; - } - __findMinIndex(array, mapper) { let idx = 0; let current = Number.MAX_VALUE; From 63c8041d8c93623267855f64cf855b231e7eef44 Mon Sep 17 00:00:00 2001 From: mistic100 Date: Mon, 14 Mar 2022 21:57:02 +0100 Subject: [PATCH 03/12] Close #649 video: automatic rotation --- docs/plugins/plugin-video.md | 13 ++ example/equirectangular-video.html | 15 +- rollup.config.js | 3 +- src/plugins/autorotate-keypoints/index.js | 38 +---- src/plugins/shared/autorotate-utils.js | 38 +++++ src/plugins/video/index.js | 186 +++++++++++++++++++++- types/plugins/video/index.d.ts | 54 ++++++- 7 files changed, 304 insertions(+), 43 deletions(-) create mode 100644 src/plugins/shared/autorotate-utils.js diff --git a/docs/plugins/plugin-video.md b/docs/plugins/plugin-video.md index ce3aa7032..7306df17d 100644 --- a/docs/plugins/plugin-video.md +++ b/docs/plugins/plugin-video.md @@ -74,6 +74,19 @@ const viewer = new PhotoSphereViewer.Viewer({ ## Configuration +#### `keypoints` +- type: `Array<{ position, time }>` + +Defines timed keypoints that will be used with by the autorotate button. + +```js +keypoints: [ + { time: 0, position: { longitude: 0, latitude: 0 } }, + { time: 5.5, position: { longitude: 0.25, latitude: 0 } }, + { time: 12.8, position: { longitude: 0.3, latitude: -12 } }, +] +``` + #### `progressbar` - type: `boolean` - default: `true` diff --git a/example/equirectangular-video.html b/example/equirectangular-video.html index 5325ca6cb..dd7ff1ae2 100644 --- a/example/equirectangular-video.html +++ b/example/equirectangular-video.html @@ -6,6 +6,7 @@ PhotoSphereViewer - equirectangular video demo + @@ -18,6 +19,7 @@ + @@ -37,7 +39,18 @@ [PhotoSphereViewer.VideoPlugin, { progressbar: true, bigbutton : true, + keypoints : [ + { time: 0, position: { longitude: 0, latitude: 0 } }, + { time: 5, position: { longitude: -Math.PI / 4, latitude: Math.PI / 8 } }, + { time: 10, position: { longitude: -Math.PI / 2, latitude: 0 } }, + { time: 15, position: { longitude: -3 * Math.PI / 4, latitude: -Math.PI / 8 } }, + { time: 20, position: { longitude: -Math.PI, latitude: 0 } }, + { time: 25, position: { longitude: -5 * Math.PI / 4, latitude: Math.PI / 8 } }, + { time: 30, position: { longitude: -3 * Math.PI / 2, latitude: 0 } }, + { time: 35, position: { longitude: -7 * Math.PI / 4, latitude: -Math.PI / 8 } }, + ] }], + PhotoSphereViewer.MarkersPlugin, PhotoSphereViewer.SettingsPlugin, [PhotoSphereViewer.ResolutionPlugin, { resolutions: [ @@ -65,7 +78,7 @@ }] ], loadingImg: 'assets/photosphere-logo.gif', - navbar : 'video zoom move caption settings fullscreen', + navbar : 'autorotate video zoom move caption settings fullscreen', }); diff --git a/rollup.config.js b/rollup.config.js index 22b36b196..49ca49604 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -11,7 +11,8 @@ import { string } from 'rollup-plugin-string'; import pkg from './package.json'; const plugins = fs.readdirSync(path.join(__dirname, 'src/plugins')) - .filter(p => fs.lstatSync(`src/plugins/${p}`).isDirectory()); + .filter(p => fs.lstatSync(`src/plugins/${p}`).isDirectory()) + .filter(p => p !== 'shared'); const adapters = fs.readdirSync(path.join(__dirname, 'src/adapters')) .filter(p => fs.lstatSync(`src/adapters/${p}`).isDirectory()) diff --git a/src/plugins/autorotate-keypoints/index.js b/src/plugins/autorotate-keypoints/index.js index d2942e62a..c2675ce4a 100644 --- a/src/plugins/autorotate-keypoints/index.js +++ b/src/plugins/autorotate-keypoints/index.js @@ -320,7 +320,7 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { .getPoints(NUM_STEPS * 3) .map(p => ([p.x, p.y])); - // __debugCurve(this.markers, curve); + // debugCurve(this.markers, curve, NUM_STEPS); // only keep the curve for the current movement this.state.curve = curve.slice(NUM_STEPS + 1, NUM_STEPS * 2 + 1); @@ -416,39 +416,3 @@ export class AutorotateKeypointsPlugin extends AbstractPlugin { } } - -let debugMarkers = []; - -function __debugCurve(markers, curve) { // eslint-disable-line no-unused-vars - debugMarkers.forEach((marker) => { - try { - markers.removeMarker(marker.id); - } - catch (e) { - // noop - } - }); - - debugMarkers = [ - markers.addMarker({ - id : 'autorotate-path', - polylineRad: curve, - svgStyle : { - stroke : 'white', - strokeWidth: '2px', - }, - }), - ]; - - curve.forEach((pos, i) => { - debugMarkers.push(markers.addMarker({ - id : 'autorotate-path-' + i, - circle : 5, - longitude: pos[0], - latitude : pos[1], - svgStyle : { - fill: 'black', - }, - })); - }); -} diff --git a/src/plugins/shared/autorotate-utils.js b/src/plugins/shared/autorotate-utils.js new file mode 100644 index 000000000..b288042d7 --- /dev/null +++ b/src/plugins/shared/autorotate-utils.js @@ -0,0 +1,38 @@ +let debugMarkers = []; + +/** + * @private + */ +export function debugCurve(markers, curve, stepSize) { + debugMarkers.forEach((marker) => { + try { + markers.removeMarker(marker.id); + } + catch (e) { + // noop + } + }); + + debugMarkers = [ + markers.addMarker({ + id : 'autorotate-path', + polylineRad: curve, + svgStyle : { + stroke : 'white', + strokeWidth: '2px', + }, + }), + ]; + + curve.forEach((pos, i) => { + debugMarkers.push(markers.addMarker({ + id : 'autorotate-path-' + i, + circle : 5, + longitude: pos[0], + latitude : pos[1], + svgStyle : { + fill: i % stepSize === 0 ? 'red' : 'black', + }, + })); + }); +} diff --git a/src/plugins/video/index.js b/src/plugins/video/index.js index 52e6b25ab..03d65a68e 100644 --- a/src/plugins/video/index.js +++ b/src/plugins/video/index.js @@ -1,4 +1,5 @@ -import { AbstractPlugin, CONSTANTS, DEFAULTS, PSVError, registerButton } from '../..'; +import * as THREE from 'three'; +import { AbstractPlugin, CONSTANTS, DEFAULTS, PSVError, registerButton, utils } from '../..'; import { EVENTS } from './constants'; import { PauseOverlay } from './PauseOverlay'; import { PlayPauseButton } from './PlayPauseButton'; @@ -8,10 +9,17 @@ import { VolumeButton } from './VolumeButton'; import './style.scss'; +/** + * @typedef {Object} PSV.plugins.VideoPlugin.Keypoint + * @property {PSV.ExtendedPosition} position + * @property {number} time + */ + /** * @typedef {Object} PSV.plugins.VideoPlugin.Options * @property {boolean} [progressbar=true] - displays a progressbar on top of the navbar * @property {boolean} [bigbutton=true] - displays a big "play" button in the center of the viewer + * @property {PSV.plugins.VideoPlugin.Keypoint[]} [keypoints] - defines autorotate timed keypoints */ @@ -49,9 +57,18 @@ export class VideoPlugin extends AbstractPlugin { /** * @member {Object} + * @property {THREE.SplineCurve} curve + * @property {PSV.plugins.VideoPlugin.Keypoint} start + * @property {PSV.plugins.VideoPlugin.Keypoint} end + * @property {PSV.plugins.VideoPlugin.Keypoint[]} keypoints * @private */ - this.prop = {}; + this.autorotate = { + curve : null, + start : null, + end : null, + keypoints: [], + }; /** * @member {PSV.plugins.VideoPlugin.Options} @@ -70,6 +87,12 @@ export class VideoPlugin extends AbstractPlugin { if (this.config.bigbutton) { this.overlay = new PauseOverlay(this); } + + /** + * @type {PSV.plugins.MarkersPlugin} + * @private + */ + this.markers = null; } /** @@ -78,6 +101,15 @@ export class VideoPlugin extends AbstractPlugin { init() { super.init(); + this.markers = this.psv.getPlugin('markers'); + + if (this.config.keypoints) { + this.setKeypoints(this.config.keypoints); + delete this.config.keypoints; + } + + this.psv.on(CONSTANTS.EVENTS.AUTOROTATE, this); + this.psv.on(CONSTANTS.EVENTS.BEFORE_RENDER, this); this.psv.on(CONSTANTS.EVENTS.PANORAMA_LOADED, this); this.psv.on(CONSTANTS.EVENTS.KEY_PRESS, this); } @@ -86,12 +118,15 @@ export class VideoPlugin extends AbstractPlugin { * @package */ destroy() { + this.psv.off(CONSTANTS.EVENTS.AUTOROTATE, this); + this.psv.off(CONSTANTS.EVENTS.BEFORE_RENDER, this); this.psv.off(CONSTANTS.EVENTS.PANORAMA_LOADED, this); this.psv.off(CONSTANTS.EVENTS.KEY_PRESS, this); this.progressbar?.destroy(); this.overlay?.destroy(); + delete this.autorotate; delete this.progressbar; delete this.overlay; @@ -105,6 +140,12 @@ export class VideoPlugin extends AbstractPlugin { /* eslint-disable */ // @formatter:off switch (e.type) { + case CONSTANTS.EVENTS.BEFORE_RENDER: + this.__autorotate(); + break; + case CONSTANTS.EVENTS.AUTOROTATE: + this.__configureAutorotate(); + break; case CONSTANTS.EVENTS.PANORAMA_LOADED: this.__bindVideo(e.args[0]); this.progressbar?.show(); @@ -287,4 +328,145 @@ export class VideoPlugin extends AbstractPlugin { } } + /** + * @summary Changes the keypoints + * @param {PSV.plugins.VideoPlugin.Keypoint[]} keypoints + */ + setKeypoints(keypoints) { + if (keypoints && keypoints.length < 2) { + throw new PSVError('At least two points are required'); + } + + this.autorotate.keypoints = utils.clone(keypoints); + + if (this.autorotate.keypoints) { + this.autorotate.keypoints.forEach((pt, i) => { + if (pt.position) { + const position = this.psv.dataHelper.cleanPosition(pt.position); + pt.position = [position.longitude, position.latitude]; + } + else { + throw new PSVError(`Keypoint #${i} is missing marker or position`); + } + + if (utils.isNil(pt.time)) { + throw new PSVError(`Keypoint #${i} is missing time`); + } + }); + + this.autorotate.keypoints.sort((a, b) => a.time - b.time); + } + + this.__configureAutorotate(); + } + + /** + * @private + */ + __configureAutorotate() { + delete this.autorotate.curve; + delete this.autorotate.start; + delete this.autorotate.end; + + if (this.psv.isAutorotateEnabled() && this.autorotate.keypoints) { + // cancel core rotation + this.psv.dynamics.position.stop(); + } + } + + /** + * @private + */ + __autorotate() { + if (!this.psv.isAutorotateEnabled()) { + return; + } + + const currentTime = this.getTime(); + const autorotate = this.autorotate; + + if (!autorotate.curve || currentTime < autorotate.start.time || currentTime >= autorotate.end.time) { + this.__autorotateNext(currentTime); + } + + if (autorotate.start === autorotate.end) { + this.psv.rotate({ + longitude: autorotate.start.position[0], + latitude : autorotate.start.position[1], + }); + } + else { + const progress = (currentTime - autorotate.start.time) / (autorotate.end.time - autorotate.start.time); + // only the middle segment contains the current section + const pt = autorotate.curve.getPoint(1 / 3 + progress / 3); + + this.psv.rotate({ + longitude: pt.x, + latitude : pt.y, + }); + } + } + + /** + * @private + */ + __autorotateNext(currentTime) { + let k1 = null; + let k2 = null; + + const keypoints = this.autorotate.keypoints; + const l = keypoints.length - 1; + + if (currentTime < keypoints[0].time) { + k1 = 0; + k2 = 0; + } + for (let i = 0; i < l; i++) { + if (currentTime >= keypoints[i].time && currentTime < keypoints[i + 1].time) { + k1 = i; + k2 = i + 1; + break; + } + } + if (currentTime >= keypoints[l].time) { + k1 = l; + k2 = l; + } + + // get the 4 points necessary to compute the current movement + // one point before and two points after the current + const workPoints = [ + keypoints[Math.max(0, k1 - 1)].position, + keypoints[k1].position, + keypoints[k2].position, + keypoints[Math.min(l, k2 + 1)].position, + ]; + + // apply offsets to avoid crossing the origin + const workVectors = [new THREE.Vector2(...workPoints[0])]; + + let k = 0; + for (let i = 1; i <= 3; i++) { + const d = workPoints[i - 1][0] - workPoints[i][0]; + if (d > Math.PI) { // crossed the origin left to right + k += 1; + } + else if (d < -Math.PI) { // crossed the origin right to left + k -= 1; + } + if (k !== 0 && i === 1) { + // do not modify first point, apply the reverse offset the the previous point instead + workVectors[0].x -= k * 2 * Math.PI; + k = 0; + } + workVectors.push(new THREE.Vector2(workPoints[i][0] + k * 2 * Math.PI, workPoints[i][1])); + } + + this.autorotate.curve = new THREE.SplineCurve(workVectors); + this.autorotate.start = keypoints[k1]; + this.autorotate.end = keypoints[k2]; + + // debugCurve(this.markers, this.autorotate.curve.getPoints(16 * 3).map(p => ([p.x, p.y])), 16); + } + } diff --git a/types/plugins/video/index.d.ts b/types/plugins/video/index.d.ts index 24c463110..66ff87030 100644 --- a/types/plugins/video/index.d.ts +++ b/types/plugins/video/index.d.ts @@ -1,5 +1,5 @@ -import { AbstractPlugin, Viewer } from '../..'; import { Event } from 'uevent'; +import { AbstractPlugin, ExtendedPosition, Viewer } from '../..'; export const EVENTS: { PLAY: 'play', @@ -9,9 +9,18 @@ export const EVENTS: { BUFFER: 'buffer', }; +/** + * @summary Definition of keypoints for automatic rotation, can be a position object, a marker id or an object with the following properties + */ +export type AutorotateKeypoint = { + position: ExtendedPosition; + time: number; +}; + export type VideoPluginOptions = { progressbar?: boolean; - progressbar?: boolean; + bigbutton?: boolean; + keypoints?: AutorotateKeypoint[]; }; /** @@ -21,28 +30,69 @@ export class VideoPlugin extends AbstractPlugin { constructor(psv: Viewer, options: VideoPluginOptions); + /** + * @summary Changes the keypoints + */ + setKeypoints(keypoints: AutorotateKeypoint[]); + + /** + * @summary Returns the durection of the video + */ getDuration(): number; + /** + * @summary Returns the current time of the video + */ getTime(): number; + /** + * @summary Returns the play progression of the video + */ getProgress(): number; + /** + * @summary Returns if the video is playing + */ isPlaying(): boolean; + /** + * @summary Returns the video volume + */ getVolume(): number; + /** + * @summary Starts or pause the video + */ playPause(): void; + /** + * @summary Starts the video if paused + */ play(): void; + /** + * @summary Pauses the cideo if playing + */ pause(): void; + /** + * @summary Sets the volume of the video + */ setVolume(volume: number): void; + /** + * @summary (Un)mutes the video + */ setMute(mute?: boolean): void; + /** + * @summary Changes the current time of the video + */ setTime(time: number): void; + /** + * @summary Changes the progression of the video + */ setProgress(progress: number): void; /** From 3bc0d66ef74fde44a7f60aab62b9ec0875289b43 Mon Sep 17 00:00:00 2001 From: mistic100 Date: Tue, 15 Mar 2022 20:56:16 +0100 Subject: [PATCH 04/12] doc: cleanup --- docs/guide/README.md | 8 +---- docs/guide/config.md | 10 +++--- docs/plugins/README.md | 2 ++ docs/plugins/plugin-autorotate-keypoints.md | 40 ++++++++++----------- docs/plugins/plugin-compass.md | 7 ++-- docs/plugins/plugin-resolution.md | 6 ++-- docs/plugins/plugin-settings.md | 8 +++-- docs/plugins/plugin-video.md | 4 ++- 8 files changed, 43 insertions(+), 42 deletions(-) diff --git a/docs/guide/README.md b/docs/guide/README.md index 4e0ea739e..f3d90722a 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -56,7 +56,7 @@ Include all JS & CSS files in your page manually or with your favorite bundler a
      + +