Code Snippet: GDPR compliant YouTube Videos

Version: 1.0.6 (Aug 05, 2021)



YouTube videos are embedded on many websites.

If you use the standard embedding methods provided by WordPress (Classic Editor, Gutenberg) or page builders (Oxygen, and many others) for this purpose and don't ask for the visitor's consent beforehand, you'll probably violate the General Data Protection Regulation (GDPR) in the EU: When displaying a page that contains a YouTube video, a connection to the YouTube servers is automatically established in order to load the preview image. In the process, the visitor's IP address, and thus personal information, is transmitted to YouTube. That's not allowed without explicit consent.

So how can you embed YouTube videos on your website in a GDPR compliant way?


The code snippet described here provides a WordPress shortcode that enables a GDPR compliant and at the same time as simple and efficient as possible embedding of YouTube videos.

Using this shortcode, the preview image for the video is already retrieved by the YouTube server and cached locally. The visitor's browser is given this local preview image, along with a note about the privacy policy. The browser does not need to establish a connection to YouTube at this point. The actual video is only loaded from YouTube once the visitor has clicked on it. This procedure is GDPR compliant because the visitor must first actively confirm that he wants to load the video from YouTube.

The code snippet also uses the special domain to retrieve the video, thus avoiding the use of cookies by YouTube.

And by the way, this solution should also have a significant positive impact on the well-known speed test tools (Page Speed Insights, GTMetrix, …), since no data is loaded from external servers.


The simplest syntax for the shortcode is:

[ma-gdpr-youtube id="4jlOF09WRw8"]

where 4jlOF09WRw8 is the ID of the YouTube video. You can easily copy the ID from the URL of the YouTube video:

This shortcode can easily be written directly in the Classic or Gutenberg editor.
In page builders, like e.g. Oxygen, there is usually a separate "Shortcode" element available for this purpose.

This shortcode generates the following output:

When clicked, this video is loaded from YouTube servers. See our Privacy Policy for details.

The thumbnail is not loaded from YouTube, but from the local cache.
In the center, the thumbnail is overlaid by the play button similar to the one on YouTube.
And at the bottom, the visitor sees a note on privacy policy in a colored bar.


The shortcode automatically loads the thumbnail for this video from YouTube.


Different image formats are retrieved:

Format NameAspect RatioSize
mqdefault16:9320 x 180 px
hqdefault4:3480 x 360 px
sddefault4:3640 x 480 px
hq72016:91280 x 720 px
maxresdefault16:9Original size, e.g. 1920 x 1080 px

The image formats are used for optimal display of the thumbnail in different screen resolutions.
The snippet uses the higher resolution thumbnail already at half of the next higher resolution to improve the display quality.

Note: Although YouTube states in the documentation that the above formats are always available, some formats are missing for some videos. The snippet detects this and uses a suitable of the available formats instead.


The thumbnails loaded from YouTube are automatically stored on the server in the /wp-content/uploads/ma-gdpr-youtube-thumbnails/ directory. The directory is created automatically if it doesn't already exist. A separate subdirectory is created for each video ID.

Shortcode Parameters

Besides the mandatory id parameter, the shortcode allows several other parameters, which are explained below.


The default aspect ratio for YouTube videos is 16:9.
The aspect-ratio parameter allows you to use a different aspect ratio, such as 4:3 or 1:1 (square).
Any aspect ratio can be specified here as long as the syntax "width:height" is kept as two numbers separated by a colon.
The 16:9 thumbnail will automatically fit the aspect ratio specified here.


[ma-gdpr-youtube id="4jlOF09WRw8" width="400px" aspect-ratio="1:1"]

When clicked, this video is loaded from YouTube servers. See our Privacy Policy for details.


The code snippet defines a notice text about the privacy policy in different languages:

DEBei Klick wird dieses Video von den YouTube Servern geladen. Details siehe Datenschutzerklärung.
ENWhen clicked, this video is loaded from YouTube servers. See our privacy policy for details.
ESAl hacer clic, este vídeo se carga desde los servidores de YouTube. Consulte la política de privacidad para más detalles.
FREn cliquant, cette vidéo est chargée depuis les serveurs de YouTube. Voir la politique de confidentialité.
HUKattintás után ez a videó a Youtube szervereiről kerül lejátszásra. A részletekért olvassa el az Adatkezelési Tájékoztatót oldalt.
ITQuando si clicca, questo video viene caricato dai server di YouTube. Vedere l'informativa sulla privacy per i dettagli.

