diff --git a/package.json b/package.json index bac0d7e251..b112d0be60 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ }, "dependencies": { "alameda": "^1.4.0", + "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "core-js": "^3.6.4", "date-fns": "^2.11.1", "document-register-element": "^1.14.3", diff --git a/src/bundle.js b/src/bundle.js index 316f42c94a..e6807c5409 100644 --- a/src/bundle.js +++ b/src/bundle.js @@ -113,6 +113,12 @@ _define("polyfill", function () { return polyfill; }); +// domtokenlist-shim +var classlist = require("classlist.js"); +_define("classlist-polyfill", function () { + return classlist; +}); + // Date-FNS var date_fns = require("date-fns"); _define("date-fns", function () { diff --git a/src/components/slideshow/slideshow.js b/src/components/slideshow/slideshow.js index 4d426f2484..26dc303def 100644 --- a/src/components/slideshow/slideshow.js +++ b/src/components/slideshow/slideshow.js @@ -1,8 +1,18 @@ -define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'focusManager', 'browser', 'apphost', 'loading', 'css!./style', 'material-icons', 'paper-icon-button-light'], function (dialogHelper, inputManager, connectionManager, layoutManager, focusManager, browser, appHost, loading) { +/** + * Image viewer component + * @module components/slideshow/slideshow + */ +define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'focusManager', 'browser', 'apphost', 'css!./style', 'material-icons', 'paper-icon-button-light'], function (dialogHelper, inputManager, connectionManager, layoutManager, focusManager, browser, appHost) { 'use strict'; + /** + * Retrieves an item's image URL from the API. + * @param {object|string} item - Item used to generate the image URL. + * @param {object} options - Options of the image. + * @param {object} apiClient - API client instance used to retrieve the image. + * @returns {null|string} URL of the item's image. + */ function getImageUrl(item, options, apiClient) { - options = options || {}; options.type = options.type || "Primary"; @@ -11,7 +21,6 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f } if (item.ImageTags && item.ImageTags[options.type]) { - options.tag = item.ImageTags[options.type]; return apiClient.getScaledImageUrl(item.Id, options); } @@ -27,8 +36,14 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f return null; } + /** + * Retrieves a backdrop's image URL from the API. + * @param {object} item - Item used to generate the image URL. + * @param {object} options - Options of the image. + * @param {object} apiClient - API client instance used to retrieve the image. + * @returns {null|string} URL of the item's backdrop. + */ function getBackdropImageUrl(item, options, apiClient) { - options = options || {}; options.type = options.type || "Backdrop"; @@ -46,19 +61,19 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f return null; } - function getImgUrl(item, original) { - + /** + * Dispatches a request for an item's image to its respective handler. + * @param {object} item - Item used to generate the image URL. + * @returns {string} URL of the item's image. + */ + function getImgUrl(item) { var apiClient = connectionManager.getApiClient(item.ServerId); var imageOptions = {}; - if (!original) { - imageOptions.maxWidth = screen.availWidth; - } if (item.BackdropImageTags && item.BackdropImageTags.length) { return getBackdropImageUrl(item, imageOptions, apiClient); } else { - - if (item.MediaType === 'Photo' && original) { + if (item.MediaType === 'Photo') { return apiClient.getItemDownloadUrl(item.Id); } imageOptions.type = "Primary"; @@ -66,15 +81,25 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f } } + /** + * Generates a button using the specified icon, classes and properties. + * @param {string} icon - Name of the material icon on the button + * @param {string} cssClass - CSS classes to assign to the button + * @param {boolean} canFocus - Flag to set the tabindex attribute on the button to -1. + * @param {boolean} autoFocus - Flag to set the autofocus attribute on the button. + * @returns {string} The HTML markup of the button. + */ function getIcon(icon, cssClass, canFocus, autoFocus) { - var tabIndex = canFocus ? '' : ' tabindex="-1"'; autoFocus = autoFocus ? ' autofocus' : ''; return ''; } + /** + * Sets the viewport meta tag to enable or disable scaling by the user. + * @param {boolean} scalable - Flag to set the scalability of the viewport. + */ function setUserScalable(scalable) { - try { appHost.setUserScalable(scalable); } catch (err) { @@ -83,23 +108,31 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f } return function (options) { - var self = this; + /** Initialized instance of Swiper. */ var swiperInstance; - var dlg; - var currentTimeout; - var currentIntervalMs; + /** Initialized instance of the dialog containing the Swiper instance. */ + var dialog; + /** Options of the slideshow components */ var currentOptions; - var currentIndex; + /** ID of the timeout used to hide the OSD. */ + var hideTimeout; + /** Last coordinates of the mouse pointer. */ + var lastMouseMoveData; + /** Visibility status of the OSD. */ + var _osdOpen = false; - // small hack since this is not possible anyway - if (browser.chromecast) { - options.interactive = false; - } + // Use autoplay on Chromecast since it is non-interactive. + options.interactive = !browser.chromecast; + /** + * Creates the HTML markup for the dialog and the OSD. + * @param {Object} options - Options used to create the dialog and slideshow. + */ function createElements(options) { + currentOptions = options; - dlg = dialogHelper.createDialog({ + dialog = dialogHelper.createDialog({ exitAnimationDuration: options.interactive ? 400 : 800, size: 'fullscreen', autoFocus: false, @@ -108,17 +141,15 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f removeOnClose: true }); - dlg.classList.add('slideshowDialog'); + dialog.classList.add('slideshowDialog'); var html = ''; - if (options.interactive) { + html += '
'; + if (options.interactive && !layoutManager.tv) { var actionButtonsOnTop = layoutManager.mobile; - html += '
'; - html += '
'; - html += getIcon('keyboard_arrow_left', 'btnSlideshowPrevious slideshowButton hide-mouse-idle-tv', false); html += getIcon('keyboard_arrow_right', 'btnSlideshowNext slideshowButton hide-mouse-idle-tv', false); @@ -137,7 +168,7 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f if (!actionButtonsOnTop) { html += '
'; - html += getIcon('pause', 'btnSlideshowPause slideshowButton', true, true); + html += getIcon('play_arrow', 'btnSlideshowPause slideshowButton', true, true); if (appHost.supports('filedownload')) { html += getIcon('file_download', 'btnDownload slideshowButton', true); } @@ -148,33 +179,28 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f html += '
'; } - html += '
'; - } else { html += '

'; } - dlg.innerHTML = html; + dialog.innerHTML = html; - if (options.interactive) { - dlg.querySelector('.btnSlideshowExit').addEventListener('click', function (e) { - - dialogHelper.close(dlg); + if (options.interactive && !layoutManager.tv) { + dialog.querySelector('.btnSlideshowExit').addEventListener('click', function (e) { + dialogHelper.close(dialog); }); - dlg.querySelector('.btnSlideshowNext').addEventListener('click', nextImage); - dlg.querySelector('.btnSlideshowPrevious').addEventListener('click', previousImage); - var btnPause = dlg.querySelector('.btnSlideshowPause'); + var btnPause = dialog.querySelector('.btnSlideshowPause'); if (btnPause) { btnPause.addEventListener('click', playPause); } - var btnDownload = dlg.querySelector('.btnDownload'); + var btnDownload = dialog.querySelector('.btnDownload'); if (btnDownload) { btnDownload.addEventListener('click', download); } - var btnShare = dlg.querySelector('.btnShare'); + var btnShare = dialog.querySelector('.btnShare'); if (btnShare) { btnShare.addEventListener('click', share); } @@ -182,78 +208,104 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f setUserScalable(true); - dialogHelper.open(dlg).then(function () { - + dialogHelper.open(dialog).then(function () { setUserScalable(false); - stopInterval(); }); inputManager.on(window, onInputCommand); document.addEventListener((window.PointerEvent ? 'pointermove' : 'mousemove'), onPointerMove); - dlg.addEventListener('close', onDialogClosed); + dialog.addEventListener('close', onDialogClosed); - if (options.interactive) { - loadSwiper(dlg); - } + loadSwiper(dialog, options); } + /** + * Handles OSD changes when the autoplay is started. + */ function onAutoplayStart() { - var btnSlideshowPause = dlg.querySelector('.btnSlideshowPause i'); + var btnSlideshowPause = dialog.querySelector('.btnSlideshowPause i'); if (btnSlideshowPause) { - btnSlideshowPause.classList.remove("play_arrow"); - btnSlideshowPause.classList.add("pause"); + btnSlideshowPause.classList.replace("play_arrow", "pause"); } } + /** + * Handles OSD changes when the autoplay is stopped. + */ function onAutoplayStop() { - var btnSlideshowPause = dlg.querySelector('.btnSlideshowPause i'); + var btnSlideshowPause = dialog.querySelector('.btnSlideshowPause i'); if (btnSlideshowPause) { - btnSlideshowPause.classList.remove("pause"); - btnSlideshowPause.classList.add("play_arrow"); + btnSlideshowPause.classList.replace("pause", "play_arrow"); } } - function loadSwiper(dlg) { - + /** + * Initializes the Swiper instance and binds the relevant events. + * @param {HTMLElement} dialog - Element containing the dialog. + * @param {Object} options - Options used to initialize the Swiper instance. + */ + function loadSwiper(dialog, options) { + var slides; if (currentOptions.slides) { - dlg.querySelector('.swiper-wrapper').innerHTML = currentOptions.slides.map(getSwiperSlideHtmlFromSlide).join(''); + slides = currentOptions.slides; } else { - dlg.querySelector('.swiper-wrapper').innerHTML = currentOptions.items.map(getSwiperSlideHtmlFromItem).join(''); + slides = currentOptions.items; } require(['swiper'], function (Swiper) { - - swiperInstance = new Swiper(dlg.querySelector('.slideshowSwiperContainer'), { - // Optional parameters + swiperInstance = new Swiper(dialog.querySelector('.slideshowSwiperContainer'), { direction: 'horizontal', - loop: options.loop !== false, - autoplay: { - delay: options.interval || 8000 + // Loop is disabled due to the virtual slides option not supporting it. + loop: false, + autoplay: !options.interactive, + keyboard: { + enabled: true }, - // Disable preloading of all images - preloadImages: false, - // Enable lazy loading - lazy: true, - loadPrevNext: true, - disableOnInteraction: false, + preloadImages: true, + slidesPerView: 1, + slidesPerColumn: 1, initialSlide: options.startIndex || 0, - speed: 240 + speed: 240, + navigation: { + nextEl: '.btnSlideshowNext', + prevEl: '.btnSlideshowPrevious' + }, + // Virtual slides reduce memory consumption for large libraries while allowing preloading of images; + virtual: { + slides: slides, + cache: true, + renderSlide: getSwiperSlideHtml, + addSlidesBefore: 1, + addSlidesAfter: 1 + } }); swiperInstance.on('autoplayStart', onAutoplayStart); swiperInstance.on('autoplayStop', onAutoplayStop); - - if (layoutManager.mobile) { - pause(); - } else { - play(); - } }); } - function getSwiperSlideHtmlFromItem(item) { + /** + * Renders the HTML markup of a slide for an item or a slide. + * @param {Object} item - The item used to render the slide. + * @param {number} index - The index of the item in the Swiper instance. + * @returns {string} The HTML markup of the slide. + */ + function getSwiperSlideHtml(item, index) { + if (currentOptions.slides) { + return getSwiperSlideHtmlFromSlide(item); + } else { + return getSwiperSlideHtmlFromItem(item); + } + } + /** + * Renders the HTML markup of a slide for an item. + * @param {Object} item - Item used to generate the slide. + * @returns {string} The HTML markup of the slide. + */ + function getSwiperSlideHtmlFromItem(item) { return getSwiperSlideHtmlFromSlide({ imageUrl: getImgUrl(item), originalImage: getImgUrl(item, true), @@ -264,11 +316,17 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f }); } + /** + * Renders the HTML markup of a slide for a slide object. + * @param {Object} item - Slide object used to generate the slide. + * @returns {string} The HTML markup of the slide. + */ function getSwiperSlideHtmlFromSlide(item) { - var html = ''; - html += '
'; - html += ''; + html += '
'; + html += '
'; + html += ''; + html += '
'; if (item.title || item.subtitle) { html += '
'; html += '
'; @@ -290,42 +348,18 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f return html; } - function previousImage() { - if (swiperInstance) { - swiperInstance.slidePrev(); - } else { - stopInterval(); - showNextImage(currentIndex - 1); - } - } - - function nextImage() { - if (swiperInstance) { - - if (options.loop === false) { - - if (swiperInstance.activeIndex >= swiperInstance.slides.length - 1) { - dialogHelper.close(dlg); - return; - } - } - - swiperInstance.slideNext(); - } else { - stopInterval(); - showNextImage(currentIndex + 1); - } - } - + /** + * Fetches the information of the currently displayed slide. + * @returns {null|{itemId: string, shareUrl: string, serverId: string, url: string}} Object containing the information of the currently displayed slide. + */ function getCurrentImageInfo() { - if (swiperInstance) { var slide = document.querySelector('.swiper-slide-active'); if (slide) { return { url: slide.getAttribute('data-original'), - shareUrl: slide.getAttribute('data-imageurl'), + shareUrl: slide.getAttribute('data-original'), itemId: slide.getAttribute('data-itemid'), serverId: slide.getAttribute('data-serverid') }; @@ -336,8 +370,10 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f } } + /** + * Starts a download for the currently displayed slide. + */ function download() { - var imageInfo = getCurrentImageInfo(); require(['fileDownloader'], function (fileDownloader) { @@ -345,8 +381,10 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f }); } + /** + * Shares the currently displayed slide using the browser's built-in sharing feature. + */ function share() { - var imageInfo = getCurrentImageInfo(); navigator.share({ @@ -354,20 +392,29 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f }); } + /** + * Starts the autoplay feature of the Swiper instance. + */ function play() { if (swiperInstance.autoplay) { swiperInstance.autoplay.start(); } } + /** + * Pauses the autoplay feature of the Swiper instance; + */ function pause() { if (swiperInstance.autoplay) { swiperInstance.autoplay.stop(); } } + /** + * Toggles the autoplay feature of the Swiper instance. + */ function playPause() { - var paused = !dlg.querySelector('.btnSlideshowPause i').classList.contains("pause"); + var paused = !dialog.querySelector('.btnSlideshowPause i').classList.contains("pause"); if (paused) { play(); } else { @@ -375,8 +422,10 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f } } + /** + * Closes the dialog and destroys the Swiper instance. + */ function onDialogClosed() { - var swiper = swiperInstance; if (swiper) { swiper.destroy(true, true); @@ -387,53 +436,38 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f document.removeEventListener((window.PointerEvent ? 'pointermove' : 'mousemove'), onPointerMove); } - function startInterval(options) { - - currentOptions = options; - - stopInterval(); - createElements(options); - - if (!options.interactive) { - currentIntervalMs = options.interval || 11000; - showNextImage(options.startIndex || 0, true); - } - } - - var _osdOpen = false; - - function isOsdOpen() { - return _osdOpen; - } - - function getOsdBottom() { - return dlg.querySelector('.slideshowBottomBar'); - } - + /** + * Shows the OSD. + */ function showOsd() { - - var bottom = getOsdBottom(); + var bottom = dialog.querySelector('.slideshowBottomBar'); if (bottom) { slideUpToShow(bottom); startHideTimer(); } } + /** + * Hides the OSD. + */ function hideOsd() { - - var bottom = getOsdBottom(); + var bottom = dialog.querySelector('.slideshowBottomBar'); if (bottom) { slideDownToHide(bottom); } } - var hideTimeout; - + /** + * Starts the timer used to automatically hide the OSD. + */ function startHideTimer() { stopHideTimer(); - hideTimeout = setTimeout(hideOsd, 4000); + hideTimeout = setTimeout(hideOsd, 3000); } + /** + * Stops the timer used to automatically hide the OSD. + */ function stopHideTimer() { if (hideTimeout) { clearTimeout(hideTimeout); @@ -441,71 +475,76 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f } } - function slideUpToShow(elem) { - - if (!elem.classList.contains('hide')) { + /** + * Shows the OSD by sliding it into view. + * @param {HTMLElement} element - Element containing the OSD. + */ + function slideUpToShow(element) { + if (!element.classList.contains('hide')) { return; } _osdOpen = true; - elem.classList.remove('hide'); + element.classList.remove('hide'); var onFinish = function () { - focusManager.focus(elem.querySelector('.btnSlideshowPause')); + focusManager.focus(element.querySelector('.btnSlideshowPause')); }; - if (!elem.animate) { + if (!element.animate) { onFinish(); return; } requestAnimationFrame(function () { - var keyframes = [ - { transform: 'translate3d(0,' + elem.offsetHeight + 'px,0)', opacity: '.3', offset: 0 }, + { transform: 'translate3d(0,' + element.offsetHeight + 'px,0)', opacity: '.3', offset: 0 }, { transform: 'translate3d(0,0,0)', opacity: '1', offset: 1 } ]; var timing = { duration: 300, iterations: 1, easing: 'ease-out' }; - elem.animate(keyframes, timing).onfinish = onFinish; + element.animate(keyframes, timing).onfinish = onFinish; }); } - function slideDownToHide(elem) { - - if (elem.classList.contains('hide')) { + /** + * Hides the OSD by sliding it out of view. + * @param {HTMLElement} element - Element containing the OSD. + */ + function slideDownToHide(element) { + if (element.classList.contains('hide')) { return; } var onFinish = function () { - elem.classList.add('hide'); + element.classList.add('hide'); _osdOpen = false; }; - if (!elem.animate) { + if (!element.animate) { onFinish(); return; } requestAnimationFrame(function () { - var keyframes = [ { transform: 'translate3d(0,0,0)', opacity: '1', offset: 0 }, - { transform: 'translate3d(0,' + elem.offsetHeight + 'px,0)', opacity: '.3', offset: 1 } + { transform: 'translate3d(0,' + element.offsetHeight + 'px,0)', opacity: '.3', offset: 1 } ]; var timing = { duration: 300, iterations: 1, easing: 'ease-out' }; - elem.animate(keyframes, timing).onfinish = onFinish; + element.animate(keyframes, timing).onfinish = onFinish; }); } - var lastMouseMoveData; - - function onPointerMove(e) { - - var pointerType = e.pointerType || (layoutManager.mobile ? 'touch' : 'mouse'); + /** + * Shows the OSD when moving the mouse pointer or touching the screen. + * @param {Event} event - Pointer movement event. + */ + function onPointerMove(event) { + var pointerType = event.pointerType || (layoutManager.mobile ? 'touch' : 'mouse'); if (pointerType === 'mouse') { - var eventX = e.screenX || 0; - var eventY = e.screenY || 0; + var eventX = event.screenX || 0; + var eventY = event.screenY || 0; var obj = lastMouseMoveData; if (!obj) { @@ -528,125 +567,46 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f } } - function onInputCommand(e) { - - switch (e.detail.command) { - - case 'left': - if (!isOsdOpen()) { - e.preventDefault(); - e.stopPropagation(); - previousImage(); - } - break; - case 'right': - if (!isOsdOpen()) { - e.preventDefault(); - e.stopPropagation(); - nextImage(); - } - break; + /** + * Dispatches keyboard inputs to their proper handlers. + * @param {Event} event - Keyboard input event. + */ + function onInputCommand(event) { + switch (event.detail.command) { case 'up': case 'down': case 'select': case 'menu': case 'info': - case 'play': - case 'playpause': - case 'pause': showOsd(); break; + case 'play': + play(); + break; + case 'pause': + pause(); + break; + case 'playpause': + playPause(); + break; default: break; } } - function showNextImage(index, skipPreload) { - - index = Math.max(0, index); - if (index >= currentOptions.items.length) { - index = 0; - } - currentIndex = index; - - var options = currentOptions; - var items = options.items; - var item = items[index]; - var imgUrl = getImgUrl(item); - - var onSrcLoaded = function () { - var cardImageContainer = dlg.querySelector('.slideshowImage'); - - var newCardImageContainer = document.createElement('div'); - newCardImageContainer.className = cardImageContainer.className; - - if (options.cover) { - newCardImageContainer.classList.add('slideshowImage-cover'); - } - - newCardImageContainer.style.backgroundImage = "url('" + imgUrl + "')"; - newCardImageContainer.classList.add('hide'); - cardImageContainer.parentNode.appendChild(newCardImageContainer); - - if (options.showTitle) { - dlg.querySelector('.slideshowImageText').innerHTML = item.Name; - } else { - dlg.querySelector('.slideshowImageText').innerHTML = ''; - } - - newCardImageContainer.classList.remove('hide'); - var onAnimationFinished = function () { - - var parentNode = cardImageContainer.parentNode; - if (parentNode) { - parentNode.removeChild(cardImageContainer); - } - }; - - if (newCardImageContainer.animate) { - - var keyframes = [ - { opacity: '0', offset: 0 }, - { opacity: '1', offset: 1 } - ]; - var timing = { duration: 1200, iterations: 1 }; - newCardImageContainer.animate(keyframes, timing).onfinish = onAnimationFinished; - } else { - onAnimationFinished(); - } - - stopInterval(); - currentTimeout = setTimeout(function () { - showNextImage(index + 1, true); - - }, currentIntervalMs); - }; - - if (!skipPreload) { - var img = new Image(); - img.onload = onSrcLoaded; - img.src = imgUrl; - } else { - onSrcLoaded(); - } - } - - function stopInterval() { - if (currentTimeout) { - clearTimeout(currentTimeout); - currentTimeout = null; - } - } - + /** + * Shows the slideshow component. + */ self.show = function () { - startInterval(options); + createElements(options); }; + /** + * Hides the slideshow element. + */ self.hide = function () { - - var dialog = dlg; + var dialog = dialog; if (dialog) { - dialogHelper.close(dialog); } }; diff --git a/src/scripts/site.js b/src/scripts/site.js index 5eb3c2a346..6ffe033fa8 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -696,7 +696,8 @@ var AppInfo = {}; "jellyfin-noto", "date-fns", "page", - "polyfill" + "polyfill", + "classlist-polyfill" ] }, urlArgs: urlArgs, @@ -705,6 +706,7 @@ var AppInfo = {}; }); require(["polyfill"]); + require(["classlist-polyfill"]); // Expose jQuery globally require(["jQuery"], function(jQuery) { diff --git a/yarn.lock b/yarn.lock index ac4a39b6ec..3c286a7d7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2337,6 +2337,10 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +"classlist.js@https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz": + version "1.2.20180112" + resolved "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz#9c7ab3ccdbde5c940f6f26f8fc59bfb6bc813bc4" + clean-css@4.2.x, clean-css@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"