live-photo.js

  1. var enableInlineVideo = require('iphone-inline-video');
  2. var touchEnabled = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints);
  3. /**
  4. * Create a video element that that can be played inline
  5. *
  6. * @param {string} src - video URL
  7. * @param {string} [className] - CSS classname for the video element
  8. * @param {number} [time] - start time for the video
  9. * @param {boolean} [muted] - whether the video should start muted
  10. *
  11. * @return {DOMElement} the video element
  12. *
  13. * @private
  14. */
  15. function createVideoElement(src, className, time, muted) {
  16. var video = document.createElement('video');
  17. video.src = src + (time ? '#' + time : '');
  18. video.controls = false;
  19. video.muted = muted || false;
  20. video.preload = 'auto';
  21. if (className) {
  22. video.className = className;
  23. }
  24. enableInlineVideo(video, !muted);
  25. return video;
  26. }
  27. /**
  28. * Some mobile browsers restrictions prevent programmatic playback of HTML5 video that hasn't been
  29. * given permission via user interaction. This function is invoked on a user event to allow
  30. * programmatic playback later.
  31. *
  32. * @param {DOMElement} video - the video element
  33. * @param {Function} [cb] - invoked with the video
  34. *
  35. * @private
  36. */
  37. function warmVideoTubes(video, cb) {
  38. if (video.paused) {
  39. var videoMuted = video.muted;
  40. var videoTime = video.currentTime;
  41. // Because we can't set currentTime until the video metadata is loaded
  42. var onLoadededMetadata = function() {
  43. video.removeEventListener('loadedmetadata', onLoadededMetadata);
  44. video.currentTime = videoTime;
  45. if (cb) {
  46. cb(video);
  47. }
  48. };
  49. // Play then immediately pause
  50. video.muted = true;
  51. video.play();
  52. video.pause();
  53. video.muted = videoMuted;
  54. if (video.duration >= 0) {
  55. onLoadededMetadata();
  56. } else {
  57. video.addEventListener('loadedmetadata', onLoadededMetadata);
  58. }
  59. }
  60. }
  61. /**
  62. * Generate the markup for a Live Photo
  63. *
  64. * @param {string} keyframeUrl - URL of the keyframe image asset
  65. * @param {string} videoUrl - URL of the Live Photo video asset
  66. * @param {number} stillImageTime - time (in seconds) that corresponds to the position of the
  67. * keyframe in the video
  68. *
  69. * @return {Object} object containing all of DOM elements used for the Live Photo
  70. *
  71. * @private
  72. */
  73. function createLivePhotoElements(keyframeUrl, videoUrl, stillImageTime) {
  74. var container = document.createElement('div');
  75. container.className = 'live-photo';
  76. container.setAttribute('data-live-photo', '');
  77. var img = document.createElement('img');
  78. img.className = 'live-photo-keyframe';
  79. img.src = keyframeUrl;
  80. var postroll = createVideoElement(videoUrl, 'live-photo-postroll', stillImageTime, true);
  81. var video = createVideoElement(videoUrl, 'live-photo-video');
  82. var icon = document.createElement('i');
  83. icon.className = 'live-photo-icon';
  84. container.appendChild(img);
  85. container.appendChild(postroll);
  86. container.appendChild(video);
  87. container.appendChild(icon);
  88. return {
  89. container: container,
  90. img: img,
  91. postroll: postroll,
  92. video: video,
  93. icon: icon,
  94. };
  95. }
  96. /**
  97. * Determine which events should activate the Live Photo by default
  98. *
  99. * @return {Array} event names
  100. *
  101. * @private
  102. */
  103. function defaultPlayEvents() {
  104. var events = ['mousedown'];
  105. if (touchEnabled) {
  106. events.push('touchstart');
  107. }
  108. return events;
  109. }
  110. /**
  111. * Determine which events should deactivate the Live Photo by default
  112. *
  113. * @return {Array} event names
  114. *
  115. * @private
  116. */
  117. function defaultStopEvents() {
  118. var events = ['mouseup', 'mouseout'];
  119. if (touchEnabled) {
  120. events.push('touchend');
  121. }
  122. return events;
  123. }
  124. /**
  125. * @classdesc Creates the necessary markup and provides the interface to interact with a Live Photo
  126. *
  127. * @param {string} keyframeUrl - URL of the keyframe image asset
  128. * @param {string} videoUrl - URL of the Live Photo video asset
  129. * @param {number} stillImageTime - time (in seconds) that corresponds to the position of the
  130. * keyframe in the video
  131. * @param {Object} [options] - additional Live Photo options
  132. *
  133. * @constructor
  134. */
  135. function LivePhoto(keyframeUrl, videoUrl, stillImageTime, options) {
  136. // Silently correct when user forgets the `new` keyword
  137. if (!(this instanceof LivePhoto)) {
  138. return new LivePhoto(keyframeUrl, videoUrl, stillImageTime, options);
  139. }
  140. // Generate the elements from the options
  141. var elements, replaceEl;
  142. if (keyframeUrl instanceof HTMLElement) {
  143. options = videoUrl;
  144. replaceEl = keyframeUrl;
  145. videoUrl = replaceEl.getAttribute('data-live-photo');
  146. stillImageTime = parseFloat(replaceEl.getAttribute('data-live-photo-still-image-time'));
  147. keyframeUrl = replaceEl.src;
  148. }
  149. // Handle options
  150. options || (options = {});
  151. this.postrollMs = options.postrollMs || 375;
  152. this.deactivateMs = options.deactivateMs || 500;
  153. this.previewMs = options.previewMs || 500;
  154. // Video time that corresponds with the keyframe
  155. this.stillImageTime = stillImageTime || NaN;
  156. // Create necessary DOM
  157. if (!keyframeUrl && !options.noErrors) {
  158. throw new Error('LivePhoto Error: Missing keyframeUrl');
  159. }
  160. if (!videoUrl && !options.noErrors) {
  161. throw new Error('LivePhoto Error: Missing videoUrl');
  162. }
  163. elements = createLivePhotoElements(keyframeUrl, videoUrl, stillImageTime);
  164. // Replace any existing element
  165. if (replaceEl) {
  166. replaceEl.parentNode.insertBefore(elements.container, replaceEl);
  167. replaceEl.parentNode.removeChild(replaceEl);
  168. }
  169. // Save the elements
  170. this.container = elements.container;
  171. this.img = elements.img;
  172. this.video = elements.video;
  173. this.postroll = elements.postroll;
  174. this.icon = elements.icon;
  175. // Bind video event handlers
  176. this.__onVideoCanPlayThrough = this._onVideoCanPlayThrough.bind(this);
  177. this.__onVideoLoadededMetadata = this._onVideoLoadededMetadata.bind(this);
  178. this.__onPostrollTimeUpdate = this._onPostrollTimeUpdate.bind(this);
  179. this.__startVideoPlayback = this._startVideoPlayback.bind(this);
  180. this.__startPostrollPlayback = this._startPostrollPlayback.bind(this);
  181. this.__resetPlayback = this._resetPlayback.bind(this);
  182. this.__resetPreview = this._resetPreview.bind(this);
  183. // Get the duration from the video
  184. this.video.addEventListener('canplaythrough', this.__onVideoCanPlayThrough);
  185. if (this.video.duration) {
  186. this._setDuration(this.video.duration);
  187. this._resetPostroll();
  188. } else {
  189. this._setDuration(0);
  190. this.video.addEventListener('loadedmetadata', this.__onVideoLoadededMetadata);
  191. }
  192. // Set initial flags
  193. this._playing = false;
  194. this._canPlayThrough = false;
  195. this._playWhenReady = false;
  196. // Add event listeners
  197. if (options.useEventHandlers !== false) {
  198. this._addEventHandlers(options.playEvents, options.stopEvents);
  199. }
  200. }
  201. LivePhoto.prototype = {
  202. /**
  203. * Adds UI event listeners for Live Photo
  204. *
  205. * @param {Array|function|string} playEvents - events that should initialize Live Photo playback
  206. * @param {Array|function|string} stopEvents - events that should interrupt Live Photo playback
  207. *
  208. * @private
  209. */
  210. _addEventHandlers: function(playEvents, stopEvents) {
  211. var container = this.container;
  212. var bootstrapped = false;
  213. var playVideo = this.play.bind(this);
  214. var video = this.video;
  215. var postroll = this.postroll;
  216. var play = function(e) {
  217. e.preventDefault();
  218. if (bootstrapped) {
  219. playVideo();
  220. } else {
  221. var videoReady = false;
  222. var postrollReady = false;
  223. warmVideoTubes(video, function() {
  224. videoReady = true;
  225. if (postrollReady) {
  226. playVideo();
  227. }
  228. });
  229. warmVideoTubes(postroll, function() {
  230. postrollReady = true;
  231. if (videoReady) {
  232. playVideo();
  233. }
  234. });
  235. bootstrapped = true;
  236. }
  237. };
  238. (playEvents && playEvents !== false) || (playEvents = defaultPlayEvents);
  239. if (typeof playEvents === 'function') {
  240. playEvents = playEvents(this);
  241. }
  242. if (typeof playEvents === 'string') {
  243. playEvents = playEvents.split(/[,\s]+/);
  244. }
  245. playEvents.forEach(function(playEvent) {
  246. if (playEvent) {
  247. container.addEventListener(playEvent, play);
  248. }
  249. });
  250. var stopVideo = this.stop.bind(this);
  251. var stop = function(e) {
  252. e.preventDefault();
  253. stopVideo();
  254. };
  255. (stopEvents && stopEvents !== false) || (stopEvents = defaultStopEvents);
  256. if (typeof stopEvents === 'function') {
  257. stopEvents = stopEvents(this);
  258. }
  259. if (typeof stopEvents === 'string') {
  260. stopEvents = stopEvents.split(/[,\s]+/);
  261. }
  262. stopEvents.forEach(function(stopEvent) {
  263. if (stopEvent) {
  264. container.addEventListener(stopEvent, stop);
  265. }
  266. });
  267. },
  268. /**
  269. * Handles first `canplaythrough` event, indicating that the Live Photo is ready for interactive playback
  270. *
  271. * @private
  272. */
  273. _onVideoCanPlayThrough: function() {
  274. this._canPlayThrough = true;
  275. if (this._playWhenReady) {
  276. this._playWhenReady = false;
  277. this.play();
  278. }
  279. this.container.classList.remove('loading');
  280. this.video.removeEventListener('canplaythrough', this.__onVideoCanPlayThrough);
  281. },
  282. /**
  283. * Handles the first `loadedmetadata` event, indicating that the video duration is available
  284. * and that seeking is available
  285. *
  286. * @private
  287. */
  288. _onVideoLoadededMetadata: function() {
  289. this._setDuration(this.video.duration || 0);
  290. this._resetPostroll();
  291. this.video.removeEventListener('loadedmetadata', this.__onVideoLoadededMetadata);
  292. },
  293. /**
  294. * Used to determine when the Live Photo "preview" should stop
  295. *
  296. * @private
  297. */
  298. _onPostrollTimeUpdate: function(e) {
  299. if (this.postroll.currentTime >= this.stillImageTime - 0.1) {
  300. this._resetPreview();
  301. }
  302. },
  303. /**
  304. * Cleans up any timeouts and event listeners used during Live Photo playback
  305. *
  306. * @private
  307. */
  308. _clearTimeouts: function() {
  309. clearTimeout(this._resetVideoTimeout);
  310. clearTimeout(this._resetPostrollTimeout);
  311. clearTimeout(this._resetPreviewTimeout);
  312. this.postroll.removeEventListener('timeupdate', this.__onPostrollTimeUpdate);
  313. },
  314. /**
  315. * Sets the duration of the video, which may be used to estimate the still image and preview
  316. * playback start times
  317. *
  318. * @param {number} duration - video duration in seconds
  319. *
  320. * @private
  321. */
  322. _setDuration: function(duration) {
  323. this.duration = duration;
  324. if (typeof this.stillImageTime !== 'number' || isNaN(this.stillImageTime)) {
  325. // com.apple.quicktime.still-image-time
  326. // The real keyframe time is stored in the metadata, but if we don't have it, assume
  327. // that we should start in the middle instead
  328. this.stillImageTime = duration * 0.5;
  329. }
  330. this.previewStart = Math.max(0, this.stillImageTime - this.previewMs / 1000);
  331. },
  332. /**
  333. * Pauses the postroll and reset to the still image time
  334. *
  335. * @private
  336. */
  337. _resetPostroll: function() {
  338. this.postroll.pause();
  339. this.postroll.currentTime = this.stillImageTime;
  340. },
  341. /**
  342. * Pauses the video and reset to the beginning
  343. *
  344. * @private
  345. */
  346. _resetVideo: function() {
  347. this.video.pause();
  348. this.video.currentTime = 0;
  349. this.video.muted = false;
  350. },
  351. /**
  352. * Starts video playback, interrupting any postroll playback
  353. *
  354. * @private
  355. */
  356. _startVideoPlayback: function() {
  357. this.video.play();
  358. this._resetPostroll();
  359. },
  360. /**
  361. * Restarts postroll playback
  362. *
  363. * @private
  364. */
  365. _startPostrollPlayback: function() {
  366. this._resetPostroll();
  367. this.postroll.play();
  368. },
  369. /**
  370. * Resets Live Photo playback, including both the video and the postroll
  371. *
  372. * @private
  373. */
  374. _resetPlayback: function() {
  375. this._playing = false;
  376. this._resetVideo();
  377. this._resetPostroll();
  378. },
  379. /**
  380. * Stops Live Photo preview playback, resetting the postroll
  381. *
  382. * @private
  383. */
  384. _resetPreview: function() {
  385. this.postroll.removeEventListener('timeupdate', this.__onPostrollTimeUpdate);
  386. this._resetPostroll();
  387. this.container.classList.remove('preview');
  388. },
  389. /**
  390. * Loads the media for the video and preroll
  391. *
  392. * @method
  393. */
  394. load: function() {
  395. this.video.load();
  396. this.postroll.load();
  397. },
  398. /**
  399. * Starts Live Photo playback from the beginning, if it is not already playing
  400. *
  401. * @method
  402. */
  403. play: function() {
  404. if (this._playing) {
  405. return;
  406. }
  407. if (!this._canPlayThrough) {
  408. this._playWhenReady = true;
  409. this._clearTimeouts();
  410. this.load();
  411. this.container.classList.add('loading');
  412. return;
  413. }
  414. this._playing = true;
  415. this._clearTimeouts();
  416. this._startPostrollPlayback();
  417. this.video.currentTime = 0;
  418. this._resetPostrollTimeout = setTimeout(this.__startVideoPlayback, this.postrollMs);
  419. this.container.classList.add('active');
  420. },
  421. /**
  422. * Stops Live Photo playback and returns to the inactive state
  423. *
  424. * @method
  425. */
  426. stop: function() {
  427. if (!this._playing) {
  428. return;
  429. }
  430. this.video.muted = true;
  431. this._clearTimeouts();
  432. this.container.classList.remove('active');
  433. this._resetVideoTimeout = setTimeout(this.__resetPlayback, this.deactivateMs);
  434. },
  435. /**
  436. * Plays the Live Photo preview, which is about one second starting from the still image time
  437. *
  438. * @method
  439. */
  440. preview: function() {
  441. if (this._playing) {
  442. return;
  443. }
  444. this._clearTimeouts();
  445. this.postroll.currentTime = this.previewStart;
  446. this.postroll.play();
  447. this.container.classList.add('preview');
  448. this._previewCompleteTimeout = setTimeout(this.__resetPreview, 1000 * (this.stillImageTime - this.previewStart));
  449. // The preview is over once we hit the correct time
  450. this.postroll.addEventListener('timeupdate', this.__onPostrollTimeUpdate);
  451. },
  452. };
  453. module.exports = LivePhoto;