The language is selected based on your website's or page language. Polylang language support is included in the snippet.
If no default text is available for your page's language, English is selected instead.

The notice text can be customized to your own requirements, for example other languages, via parameter gdpr-text, and can contain the following placeholders:
{privacy-policy-url} will be replaced by the URL of the privacy policy page configured in WordPress.
{privacy-policy-link} will be replaced with a full link to the privacy policy page configured in WordPress.

Note: The predefined default texts include a link to the privacy policy, if it's properly configured in WordPress.


[ma-gdpr-youtube id="4jlOF09WRw8" gdpr-text="Wanneer erop wordt geklikt, wordt deze video van de YouTube-servers geladen. Zie het {privacy-policy-link} voor details."]

Wanneer erop wordt geklikt, wordt deze video van de YouTube-servers geladen. Zie het Privacy Policy voor details.

Note: In this case the page title of the privacy policy is displayed in English, since this is the default language for this page.

The parameter gdpr-text can also be set to be empty. In this case, no hint text is displayed at all. This is especially conceivable in conjunction with the parameter new-window.

[ma-gdpr-youtube id="4jlOF09WRw8" gdpr-text=""]


By default, the notice text is displayed in the font size 0.7em, i.e. 70% of the text size defined for this block.
The gdpr-text-size parameter can be used to adjust the text size to your own requirements. Any valid CSS specification for the text size is allowed.


[ma-gdpr-youtube id="4jlOF09WRw8" gdpr-text-size="20px"]

When clicked, this video is loaded from YouTube servers. See our Privacy Policy for details.


By default, a width of 100% is set for the video block. The video block thus occupies the entire width of the enclosing block, e.g. a DIV or a column. The default of 100% allows easy adjustment of the responsive view by the parent element.
The height is automatically calculated from the width and aspect ratio.

If needed, the width can be changed with the width parameter. All valid CSS specifications for the width of an element are allowed.


[ma-gdpr-youtube id="4jlOF09WRw8" width="300px"]

When clicked, this video is loaded from YouTube servers. See our Privacy Policy for details.

Note: I recommend to leave the width at the default of 100% and instead style the enclosing block for responsive viewports using CSS media queries.


The parameter new-window opens the video in a new browser tab directly on the YouTube page.
This may be desirable if you want to display only very small thumbnails of the videos in WordPress, which are unsuitable for direct integration of the player.

[ma-gdpr-youtube id="4jlOF09WRw8" new-window=1]

When clicked, this video is loaded from YouTube servers. See our Privacy Policy for details.

YouTube Player API

When clicking on a video, the YouTube Player is loaded from the YouTube servers via YouTube Player API.
All the usual YouTube functions are available here.
I implemented the YouTube player in a way that when clicking on a video, a possibly other, already running video is paused.
Thus, two videos never run at the same time.


The code snippet is available for download here:

Version 1.0.6, 2021-08-05

For installation and use of the downloaded JSON file you will need the plugin Code Snippets or Advanced Scripts.
You can install the JSON file using the "Import" function of the plugin. 
Don't forget to activate the snippet after import.

Alternative: At the end of this page you can view and copy the complete source code of the snippet.

New functionalities and bug fixes are documented in the change log.


I have developed and tested the code snippet to the best of my knowledge under WordPress 5.7.2 and Oxygen 3.8 .
I provide the code snippet for free use.
I cannot give any guarantee for the functionality because of the countless possible variations in WordPress environments.
Download and use of this code snippet is at your own risk and responsibility.


I enjoy developing code snippets and solving requirements with them. I provide the snippets free of charge.

If you like, you can honor my many hours of work with a small coffee donation via PayPal.

  When clicking the button, a connection to PayPal is established.

Your donation will of course be taxed properly by me.

Change Log

See "Version History" in Source Code

Source Code

