MediaWiki:Gadget-FreezeGifs.js

From SourceWorlds Wiki
Revision as of 23:46, 19 October 2026 by Vizzys (talk | contribs) (Protected "MediaWiki:Gadget-FreezeGifs.js" ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite)))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * Gadget-FreezeGifs.js
 *
 * This gadget finds animated GIFs and autoplaying videos on a page,
 * freezes them, and overlays a play icon. The animation or video plays
 * only when the user hovers their mouse over the element.
 *
 * - Freezes GIFs by drawing their first frame to a <canvas> element.
 * - Correctly handles lazy-loaded images and gallery thumbnails.
 * - Excludes specific areas (like .charbox) from being processed.
 */
(function (mw, $) {

	// A flag to ensure the script doesn't run multiple times on the same page load.
	if (window.PAUSE_ANIMATIONS_GADGET_LOADED) {
		return;
	}
	window.PAUSE_ANIMATIONS_GADGET_LOADED = true;

	var config = {
		containerClass: 'pa-container',
		overlayClass: 'pa-overlay',
		playIconClass: 'pa-play-icon',
		processedClass: 'pa-processed',
		pendingClass: 'pa-pending', // Class to prevent race conditions on lazy-loaded images
		staticCanvasClass: 'pa-static-canvas',
		animatedGifClass: 'pa-animated-gif',
		// CSS styles are injected dynamically.
		css:
			// 1. The container class is added to the IMG's existing parent, setting up the positioning context.
			'.pa-container { position: relative !important; }' +
			// 2. The original animated GIF remains in the normal layout flow but is made invisible.
			'.pa-container > img.' + 'pa-animated-gif' + ' { visibility: hidden !important; }' +
			// 3. The canvas and overlay are layered on top using absolute positioning. They will fill the container.
			'.pa-container > .pa-static-canvas, .pa-container > .pa-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }' +
			// 4. The canvas is visible by default, covering the space of the hidden GIF.
			'.pa-container > .pa-static-canvas { visibility: visible; z-index: 1; }' +
			// 5. The overlay sits on the highest layer.
			'.pa-overlay { z-index: 2; background-color: rgba(0, 0, 0, 0.3); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: opacity 0.2s ease-in-out; opacity: 1; pointer-events: none; }' +
			'.pa-play-icon { box-sizing: border-box; width: 0; height: 30px; border-color: transparent transparent transparent white; border-style: solid; border-width: 15px 0 15px 25px; }' +
			// 6. On hover, fade out the overlay.
			'.pa-container:hover .pa-overlay { opacity: 0; }' +
			// 7. On hover, make the original animated GIF visible again...
			'.pa-container:hover > img.' + 'pa-animated-gif' + ' { visibility: visible !important; }' +
			// 8. ...and hide the static canvas.
			'.pa-container:hover > .' + 'pa-static-canvas' + ' { visibility: hidden !important; }'
	};

	/**
	 * Finds and freezes all autoplaying videos.
	 */
	function handleVideos() {
		$('video[autoplay]').not('.' + config.processedClass).each(function () {
			var $video = $(this);
			this.pause();
			this.currentTime = 0;
			$video.addClass(config.processedClass).removeAttr('autoplay').attr('preload', 'metadata').prop('loop', true);
			$video.wrap($('<div>').addClass(config.containerClass));
			var $container = $video.parent();
			$container.append($('<div>').addClass(config.overlayClass).append($('<div>').addClass(config.playIconClass)));
			if ($video.css('float') !== 'none') {
				$container.css('float', $video.css('float'));
				$video.css('float', 'none');
			}
			$container.on('mouseenter', function () {
				this.querySelector('video').play().catch(function() {
					this.querySelector('video').muted = true;
					this.querySelector('video').play();
				});
			}).on('mouseleave', function () {
				var videoEl = this.querySelector('video');
				videoEl.pause();
				videoEl.currentTime = 0;
			});
		});
	}
	
	/**
	 * Creates a static canvas and layers it correctly without breaking layouts.
	 */
	function createStaticCanvas(imgElement) {
		var $img = $(imgElement);
		var $parent = $img.parent();
	
		// For galleries, the image may be wrapped in an extra span or anchor
		if ($parent.hasClass('galleryfilename') || $parent.hasClass('thumb') || $parent.hasClass('gallerybox')) {
			$parent = $img.closest('.thumb, .gallerybox, .galleryfilename');
		}
	
		$img.removeClass(config.pendingClass).addClass(config.processedClass);
	
		// Create canvas
		var canvas = document.createElement('canvas');
		canvas.width = imgElement.naturalWidth;
		canvas.height = imgElement.naturalHeight;
		canvas.getContext('2d').drawImage(imgElement, 0, 0);
	
		$img.addClass(config.animatedGifClass);
		$(canvas).addClass(config.staticCanvasClass);
	
		// Ensure container is positionable
		$parent.css({ position: 'relative', display: 'inline-block' }).addClass(config.containerClass);
	
		// Insert canvas and overlay on top
		$parent.append(canvas);
		$parent.append($('<div>').addClass(config.overlayClass).append($('<div>').addClass(config.playIconClass)));
	}

	/**
	 * Finds and freezes all animated GIFs.
	 */
	function handleGifs() {
		// Select GIFs that aren't in a charbox and haven't been marked as pending or processed.
		$('img[src$=".gif"]')
			.not('.charbox img, .' + config.processedClass + ', .' + config.pendingClass)
			.each(function () {
				var imageElement = this;
				
				if (imageElement.complete && imageElement.naturalWidth > 0) {
					// If image is already loaded, process it immediately.
					createStaticCanvas(imageElement);
				} else {
					// If image is not loaded, mark it as 'pending' immediately.
					var $img = $(imageElement);
					$img.addClass(config.pendingClass);
					
					// Then, attach a ONE-TIME listener to process it when it's ready.
					$img.one('load', function () {
						createStaticCanvas(this);
					}).one('error', function() {
						// If the image fails, just mark it as processed so we don't try again.
						$(this).removeClass(config.pendingClass).addClass(config.processedClass);
					});
				}
			});
			
			// Also check gallery images specifically
			$('.gallerybox img[src$=".gif"]')
				.not('.' + config.processedClass + ', .' + config.pendingClass)
				.each(function () {
					var imageElement = this;
			
					if (imageElement.complete && imageElement.naturalWidth > 0) {
						createStaticCanvas(imageElement);
					} else {
						var $img = $(imageElement);
						$img.addClass(config.pendingClass);
			
						$img.one('load', function () {
							createStaticCanvas(this);
						}).one('error', function () {
							$(this).removeClass(config.pendingClass).addClass(config.processedClass);
						});
					}
				});
	}

	// Main execution block.
	mw.loader.using(['mediawiki.util'], function () {
		$(function () {
			mw.util.addCSS(config.css);
			
			var runPause = function() {
				handleVideos();
				handleGifs();
			};

			runPause();

			var targetNode = document.getElementById('mw-content-text');
			if (targetNode) {
				new MutationObserver(runPause).observe(targetNode, {
					childList: true,
					subtree: true
				});
			}
		});
	});
}(mediaWiki, jQuery));