Plugin Name:	MA GDPR YouTube
Description:	GDPR compliant YouTube video embedding
Author:			<a href="">Matthias Altmann</a>
Project:		Code Snippet: GDPR Compliant YouTube Embed
Version:		1.0.6
Plugin URI:
Description:	en:
Copyright:		© 2021, Matthias Altmann

Version History:
Date		Version		Description
2021-08-05	1.0.6		Features:
						- Using scheme-less URL to avoid issues with wrong WordPress URL configuration
						- Added parameter new-window to play video in a new window
						- Added "_" to valid character check on video id
						- Hide GDPR notice block if text is empty
						- Load and cache YouTube thumbnails only on very first appearance of a new video ID 
						  to improve performance, if specific YouTube thumbnail sizes are not available
						Bug Fixes:
						- Check for availability of specific thumbnail sizes (might not be available from YouTube)
2021-06-17	1.0.5		Fix: Correction in Hungarian translation
2021-06-17	1.0.4		Features: 
						- Added "-" to valid character check on video id (thanks to Zoltán Kőrösi)
						- Added Hungarian GDPR text (thanks to Zoltán Kőrösi)
2021-06-17	1.0.3		Fix: Check GET parameter "ct_builder" before accessing it
2021-06-15	1.0.2		Feature: Add link to privacy policy to default gdpr text if configured in WordPress
2021-06-15	1.0.1		Fix: Allow same video embedded multiple times
2021-06-15	1.0.0		Initial Release

if (!is_admin() && !wp_doing_ajax() && !wp_doing_cron() ) {
class MA_GDPR_YouTube {

	// ===== CONFIGURATION ==============================================================================================
	private static $default_aspect_ratio		= '16:9';		// aspect ratio of the video block. Syntax X:X
	private static $default_gdpr_text 			= [ 			// GDPR notice text in different languages.  
		'de' => ['Bei Klick wird dieses Video von den YouTube Servern geladen. Details siehe %s.', 'Datenschutzerklärung.'],
		'en' => ['When clicked, this video is loaded from YouTube servers. See our %s for details.', 'privacy policy'],
		'es' => ['Al hacer clic, este vídeo se carga desde los servidores de YouTube. Consulte la %s para más detalles.', 'política de privacidad'],
		'fr' => ['En cliquant, cette vidéo est chargée depuis les serveurs de YouTube. Voir la %s.', 'politique de confidentialité'], 
		'hu' => ['Kattintás után ez a videó a Youtube szervereiről kerül lejátszásra. A részletekért olvassa el az %s oldalt.', 'Adatkezelési Tájékoztatót'],
		'it' => ['Quando si clicca, questo video viene caricato dai server di YouTube. Vedere %s per i dettagli.', 'l\'informativa sulla privacy'],
	private static $default_gdpr_text_size		= '.7em';		// font size for GDPR text
	private static $default_width				= '100%';		// width of the video block. Can be specified in %, px
	private static $default_new_window			= false;		// open video in new window

	// ===== INTERNAL ===================================================================================================
	private static $footercode_needed	= false;	// will be set to true if shortcode used on current page
	private static $footercode_minimize = true;		// should we minimize all footer code (style, script, svg)?
	private static $yt_image_url 		= '$s/%2$s.jpg';
	private static $yt_image_sizes		= [ // various resolutions, not all might be available!
	# see
	#	tag		 						  aspect ratio	availability	resolution
	#	'default' 			=> 120,		// 		4:3		guaranteed		120x90
		'mqdefault' 		=> 320,		// 		16:9	guaranteed		320x180
		'hqdefault'			=> 480,		// 		4:3		guaranteed		480x360
		'sddefault'			=> 640,		// 		4:3		optional		640x480
		'hq720'				=> 1280,	// 		16:9	optional		1280x720
		'maxresdefault'		=> 1920,	// 		16:9	optional		(highest, depends on video, e.g. 1280x720, 1920x1080, ...)


	public static function init() {

		add_shortcode('ma-gdpr-youtube', [__CLASS__, 'shortcode']);

		if ( isset($_GET['ct_builder']) && ($_GET['ct_builder'] == true) ) {
			// emit styles, script, svg when Oxygen Builder is active
			self::$footercode_needed = true;
	private static function get_privacy_policy_link($text) {
		// return a link to the privacy policy (if configured) or just the passed text
		$pplink = get_the_privacy_policy_link();
		return  $pplink ? $pplink : $text; 

	public static function shortcode($atts, $content = '') {
		$lang = self::get_current_language();

		// get defaults for unspecified atts
		$atts = (object)array_merge([
			'id'				=> null,
			'uniqid'			=> null,
			'width'				=> self::$default_width,
			'aspect-ratio'		=> self::$default_aspect_ratio,
			'gdpr-text'			=> isset(self::$default_gdpr_text[$lang]) 
									? sprintf(self::$default_gdpr_text[$lang][0],self::get_privacy_policy_link(self::$default_gdpr_text[$lang][1])) 
									: sprintf(self::$default_gdpr_text['en'][0],self::get_privacy_policy_link(self::$default_gdpr_text['en'][1])),
			'gdpr-text-size'	=> self::$default_gdpr_text_size,
			'new-window'		=> self::$default_new_window,
		], $atts);

		if (!isset($atts->id) || ($atts->id == '' )) 			{return '[MA GDPR YouTube] Missing video id.';}
		if (preg_match('/[^A-Za-z0-9\-\_]/',$atts->id))			{return '[MA GDPR YouTube] Invalid video id.';}
		// generate an unique id (for the case a video is embedded multiple times)
		$atts->uniqid = $atts->id.'-'.uniqid();

		$thumbnails_base = self::get_thumbnails_base(); 
		if (!$thumbnails_base) 									{return '[MA GDPR YouTube] Error creating thumbnail directory.';}
		// check if we already have a thumbnail

		if (!self::check_thumbnails($atts->id)) 				{return '[MA GDPR YouTube] Error retrieving thumbnails.';}

		$thumbnail = '';
		$source_list = [];
		$sizes = [];
		// get sources
		foreach (self::$yt_image_sizes as $size_tag => $size) {
			$source_list[] = [$size_tag,$size];
		// get smallest thumbnail first
		list ($size_tag,$size) = array_shift($source_list);
		$img_src = $thumbnails_base->url.'/'.$atts->id.'/'.$atts->id.'_'.$size_tag.'.jpg';
		// get larger thumbnails
		$sources = [];
		while (count($source_list)) {
			list ($size_tag,$size) = array_shift($source_list);
			$img_path = $thumbnails_base->dir.'/'.$atts->id.'/'.$atts->id.'_'.$size_tag.'.jpg';
			if (file_exists($img_path)) {
				// get real image size
				$img_info = getimagesize($img_path);
				if ($img_info) {
					$img_width = $img_info[0];
					// skip if we already have this size (maxres might be same as hq720)
					if (in_array($img_width,$sizes)) {continue;}
					$sizes[] = $img_width;
					// to improve thumbnail quality, use higher res image if we reach half its size 
					$sources[] = '<source media="(min-width:'.($img_width/2).'px)" srcset="'.$thumbnails_base->url.'/'.$atts->id.'/'.$atts->id.'_'.$size_tag.'.jpg'.'">';
		$thumbnail .= '<picture class="ma-gdpr-youtube-thumbnail">' . implode('',array_reverse($sources)) . '<img src="'.$img_src.'"></picture>';

		// calculate dimensions of video block depending on width and aspect ratio
		list ($arw,$arh) = explode(':',$atts->{'aspect-ratio'},2); // aspect ratio elements
		list ($width_value, $width_unit) = ['100','%']; // default width value and unit
		// split width value and unit
		if (count($matches) == 3) {array_shift($matches); list ($width_value, $width_unit) =  $matches;}
		// calculate block dimensions
		$block_width = $width_value.$width_unit;
		$block_height = ($width_value * ($arh/$arw)) . $width_unit;

		// privacy policy utrl and link
		$atts->{'gdpr-text'} = str_replace('{privacy-policy-url}', get_privacy_policy_url(), $atts->{'gdpr-text'});
		$atts->{'gdpr-text'} = str_replace('{privacy-policy-link}', get_the_privacy_policy_link(), $atts->{'gdpr-text'});

		$retval = sprintf(	'<div id="%7$s" data-video-id="%2$s" class="ma-gdpr-youtube-wrapper" style="width:%3$s;height:%4$s;padding-top:%4$s;" data-new-window="%8$s">'.
								'<svg class="ma-gdpr-youtube-button"><use xlink:href="#ma-gdpr-youtube-play-button"></use></svg>'.
								'<div class="ma-gdpr-youtube-notice" style="font-size:%6$s;">%5$s</div>'.
		self::$footercode_needed = true;
		return $retval;


	// return base dir/url for video thumbnails. Create directory if necessary
	private static function get_thumbnails_base() {
		$retval = (object)['dir'=>null,'url'=>''];
		$thumbnail_dir_info = wp_get_upload_dir();
		$retval->dir = $thumbnail_dir_info['basedir'].'/ma-gdpr-youtube-thumbnails';
		$retval->url = $thumbnail_dir_info['baseurl'].'/ma-gdpr-youtube-thumbnails';
		// create thumbnails folder if not exists
		if (!file_exists($retval->dir)) {
			if (!@mkdir($retval->dir)) {
				error_log('[MA GDPR YouTube] Error creating thumbnail cache base folder.'); 
				return null;
		// create scheme-less URL
		$retval->url = preg_replace('/^https?\:/','',$retval->url);
		return $retval;
	// Check if thumbnails for video $id have been downloaded. Load if not yet available
	private static function check_thumbnails($id) {
		$thumbnails_base = self::get_thumbnails_base(); 
		if (!$thumbnails_base) 	{return false;}
		// check if directory for this video exists
		$vid_dir = $thumbnails_base->dir.'/'.$id;
		if (!file_exists($vid_dir)) {
			if (!@mkdir($vid_dir)) {error_log('[MA GDPR YouTube] Error creating thumbnail cache folder for video '.$id.'.'); return false;}
			foreach (self::$yt_image_sizes as $size_tag => $size) {
				$img_path = $vid_dir.'/'.$id.'_'.$size_tag.'.jpg';
				if (!file_exists($img_path)) {
					// load thumbnail
					$img_url = sprintf(self::$yt_image_url, $id, $size_tag);
					$img_data = @file_get_contents($img_url);
					// cache thumbnail
					if ($img_data) {file_put_contents($img_path, $img_data);}  
		return true;
	private static function get_current_language(){
		$retval = get_locale();
		// Is Polylang available? 
		if (function_exists('pll_current_language')) {$retval = pll_current_language();}
		$retval = str_replace('_','-',$retval);
		$retval = explode('-',@$retval)[0];
		return $retval;
	public static function footercode() {
		if (!self::$footercode_needed) {return;}
		// emit style
		$style = <<<'END_OF_STYLE'
<style id="ma-gdpr-youtube-style">
	.ma-gdpr-youtube-wrapper {position:relative;}
	.ma-gdpr-youtube-thumbnail {position:absolute; top:0;left:0; width:100%; height:100%; display:flex;cursor:pointer;}
	.ma-gdpr-youtube-thumbnail img {width:100%; height:100%; object-fit:cover; object-position:50% 50%;}
	.ma-gdpr-youtube-button {position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:70px; height:auto; cursor:pointer;}
	.ma-gdpr-youtube-notice {position:absolute; width:100%; left:0; right:0; bottom:0; max-width:100%; text-align:center; font-size:.7em; background-color:rgba(255,255,255,.8); padding:.2em .5em;}
	.ma-gdpr-youtube-notice:empty {display:none;}
	/* make youtube iframes responsive */
	/* {max-width:100% !important;}*/
		if (self::$footercode_minimize) { 
			$style = preg_replace('/\/\*.*?\*\//','',$style); 
			$style = preg_replace('/\r?\n */','',$style); 
			$style = preg_replace('/\t/','',$style); 

		echo $style;
		// emit code
		$script = <<<'END_OF_SCRIPT'
<script id="ma-gdpr-youtube-script">
		var $debug = false;
		window.ma_gdpr_youtube_player = null;
		window.YT = null; /* prevent JS errors in Oxygen Builder */

		$('.ma-gdpr-youtube-wrapper :is(img,.ma-gdpr-youtube-button)').click(function() {
			/* get closest wrapper */
			var $ytWrapper = $(this).closest('.ma-gdpr-youtube-wrapper'); 

			/* check if video should be played in new window */
			var $new_window = $ytWrapper.attr('data-new-window') ? true : false;
			$debug && console && console.log('YouTube open in new-window:',$new_window);

			if ($new_window) {
				/* Get the video id from the parent div's id attribute */
				var $ytVidID = $ytWrapper.attr('data-video-id');''+$ytVidID,'video-'+$ytVidID);

			/* Instantiate a new YT player (replacing out wrapper), and play it when ready */
			if ( $('#ma-gdpr-youtube-player-api').length==0 ) { /* check if youtube player api has already been loaded */
				/* Load the YouTube API */
				window.onYouTubeIframeAPIReady = function() {
					$debug && console && console.log('YouTube API ready.');
				$debug && console && console.log('Loading YouTube API...');
				$('<script id="ma-gdpr-youtube-player-api" src=""><\/script>').appendTo('body');
			} else {
				/* YouTube API is already loaded */
		/* This gets called when the onReady event fires */
		window.ytVidPlay = function($ytWrapper) {
			/* Get the video id from the parent div's id attribute */
			var $ytVidID = $ytWrapper.attr('data-video-id');
			var $apiID = $ytWrapper.attr('id');
			$debug && console && console.log('Starting video '+$ytVidID+' from wrapper '+$apiID);
			window.ma_gdpr_youtube_player && (typeof window.ma_gdpr_youtube_player.pauseVideo!=='undefined') && window.ma_gdpr_youtube_player.pauseVideo();
			/* Get the dimensions of the wrapper */
			var $ytWrapperWidth = $ytWrapper.innerWidth();
			var $ytWrapperHeight = $ytWrapper.innerHeight();
			$debug && console && console.log('Video WxH',[$ytWrapperWidth,$ytWrapperHeight]);

			/* remove styles from wrapper */

			if (!YT) return; /* prevent JS errors in Oxygen Builder */
			window.ma_gdpr_youtube_player  = new YT.Player($apiID, {
				width: $ytWrapperWidth,
				height: $ytWrapperHeight ,
				videoId: $ytVidID,
				host: '',
				enablejsapi: 1,
				playerapiid: $apiID,
				events: {
					'onReady': function(event) { 
						$debug && console && console.log('Video ready.');
						window.ma_gdpr_youtube_player && (typeof window.ma_gdpr_youtube_player.playVideo!=='undefined') && window.ma_gdpr_youtube_player.playVideo();
					'onStateChange': function(event) {
						/* if multiple YT players are open, stop others when one is (re-)started. */
						/* see */
						$debug && console && console.log('Event',event);
						if ( == 1) { /* play */
							$('').each(function() { /* check for wrappers that are already a YT iframe */
								if ( ($(this).attr('id') != $apiID) ) { /* skip the current video */
									$debug && console && console.log('Pausing other video '+$(this).attr('id')+'.');
									/* send pause command */
									$(this)[0].contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}', '*')
						if ( == 2) { /* pause */
							$debug && console && console.log('Pausing video ''.');
		if (self::$footercode_minimize) { 
			$script = preg_replace('/\/\*.*?\*\//','',$script); 
			$script = preg_replace('/\r?\n */','',$script); 
			$script = preg_replace('/\t/','',$script); 
		echo $script;

		// emit play button svg symbol
		$symbol = <<<'END_OF_SYMBOL'
<svg id="ma-gdpr-youtube-symbol" version="1.1" xmlns="" xmlns:xlink="" 
			aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;">
		<symbol id="ma-gdpr-youtube-play-button" viewBox="0 0 500 350" >
			<path fill="#f61c0d" d="M500,74.767C500,33.472,466.55,0,425.277,0 H74.722C33.45,0,0,33.472,0,74.767v200.467C0,316.527,33.45,350,74.722,350h350.555C466.55,350,500,316.527,500,275.233V74.767z  M200,259.578v-188.3l142.789,94.15L200,259.578z"/>
			<path fill="white" d="M199.928,71.057l0.074,188.537l142.98-94.182 L199.928,71.057z"/>
		if (self::$footercode_minimize) { $symbol = preg_replace('/\r?\n */','',$symbol); }
		echo $symbol;


First published: Jun 15, 2021 on Code Snippet: GDPR compliant YouTube Videos