Blog

Code Snippet: DSGVO-konforme Vimeo Videos

Version: 1.3.0 (28.04.2024)
Auch verfügbar für YouTube!

Einführung

Auf vielen Websites werden Vimeo Videos eingebunden.

Nutzt man hierzu die in WordPress (Classic Editor, Gutenberg) oder Page Buildern (Oxygen, und viele andere) vorgesehenen Standard-Verfahren und fragt nicht vorher die Zustimmung des Besuchers ab, verstößt man in der EU gegen die Datenschutzrichtlinien (DSGVO, GDPR): Bei der Anzeige einer Seite, die ein Vimeo Video enthält, wird automatisch eine Verbindung zu den Vimeo Servern aufgebaut, um das Vorschaubild zu laden. Dabei wird die IP-Adresse des Besuchers, und damit eine personenbezogene Information, an Vimeo übertragen. Das ist nicht erlaubt ohne explizite Zustimmung.

Wie kann man also Vimeo Videos auf der Website DSGVO-konform einbinden?

Lösung

Das hier beschriebene Code Snippet stellt einen WordPress Shortcode zur Verfügung, der eine DSGVO-konforme und gleichzeitig möglichst einfache und effiziente Einbettung von Vimeo Videos ermöglicht.

Mittels dieses Shortcodes wird das Vorschaubild für das Video bereits durch den Server von Vimeo abgerufen und lokal zwischengespeichert. Dem Browser des Besuchers wird dieses lokale Vorschaubild übergeben, zusammen mit einem Hinweistext zu den Datenschutzrichtlinien. Der Browser braucht also zunächst keine Verbindung zu Vimeo aufzubauen. Erst nach Klick durch den Besucher wird das eigentliche Video von Vimeo geladen. Dieses Verfahren ist DSGVO-konform, weil der Besucher zunächst aktiv bestätigen muss, dass er das Video von Vimeo laden will.

Das Code Snippet verwendet für den Abruf des Videos außerdem einen "Do Not Track" Parameter (dnt=1) und vermeidet so die Verwendung von Cookies seitens Vimeo.

Und so ganz nebenbei sollte sich diese Lösung auch deutlich positiv auf die bekannten Speed Test Tools (Page Speed Insights, GTMetrix, ...) auswirken, da keine Daten von externen Servern geladen werden, bevor der Besucher dem nicht zugestimmt hat.

Shortcode

Die einfachste und kürzeste Syntax für den Shortcode lautet:

[ma-gdpr-vimeo video="814361316"]

wobei 814361316 hier für die ID des Vimeo Videos steht. Die ID kann man sich ganz einfach aus dem URL zu dem Vimeo Video kopieren:

Dieser Shortcode kann ganz einfach direkt im Classic oder Gutenberg Editor geschrieben werden.
In Page Buildern steht dafür meist ein eigenes Element "Shortcode" zur Verfügung.

Dieser Shortcode erzeugt folgende Ausgabe:

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

Das Vorschaubild wird nicht von Vimeo, sondern aus dem lokalen Zwischenspeicher geladen.
Mittig wird das Vorschaubild durch den von Vimeo bekannten Play-Button überlagert.
Und unten sieht der Besucher in einem farblich abgehobenen Balken einen Hinweistext zum Datenschutz.

Vorschaubild

Das Snippet lädt automatisch die verfügbaren Vorschaubilder für dieses Video von Vimeo und bietet diese dem Browser an.
Meist sind dies JPG Dateien mit einer Breite von 100, 200, 295 und 640 Pixeln.
Der Browser wählt das für die jeweilige Bildschirmauflösung passendste Vorschaubild aus.

Hinweis:

  • Das Snippet stellt dem Browser alle Bildgrößen zur Auswahl. Der Browser entscheidet selbst, welche Bildgröße geladen wird.

Zwischenspeicher

Die von Vimeo geladenen Vorschaubilder werden automatisch am Server im Verzeichnis /wp-content/uploads/ma-gdpr-vimeo/ gespeichert. Das Verzeichnis wird automatisch angelegt, falls es noch nicht existiert. Für jede Video-ID wird darin ein eigenes Unterverzeichnis angelegt.

Shortcode Parameter

Damit der Shortcode funktioniert, muss das gewünschte Vimeo Video zwingend mit dem Shortcode-Parameter video angegeben werden.

Daneben erlaubt der Shortcode noch einige weitere Parameter, die weiter erklärt sind.

video

Mit dem Parameter video wird die Vimeo ID oder der Vimeo URL des gewünschten Videos angegeben.
Die Video ID besteht aus Zahlen und Buchstaben.
Bei Angabe eines URL wird die ID daraus automatisch ermittelt. Es werden zahlreiche Varianten von Vimeo URLs unterstützt.

Beispiele:

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
[ma-gdpr-vimeo video="814361316"]
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
[ma-gdpr-vimeo video="https://vimeo.com/814361316"]

aspect-ratio

Das Standard-Seitenverhältnis für Vimeo Videos ist 16:9.
Der Parameter aspect-ratio erlaubt die Verwendung eines davon abweichenden Seitenverhältnisses, wie z.b. 4:3 oder 1:1 (quadratisch).
Es können hier beliebige Seitenverhältnisse angegeben werden, solange die Syntax mit zwei Zahlen durch Doppelpunkt oder Schrägstrich getrennt eingehalten wird: "Breite:Höhe" oder "Breite/Höhe"
Das Vorschaubild und der Vimeo Player werden automatisch in das hier angegebene Seitenverhältnis eingepasst. Das Video selbst wird im Original-Seitenverhältnis angezeigt, eventuell dann mit schwarzen Balken.

Beispiel:

[ma-gdpr-vimeo video="814361316" width="350px" aspect-ratio="1:1"]

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

gdpr-text

Im Code Snippet ist ein Hinweis-Text zu den Datenschutzrichtlinien in verschiedenen Sprachen hinterlegt:

SpracheText
DANår du har trykket, vil videoen blive indlæst fra Vimeo's servere. Se vores privatlivspolitik for flere informationer.
DEBei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
ENWhen clicked, this video is loaded from Vimeo servers. See our privacy policy for details.
ESAl hacer clic, este vídeo se carga desde los servidores de Vimeo. Consulte la política de privacidad para más detalles.
FIKlikattuasi, tämä video ladataan Vimeon palvelimilta. Katso lisätietoja meidän tietosuojaselosteesta.
FREn cliquant, cette vidéo est chargée depuis les serveurs de Vimeo. Voir la politique de confidentialité.
HUKattintás után ez a videó a Vimeo 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 Vimeo. Vedere l'informativa sulla privacy per i dettagli.
JAクリックすると、この動画が Vimeo サーバーから読み込まれます。詳細については、プライバシー ポリシー をご覧ください。

Die Sprache wird basierend auf der Sprache der Website oder Seite ausgewählt. Die Unterstützung von Polylang-Sprachen ist im Snippet enthalten.
Wenn kein Standardtext für die Sprache der Seite verfügbar ist, wird stattdessen Englisch verwendet.

Der Hinweis-Text kann per Parameter gdpr-text an die eigenen Anforderungen, beispielsweise andere Sprachen, angepasst werden, und kann folgende Platzhalter enthalten:
{privacy-policy-url} wird ersetzt durch den in WordPress konfigurierten URL der Seite zur Datenschutzerklärung.
{privacy-policy-link} wird ersetzt durch einen vollständigen Link zur in WordPress konfigurierten Seite zur Datenschutzerklärung.

Hinweis: Die vordefinierten Standardtexte enthalten einen Link zur Datenschutzerklärung, wenn diese in WordPress korrekt konfiguriert ist.

Beispiel:

[ma-gdpr-vimeo video="814361316" gdpr-text="Wanneer erop wordt geklikt, wordt deze video van de Vimeo-servers geladen. Zie het {privacy-policy-link} voor details."]

Wanneer erop wordt geklikt, wordt deze video van de Vimeo-servers geladen. Zie het Datenschutzerklärung voor details.

Hinweis: In diesem Fall wird der deutsche Titel der Datenschutzerklärung angezeigt, da dies die Standardsprache für diese Seite ist.

Der Parameter gdpr-text kann auch gezielt gelöscht werden. In diesem Fall wird dann kein Hinweis-Text angezeigt. Dies ist vor allem in Verbindung mit dem Parameter new-window denkbar.

[ma-gdpr-vimeo video="814361316" gdpr-text=""]

gdpr-text-size

Der Hinweistext wird standardmäßig in der Schriftgröße 0.7em, also 70% der für diesen Block definierten Textgröße, ausgegeben.
Mit dem Parameter gdpr-text-size an die eigenen Wünsche angepasst werden. Erlaubt sind alle gültigen CSS Angaben zur Text-Größe.

Beispiel:

[ma-gdpr-vimeo video="814361316" gdpr-text-size="20px"]
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

notice-style

Das Design des Datenschutzhinweises kann bei Bedarf angepasst werden.
Weiße Schrift auf rotem Balken? Kein Problem!

Beispiel:

[ma-gdpr-vimeo video="814361316" notice-style="background-color:red; color:white"]
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

notice-class

Mit einer eigenen CSS Klasse kann das noch gezielter erfolgen. So kann man z.B. auch das Design des Links steuern:

Beispiel:

<style>
body .my-notice-class {
  background-color: red;
  color:white;
}
body .my-notice-class a {
  color: white;
  font-weight: bold;
}
</style>
[ma-gdpr-vimeo video="814361316" notice-class="my-notice-class"]
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

width

Im Standard wird für den Video-Block eine Breite von 100% gesetzt. Der Video-Block nimmt dadurch die gesamte Breite des umschließenden Blocks, z.B. eines DIVs oder eine Spalte ein. Der Standard von 100% erlaubt eine einfache Anpassung der Responsive-Ansicht durch das übergeordnete Seitenelement.
Die Höhe errechnet sich automatisch aus der Breite und dem Seitenverhältnis.

Bei Bedarf kann die Breite mit dem Parameter width geändert werden. Erlaubt sind alle gültigen CSS Angaben zur Breite eines Elements.

Beispiel:

[ma-gdpr-vimeo video="814361316" width="300px"]

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

Hinweis: Ich empfehle, die Breite auf dem Standardwert von 100% zu belassen und stattdessen den umschließenden Block für Responsive Viewports mit CSS-Medienabfragen zu gestalten.

alt / title

Die Parameter alt und title setzen die entsprechenden HTML Attribute für das Vorschaubild. Das Attribut alt dient der Unterstützung von Suchmaschinen und der Barrierefreiheit, das Attribut title stellt den Tooltip Text bereit.

[ma-gdpr-vimeo video="814361316" alt="Die Erde dreht sich" title="Erddrehung"]

Die Erde dreht sich
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

Es können hier auch Vimeo Metadaten eingeblendet werden.

thumbnail

Mit dem Parameter thumbnail kann ein eigenes statt des von Vimeo geladenen Vorschaubildes verwendet werden.
Hier kann entweder ein vollständiger URL zu einem Bild angegeben werden, oder die Medien-ID eines Bildes, das bereits in WordPress hochgeladen wurde.

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

[ma-gdpr-vimeo video="814361316" thumbnail="https://www.altmann.de/wp-content/uploads/2023/04/gdpr-vimeo-nasa.jpg"]

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

[ma-gdpr-vimeo video="814361316" thumbnail="2361"]

Foto von NASA auf Unsplash

title-text

Das Snippet zeigt das Vorschau-Bild von Vimeo, aber keinen Titel zu dem Video.
Mit dem Parameter title-text kann ein Titel über dem Vorschaubild angezeigt werden.

[ma-gdpr-vimeo video="814361316" title-text="Schau, wie sich die Erde dreht..."]
Schau, wie sich die Erde dreht
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

Der Titel wird standardmäßig oben links angezeigt, weiße Schrift in Standardgröße, mit einem subtilen Schatten, damit der Text auch auf hellen Vorschaubildern lesbar ist.

Es können hier auch Vimeo Metadaten eingeblendet werden.

title-style

Der Standard Style für den Titel ist definiert als:

.ma-gdpr-vimeo-title {
  position:absolute; 
  width: 100%; 
  top: 1em; 
  padding: 0 1em; 
  color: white; 
  text-shadow: black 1px 1px 2px;
}

Über den Parameter title-style können Positionierung und Design des Titels angepasst werden.

Positionierung

Einmal um die Welt...
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
[ma-gdpr-vimeo video="814361316"title-text="Einmal um die Welt..." title-style="top:unset; bottom:30%;text-align:center"]

Design

Einmal um die Welt...
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
[ma-gdpr-vimeo video="814361316" title-text="Einmal um die Welt..." title-style="top:1rem; font-size:2em; color:red; text-align:center;"]

title-class

Zur globalen Steuerung von Positionierung und Design der Titel kann mit dem Parameter title-class auch eine Klasse festgelegt werden.

Beispiel:

<style>
body .my-video-title-class {
  top: 1rem; 
  font-size: 1.5em; 
  line-height: 1;
  color: blue; 
  text-align: center;
}
body .my-video-title-class span {
  display: inline-block;
  transform: rotate(-15deg);
  color:red;
}
</style>
[ma-gdpr-vimeo video="814361316" title-text="Unsere Erde <span>mit Wolken</span>" title-class="my-video-title-class"]
Unsere Erde mit Wolken
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

play-button

Das Snippet stellt verschiedene Play Button Typen zur Verfügung.
Standard ist der Button Type vimeo. Dieser braucht nicht explizit angegeben zu werden.
Über den Parameter play-button kann man den gewünschten Button auswählen:

Button Type vimeo

[ma-gdpr-vimeo video="814361316" play-button="vimeo"]

Button Typ circle

[ma-gdpr-vimeo video="814361316" play-button="circle"]

Button Typ circle-o

[ma-gdpr-vimeo video="814361316" play-button="circle-o"]

Button Typ play

[ma-gdpr-vimeo video="814361316" play-button="play"]

play-button-color

Die Farbe des Vimeo Play Buttons ist schwarz. Die anderen Button Typen haben weiß als Standardfarbe.
Mit dem Parameter play-button-color kann eine abweichende Farbe für den Button festgelegt werden.

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
[ma-gdpr-vimeo video="814361316" play-button-color="blue"]
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
[ma-gdpr-vimeo video="814361316" play-button="circle" play-button-color="green"]
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.
[ma-gdpr-vimeo video="814361316" play-button="circle-o" play-button-color="black"]

play-button-class

Mit dem Parameter play-button-class kann eine eigene CSS Klasse für den Play Button verwendet werden.
Ein grüner Play Button auf der Seite zu "Grüner Energie" (Post ID 2336)? Kein Problem!

Beispiel:

<style>
body.postid-2336 .my-play-button {
  color:green;
}
</style>
[ma-gdpr-vimeo video="814361316" play-button-class="my-play-button"]
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

new-window

Der Parameter new-window öffnet das Video in einem neuen Browser-Tab direkt auf der Vimeo Seite.
Dies ist eventuell wünschenswert, wenn man in WordPress nur sehr kleine Vorschaubilder der Videos anzeigen will, die für eine direkte Einbindung des Players ungeeignet sind.

[ma-gdpr-vimeo video="814361316" new-window=1]

Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

Globale Parameter

Alle Shortcode Parameter können global definiert werden.
Das erleichtert die einheitliche Gestaltung der Videos und erspart die Definition der Parameter für jedes einzelne Video.

Dazu wird eine globale Variable $GLOBALS['ma_gdpr_vimeo'] als Array angelegt und mit den gewünschten Einstellungen versorgt.
Das geht recht einfach mit einem weiteren Code Snippet mit z.B. folgendem Inhalt:

$GLOBALS['ma_gdpr_vimeo'] = [
 'play-button' => 'play',
 'play-button-class' => 'my-play-button',
];

Eine Sonderlösung gilt hierbei für den Parameter gdpr-text, der sprach-sensitiv ist.
Die Angabe eines globalen Parameters gdpr-text setzt den Hinweis global auf einen einheitlichen Wert.
Die Ergänzung um ein Sprachkennzeichen steuert die Text-Ausgabe je Sprache.

$GLOBALS['ma_gdpr_vimeo'] = [
 'gdpr-text' => 'Read the {privacy-policy-link}.',
 'gdpr-text-de' => 'Info zum <a href="{privacy-policy-url}" target="_blank">Datenschutz</a>',
];

Weitere Vimeo Parameter

Vimeo bietet selbst noch weitere Parameter für die Steuerung der Video-Wiedergabe und das Aussehen des Players an.
Eine Übersicht über die verfügbaren Parameter gibt es hier: https://developer.vimeo.com/api/oembed/videos
Aktuell werden diese Parameter nicht unterstützt, aber bei Nachfrage und Bedarf gegebenenfalls noch implementiert.

Vimeo Player

Bei Klick auf ein Video wird ein iFrame aktiviert, der das Video von den Vimeo Servern lädt.
Alle von Vimeo gewohnten Funktionen sind hier verfügbar.
Der Player pausiert automatisch ein bereits laufendes Video, wenn ein anderes Video gestartet wird. So laufen nicht zwei Videos gleichzeitig.

Vimeo Metadaten

Das Snippet lädt automatisch diverse Metadaten zu dem eingebetteten Video von Vimeo.
Dazu gehören der Video-Titel, Autor und Beschreibung.
Diese Metadaten können per Platzhalter zur Attributierung des Videos verwendet werden.
Unterstützt werden derzeit die Platzhalter @title@, @author_name@, @description@ in den Parametern alt, title und title-text.

Beispiel:

[ma-gdpr-vimeo video="814361316" title="@title@" title-text="@title@ by @author_name@<br>@description@" alt="@title@"]
Orbiting Earth
Orbiting Earth by Matthias Altmann
Created with Fusion, 2017
Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe Datenschutzerklärung.

Hinweise zu Page Buildern

Oxygen Builder

Beim Schließen von Oxygen Modals wird das Attribut src von IFrames zurückgesetzt, um das Video zu stoppen. 
Oxygen implementiert dies durch this.src = this.src
Dadurch werden gleichzeitig sämtliche Event Handler dieses Snippets entfernt.
Wenn ein Modal geschlossen wird, wird das Video gestoppt, aber das Snippet verliert jegliche Kontrolle über den Status des Videos.
Ab diesem Zeitpunkt kann das Snippet nicht mehr automatisch Videos stoppen, wenn ein neues Video gestartet wird.

Bricks

Beim Einfügen eines Shortcode Elements in Bricks wird der Shortcode schon während des Tippens ausgewertet und das Ergebnis dargestellt, auch wenn der Shortcode noch nicht vollständig ist.

Wenn also ein Shortcode mit Video ID 347119375 eingetippt wird, erfolgt eine Auswertung für Video ID 3, dann 34, dann 347 ... usw.
Bei jeder dieser Auswertungen prüft das Snippet, ob das Video mit dieser (unvollständigen) ID existiert, und erstellt Ordner 3, 34, 347 usw., jeweils mit einer JSON Datei, die die Info über ein nicht existierendes Video enthält.

Ich konnte bisher keine akzeptable Lösung für dieses Verhalten finden.

FAQ – Häufig gestellte Fragen

Safari bietet eine Einstellung "Automatische Wiedergabe" für Medien.
Ist diese Einstellung auf "Deaktivieren" oder "Für Medien mit Ton deaktivieren" gestellt, wird der automatische Start des Videos unterdrückt.
Leider ist mir keine Möglichkeit bekannt, diese Sperre zu umgehen.

Ja, mittels SVG Code.
Dazu brauchen wir eine SVG Symbol Definition. Füge am Ende Deiner Seite einen Code Block ein:
(In Gutenberg als HTML, in Oxygen als Code Block)

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;">
  <defs>
    <symbol id="ma-gdpr-vimeo-play-button-custom" viewBox="0 0 20 20">
      <path fill="currentColor" d="M5 20c-0.128 0-0.256-0.049-0.354-0.146-0.195-0.195-0.195-0.512 0-0.707l8.646-8.646-8.646-8.646c-0.195-0.195-0.195-0.512 0-0.707s0.512-0.195 0.707 0l9 9c0.195 0.195 0.195 0.512 0 0.707l-9 9c-0.098 0.098-0.226 0.146-0.354 0.146z"/>
    </symbol>
  </defs>
</svg>

Bei der ID gibst Du nach ma-gdpr-vimeo-play-button- den gewünschten Bezeichner, z.B. custom, für Deinen eigen Play Button an.
Der <path /> ist der SVG Code für Deinen eigenen Button. Wenn Du hier fill="currentColor" angbibst, kannst Du die Farbe des Buttons mit dem Shortcode Parameter play-button-color steuern.
Bitte passe auch die viewBox an Deinen SVG Code an.

Bei Deinem Shortcode kannst Du jetzt mit dem Parameter play-button="custom" Deinen eigenen Button auswählen.

Download

Dieser JSON-Download kann direkt in die Plugins Code Snippets oder Advanced Scripts importiert werden. Vergiss nicht, das Snippet nach dem Import zu aktivieren.
Falls Du ein anderes Snippet Plugin verwendest, kannst Du stattdessen den Source Code kopieren und damit selbst ein neues Snippet anlegen.

ma-gdpr-vimeo.code-snippets.json
Version 1.3.0, 2024-04-28

Spenden

Es macht mir viel Freude, Code Snippets zu entwickeln und damit Anforderungen zu lösen. Die Snippets stelle ich kostenfrei zur Verfügung.

Wenn Du möchtest, kannst Du meine vielen Stunden Arbeit mit einer kleinen Kaffee-Spende über PayPal honorieren.

  Bei Klick auf den Button wird eine Verbindung zu PayPal aufgebaut.

Spenden werden selbstverständlich ordnungsgemäß durch mich versteuert.

Disclaimer

Das Code Snippet habe ich nach bestem Wissen und Gewissen getestet unter den im Source Code gelisteten Software Versionen.
Ich stelle das Code Snippet zur freien Verwendung zur Verfügung.
Eine Garantie für die Funktionalität in allen denkbaren WordPress Umgebungen kann ich nicht geben. 
Download und Nutzung dieses Code Snippets erfolgen auf eigene Gefahr und Verantwortung.

Change Log

Siehe "Version History" in Source Code

Source Code

<?php
/*
Plugin Name:	MA GDPR Vimeo
Description:	GDPR compliant Vimeo video embedding
Author:			<a href="https://www.altmann.de/">Matthias Altmann</a>
Project:		Code Snippet: GDPR Compliant Vimeo Embed
Version:		1.3.0
Plugin URI:		https://www.altmann.de/blog/code-snippet-gdpr-compliant-vimeo-videos/
Description:	en: https://www.altmann.de/blog/code-snippet-gdpr-compliant-vimeo-videos/
				de: https://www.altmann.de/blog/code-snippet-dsgvo-konforme-vimeo-videos/
Copyright:		© 2023-2024, Matthias Altmann


Notes:
Oxygen Builder
	Modals, on close, reset src attribute of iframes to stop running videos. 
	That is done by this.src = this.src, which also kills event handlers we assigned.
	If a modal is closed with a playing video, it's stopped, but we'll lose control 
	over it since we don't get any further events. 

TESTED WITH:
Product		Versions
--------------------------------------------------------------------------------------------------------------
PHP 		7.4, 8.1, 8.2
WordPress	6.4.2, 6.4.3, 6.5, 6.5.2
Bricks		1.9.5, 1.9.6, 1.9.7
Oxygen		4.8.1
--------------------------------------------------------------------------------------------------------------

Version History:
Date		Version		Description
--------------------------------------------------------------------------------------------------------------
2024-04-28	1.3.0		Reorganization of code base.
						New Features:
						- For better accessibility, the Enter key can now be used to start and stop videos.
						  (Thanks to Stephan Koenigk for his feature request and üre-release tests)
						Changes:
						- For invalid IDs, don't create directory, store json, attempt to retrieve thumbnails
						- In Builders Bricks and Oxygen, click handler is deactivated to allow selecting element
						- Preparation for Bricks Element. Coming soon.
2023-12-30	1.2.0		New Features:
						- Complete rebuild of JS
						- Now using Vimeo Player API to provide more control
						- Video player management via players registry and observer
							- Pause current video if another one is started
							- Pause video if modal/popup closed (evaluated by visibility of parent DOM element)
						- Support for dynamically embedded videos using AJAX calls:
							- Removed init prevention for AJAX calls
							- Added PHP method MA_GDPR_Vimeo::enable_footercode() to trigger output of footer
							  code (styles, scripts, svg) for video embeds dynamically loaded by AJAX calls
 							- Play click handler is now assigned as onclick event instead of collecting all 
							  videos after page load. This eliminates the need for an extra click handler 
							  initialization for players dynamically loaded after page load.  
						- Optimizations for accessibility 
						Fixes:
						- Prefixed wrapper/player IDs with snippet slug to prevent IDs starting with number
						- Fallback for aspect ratio via padding-top (CSS variable, @supports rule) 
						  for older browsers not supporting aspect-ratio like Safari < V15
2023-11-09	1.1.1		Fix: "Attempt to assign property 'notes' on null" error in get_video_details()
2023-04-04	1.1.0		New Feature: Pause handler for closed Oxygen Modals
2023-04-03	1.0.0		Initial Release
2023-04-02	0.0.0		Development start

Samples
https://vimeo.com/347119375	> Sample Video
https://vimeo.com/814361316 > Orbiting Earth

Documentation:
https://developer.vimeo.com/api/oembed/videos


*/


if (!class_exists('MA_GDPR_Vimeo')) :

class MA_GDPR_Vimeo {

	const TITLE		= 'MA GDPR Vimeo';
	const SLUG		= 'ma-gdpr-vimeo';
	const VERSION	= '1.3.0';

	// ===== CONFIGURATION ==============================================================================================
	/** Default width of the video block. Can be specified in %, px */
	public $default_width			= '100%';
	/** Default aspect ratio of the video block. Syntax X:X or X/X */
	public $default_aspect_ratio	= '16/9';
	/** GDPR notice text in different languages. */
	private $default_gdpr_text 		= [  
		'da' => ['Når du har trykket, vil videoen blive indlæst fra Vimeo\'s servere. Se vores %s for flere informationer.','privatlivspolitik'],
		'de' => ['Bei Klick wird dieses Video von den Vimeo Servern geladen. Details siehe %s.', 'Datenschutzerklärung'],
		'en' => ['When clicked, this video is loaded from Vimeo servers. See our %s for details.', 'privacy policy'],
		'es' => ['Al hacer clic, este vídeo se carga desde los servidores de Vimeo. Consulte la %s para más detalles.', 'política de privacidad'],
		'fi' => ['Klikattuasi, tämä video ladataan Vimeon palvelimilta. Katso lisätietoja meidän %s.', 'tietosuojaselosteesta'],
		'fr' => ['En cliquant, cette vidéo est chargée depuis les serveurs de Vimeo. Voir la %s.', 'politique de confidentialité'], 
		'hu' => ['Kattintás után ez a videó a Vimeo 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 Vimeo. Vedere %s per i dettagli.', 'l\'informativa sulla privacy'],
		'ja' => ['クリックすると、この動画が Vimeo サーバーから読み込まれます。詳細については、%s をご覧ください。', 'プライバシー ポリシー'],
	]; 
	/** Default font size for GDPR text */
	public $default_gdpr_text_size	= '.7em';
	/** Default open video in new window */
	private $default_new_window		= false;
	/** Enable timing info to WordPress debug.log if WP_DEBUG also enabled. 
	 * - false/0:	Disabled 
	 * - true/1: 	Enabled 
	 * - 2: 		Extended 
	 */
	public $timing					= false;

	// ===== INTERNAL. DO NOT EDIT. =====================================================================================
	private $incompatibilities 		= [];		// incompatibilities detected before initialization
	private $content_base			= null;		// will be set to the content base folder dir and url
	private $footercode_needed		= false;	// will be set to true if shortcode used on current page
	private $footercode_minimize 	= true;		// should we minimize all footer code (style, script, svg)?
	private $timing_total_runtime	= 0;
	private $urlformat_json_embed	= 'https://vimeo.com/api/oembed.json?url=https%%3A//vimeo.com/%s';
	private $urlformat_json_api		= 'http://vimeo.com/api/v2/video/%s.json';

	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Initialize Snippet: 
	 * - Add shortcode "ma-gdpr-youtube"
	 * - Register hook action for wp_footer to emit footer code (style, script)
	 * - Set flag to emit footer code (style, script) when in Oxygen Builder
	 */
	function __construct() {
		$st = microtime(true);
		$GLOBALS[__CLASS__] = $this;

		if (wp_doing_cron()) 		goto DONE;	// don't run for CRON requests

		if (WP_DEBUG && $this->timing>1) {error_log(sprintf('%s Initializing...',__CLASS__));}

		if (!defined('MA_GDPR_Vimeo_Version')) define('MA_GDPR_Vimeo_Version',self::VERSION);

		if ($this->is_incompatible()) return;

		// check content directory
		$this->content_base = $this->get_content_base();
		if (!$this->content_base) {return;}

		add_shortcode('ma-gdpr-vimeo', [$this, 'shortcode']);
		add_action('wp_footer',[$this,'footercode']);

		add_action('init', [$this, 'init_builder'], 50);

		DONE:
		// add a handler for logging total runtime
		add_action('shutdown', [$this, 'total_runtime']);
		
		$et = microtime(true);
		if (WP_DEBUG && $this->timing>1) {error_log(sprintf('  %s Initialization Timing %.5f s.',__CLASS__, $et-$st));}
		$this->timing_total_runtime += $et-$st;

		$this->timing_total_runtime += $et-$st;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Logs total timing for shortcodes on a page 
	 */
	public function total_runtime(){
		if (WP_DEBUG && $this->timing) {error_log(sprintf('%s Runtime: %.5f sec.', __CLASS__, $this->timing_total_runtime));}
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Checks for incompatibilities. Registers admin notice.
	 * @return bool 				`true` if any incompatibilities found
	 */
	private function is_incompatible(): bool{
		$incomp = [];
		if (!ini_get('allow_url_fopen')) {
			$incomp[] = 'PHP setting <code>allow_url_fopen</code> needs to be <b><On</b> for the '.$this->get_script_details()->type.' to work correctly.';
		}
		$this->incompatibilities = $incomp;
		if (count($incomp) && is_admin()) {
			add_action('admin_notices', function(){
				if (WP_DEBUG ) {error_log(self::TITLE.' Incompatibilities: '.implode(', ',$this->incompatibilities));}
				echo '<div class="notice notice-error is-dismissible">
						<p>The '.$this->get_script_details()->combined.' is skipped: '.implode(', ',$this->incompatibilities).'</p>
					</div>';
			});
		}	
		return count($incomp) ? true : false;	
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Init actions for Builder support.
	 */
	public function init_builder() {
		
		// OXYGEN - include footer code to support video preview
		if ( ($_GET['ct_builder']??null) == true) {
			// emit styles, script, svg when Oxygen Builder is active
			$this->footercode_needed = true;
		}
		
		// BRICKS - load Bricks element add-on (coming soon)
		if (defined('BRICKS_VERSION')) {
			foreach([__DIR__,$this->content_base->dir] as $module_dir) {
				$module_filepath = $module_dir.'/'.self::SLUG.'-bricks-element.php';
				if (file_exists($module_filepath)) {
					call_user_func('\Bricks\Elements::register_element', $module_filepath);
					break;
				}
			}
		}
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Return base dir/url for video content. 
	 * Create directory /wp-content/uploads/ma-gdpr-vimeo/ if necessary
	 * Rename from old scheme ma-gdpr-vimeo-thumbnails if exists
	 * @return object|null	A dirinfo object (->dir, ->url)
	 */
	private function get_content_base(): ?object {
		$retval = (object)['dir'=>null,'url'=>''];
		$content_dir_info = wp_get_upload_dir();
		$retval->dir = $content_dir_info['basedir'].'/'.self::SLUG;
		$retval->url = $content_dir_info['baseurl'].'/'.self::SLUG;
		// rename folder from old to new scheme 
		if (file_exists($retval->dir.'-thumbnails')) {
			@rename($retval->dir.'-thumbnails',$retval->dir);
		}
		// create content folder if not exists
		if (!file_exists($retval->dir)) {
			if (!@mkdir($retval->dir)) {
				add_action('admin_notices', function(){
					echo '<div class="notice notice-error"><p>['.self::TITLE.'] Error creating content base folder <code>wp-content/uploads/'.self::SLUG.'</code>.</p></div>';
				});
				error_log(sprintf('%s Error creating content base folder.', __CLASS__)); 
				return null;
			}
		}
		if (!is_writable($retval->dir)) {
			add_action('admin_notices', function(){
				echo '<div class="notice notice-error"><p>['.self::TITLE.'] Folder <code>wp-content/uploads/'.self::SLUG.'</code> is not writable. Please correct folder permissions.</p></div>';
			});
		}
		// create scheme-less URL
		$retval->url = preg_replace('/^https?\:/','',$retval->url);
		return $retval;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Return a link to the privacy policy page (if configured in WordPress) or just the passed text
	 * @param string $text	The text to return if privacy policy page is not defined in WordPress
	 * @return string		The HTML link element for the privacy policy page or the initial text
	 */
	private function get_privacy_policy_link(string $text='privacy policy'): string {
		$pplink = get_the_privacy_policy_link();
		return  $pplink ? $pplink : $text; 
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Parses a Vimeo URL for a video ID.
	 * Handles numerous URL formats, or plain video ID.
	 * @param string $s	The URL/string to parse
	 * @return array	Array containing key v (video ID), or empty
	 */
	private function parse_video_url(string $s=''): array {
		$st = microtime(true);
		$retval = [];
		// regex for parsing vimeo url variants
		$re = '/^
			(?:https?\:)?\/{2}														# protocol http, https, or schemeless
			(?:www\.)?																# optional www
			(?:(?:player\.)?vimeo\.com)\/											# domain variants
			(?:(?:video)[\/]?) ?													# video term
			([A-Za-z0-9\-\_]+)														# THE ID
		/x';
		// parse url
		if (preg_match($re,$s,$matches)) 							{$retval['v'] = $matches[1];}
		// id only?
		else if (preg_match('/^([A-Za-z0-9\-\_]+)$/',$s,$matches))	{$retval['v'] = $matches[1];}
	
		$et = microtime(true);
		if (WP_DEBUG && $this->timing>1) {error_log(sprintf('    %s("%s") => %s Timing: %.5f sec.', __METHOD__, $s, json_encode($retval), $et-$st));}
		return $retval;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/** 
	 * Get the image type as extension string from an integer value. Handles gif, jpg, pmg, webp.
	 * @param int $image_type 	The image type as int 
	 * @return string 			The image extension
	 */
	private function get_image_type_as_string(int $image_type): string {
		switch ($image_type) {
			case IMAGETYPE_GIF: 	return 'gif';
			case IMAGETYPE_JPEG:	return 'jpg';
			case IMAGETYPE_PNG:		return 'png';
			case IMAGETYPE_WEBP:	return 'webp';
			default:				return '';
		}
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Get type, width, height from an image file.
	 * @param string $filepath 	The image file path
	 * @return object 			The image info object: (bool) status, (string) type, (int) width, (int) height
	 */
	private function get_image_info(string $filepath): object {
		$retval = (object)[
			'status'	=> false,
			'type'		=> null,
			'width'		=> null,
			'height'	=> null,
		];
		if ($result = getimagesize($filepath)) {
			$retval->status = true;
			$retval->type 	= $this->get_image_type_as_string($result[2]);
			$retval->width	= $result[0];
			$retval->height	= $result[1];
		}
		return $retval;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Parses some info from an vimeo embed request result
	 * @param object $data	The embed request result
	 * @return object 		The info parsed 
	 */
	private function parse_embed_info(object $data): object {
		$retval = (object)[
			'embed_code' 	=> null,
			'app_id'		=> null,
			'title'			=> null,
			'status_code' 	=> null,
		];
		$html = $data->html??'';
		/* Sample: 
		<iframe src="https://player.vimeo.com/video/347119375?h=1699409fe2&app_id=122963&autoplay=1&color=ef2200&byline=0&portrait=0" 
			width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen title="Sample Video"></iframe>
		*/
		if (preg_match('/[\?&;]h=([\da-f]+)/',$html,$matches)) 		{$retval->embed_code = $matches[1];}
		if (preg_match('/[\?&;]app_id=([\da-f]+)/',$html,$matches))	{$retval->app_id = $matches[1];}
		if (preg_match('/title="([^"]+)"/',$html,$matches)) 		{$retval->title = html_entity_decode($matches[1]);}
		if ($data->domain_status_code??'') 							{$retval->domain_status_code = $data->domain_status_code;}
		return $retval;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Get details for video $id, containing e.g. thumbnails, if already downloaded. Load if not yet available.
	 * @param string $id	The video ID
	 * @return object		Object with (bool) status, (int) status_code, (string) status_text, (object) info, (object) thumbnails
	 */
	private function get_video_details(string $id): object {
		$st = microtime(true);
		$retval = (object)[
			'status' 		=> false,
			'notes'			=> [],
			'embed'			=> (object)['code'=>null,'text'=>null,'data'=> null],
			'api'			=> (object)['code'=>null,'text'=>null,'data'=> null],
			'thumbnails'	=> null,
		];
		if (!$this->content_base) 	{goto DONE;}

		$vid_dir = $this->content_base->dir.'/'.$id;
		$info_filepath = $vid_dir.'/'.$id.'.json';

		// read from cache if available
		if (file_exists($info_filepath)) {
			// read from cache
			$result = json_decode(@file_get_contents($info_filepath));
			// status OK and thumbnails available?
			if ($result && ($result->status??null) && ($result->thumbnails??[])) {
				$retval = $result;
				goto DONE;
			}
		}

		// retrieve from Vimeo, using current language
		$referer = ($_SERVER['HTTPS']??'' == 'on' ? 'https:' : 'http').'//'.($_SERVER['HTTP_HOST']??'');
		$http_request_headers = [
			'Accept-language: '.$this->get_current_language(),
			'Referer: '.$referer,
		];
		$options = ['http'=>['method'=>'GET','header'=>implode("\r\n",$http_request_headers)]];
		$context = stream_context_create($options);

		// get embed info, to get the embed code
		$url = sprintf($this->urlformat_json_embed, $id);
		$result = @file_get_contents($url, false, $context);
		// check status
		$http_status = $http_response_header[0];
		preg_match( "#HTTP/[0-9\.]+\s+([0-9]+)\s(.*)#",$http_status, $matches);
		$retval->embed->code = intval($matches[1]);
		$retval->embed->text = trim($matches[2]);
		if ($retval->embed->code == 200) {
			$retval->embed->data = json_decode($result);
			// parse embed info
			$parsed = $this->parse_embed_info($retval->embed->data);
			if (($parsed->domain_status_code??null) == 403) {
				$retval->embed->code = 403;
				$retval->embed->text = 'Forbidden';
			}
			if (($parsed->domain_status_code??null) == 404) {
				$retval->embed->code = 404;
				$retval->embed->text = 'Not Found';
			}
			$retval->parsed = $parsed;
		}
		if ($retval->embed->code != 200) {
			$note = sprintf('Video %s: %s', $id, $retval->embed->text);
			$retval->notes[] = $note;
			error_log('['.self::TITLE.'] '.$note); 
			goto DONE;
		}

		// get api info
		$url = sprintf($this->urlformat_json_api, $id);
		$result = @file_get_contents($url, false, $context);
		// check status
		$http_status = $http_response_header[0];
		preg_match( "#HTTP/[0-9\.]+\s+([0-9]+)\s(.*)#",$http_status, $matches);
		$retval->api->code = intval($matches[1]);
		$retval->api->text = trim($matches[2]);
		if ($retval->api->code == 200) {
			$retval->api->data = json_decode($result);
			// flatten if json is an array
			if (is_array($retval->api->data)) {$retval->api->data = $retval->api->data[0];}
		} else {
			$note = sprintf('Video %s API: %s', $id, $retval->api->text);
			$retval->notes[] = $note;
			error_log('['.self::TITLE.'] '.$note); 
		}
		
		$retval->status = (($retval->embed->code??null) == 200) && (($retval->api->code??null) == 200);

		// check thumbnails
		if ($retval->status && !($retval->thumbnails??null)) {

			// check/create directory for this video
			if (!file_exists($vid_dir)) {
				if (!@mkdir($vid_dir)) {
					$note = sprintf('Error creating cache folder for video %s.', $id);
					$retval->notes[] = $note;
					error_log('['.self::TITLE.'] '.$note); 
					goto DONE;
				}
			}

			$retval->thumbnails = (object)[];
			$img_path = $vid_dir.'/'.$id;
			foreach(['embed:url','api:small','api:medium','api:large'] as $slug) {
				list($obj,$tag) = explode(':',$slug,2);
				if ($img_url = $retval->{$obj}->data->{'thumbnail_'.$tag}??null) {
					// load and store thumbnail
					if ($img_data = @file_get_contents($img_url)) {
						$status = $http_response_header[0];
						if (!preg_match('/200\sOK/',$status)) continue;
						// store as tmp file to get image type from exif
						file_put_contents($img_path.'.tmp', $img_data);
						$img_info = $this->get_image_info($img_path.'.tmp');
						if ($img_info->status) {
							$img_spec = $img_info->width.'.'.$img_info->type;
							// store with correct filename
							if (@rename($img_path.'.tmp', $img_path.'_'.$img_spec)) {
								// update json
								$name = basename($img_path.'_'.$img_spec);
								$retval->thumbnails->{$img_info->width} = $name;
								@file_put_contents($info_filepath, json_encode($retval,JSON_PRETTY_PRINT));
							}
						}
					}
				}
			}
		}
		
		DONE:
		// save video details, even if not yet complete
		//@file_put_contents($info_filepath, json_encode($retval,JSON_PRETTY_PRINT));

		$et = microtime(true);
		if (WP_DEBUG && $this->timing>1) {error_log(sprintf('    %s("%s") Timing: %.5f sec.', __METHOD__, $id, $et-$st));}
		return $retval;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Get the current page/post get_current_language
	 * @return string		The language code as e.g. "de", "en"
	 */
	private function get_current_language(): string {
		$retval = get_locale();
		// Is a translation plugin active? Supporting Polylang, WPML 
		foreach (['pll_current_language','wpml_current_language'] as $func) {
			if (function_exists($func)) {$retval = $func(); break;}
		}
		$retval = str_replace('_','-',$retval);
		$retval = explode('-',$retval??'')[0];
		return $retval;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Handle the shortcode "ma-gdpr-vimeo". 
	 * @param array $atts		The shortcode attributes
	 * @param string $content	The content of the shortcode
	 * @return string			The output
	 */
	public function shortcode(array $shortcode_atts = [], string $content = ''): string {
		$st = microtime(true);
		$lang = $this->get_current_language();
		$retval = '';

		// get defaults for unspecified attributes
		$atts_default = [
			'slug'				=> self::SLUG,
			'video'				=> null,
			'uniqid'			=> null,
			'width'				=> $this->default_width,
			'aspect-ratio'		=> $this->default_aspect_ratio,
			'notice-class'		=> null,
			'notice-style'		=> null,
			'gdpr-text'			=> isset($this->default_gdpr_text[$lang]) 
									? sprintf($this->default_gdpr_text[$lang][0],$this->get_privacy_policy_link($this->default_gdpr_text[$lang][1])) 
									: sprintf($this->default_gdpr_text['en'][0],$this->get_privacy_policy_link($this->default_gdpr_text['en'][1])),
			'gdpr-text-size'	=> null,
			'alt'				=> null,
			'title'				=> null,
			'thumbnail'			=> null,
			'title-text'		=> null,
			'title-class'		=> null,
			'title-style'		=> null,
			'play-button'		=> 'vimeo', // currently included: vimeo, circle, circle-o, play
			'play-button-style'	=> null,
			'play-button-color'	=> null,
			'new-window'		=> $this->default_new_window,
		];

		// merge global settings
		$atts = array_merge($atts_default, $GLOBALS['ma_gdpr_vimeo']??[]);
		// choose correct language for gdpr-text
		if ($GLOBALS['ma_gdpr_vimeo']['gdpr-text-'.strtolower($lang)]??'') {
			$atts['gdpr-text'] = $GLOBALS['ma_gdpr_vimeo']['gdpr-text-'.strtolower($lang)];
		}
		// allow html in shortcode attributes title-text, gdpr-text
		foreach (['title-text','gdpr-text'] as $att) {
			if ($shortcode_atts[$att]??'') {$shortcode_atts[$att] = html_entity_decode($shortcode_atts[$att]);}
		}
		// merge shortcode attributes
		$atts = (object)array_merge($atts, $shortcode_atts);

		if ($atts->video) {
			$video = $this->parse_video_url($atts->video);
			if (isset($video['v'])) {
				$atts->id = $video['v'];
			}
		}

		// any other parameter will be passed to vimeo directly
		$vimeo_parameters = [];
		foreach ($atts as $att_key => $att_val) {
			if (!in_array($att_key,array_keys($atts_default))) {
				$vimeo_parameters[$att_key] = $att_val;
			} 
		}

		if (!isset($atts->id) || ($atts->id == '' )) 	{$retval = sprintf('[%s] Missing video id.',self::TITLE); goto DONE;}
		if (preg_match('/[^A-Za-z0-9\-\_]/',$atts->id))	{$retval = sprintf('[%s] Invalid video id.',self::TITLE); goto DONE;}
		// generate an unique id (for the case a video is embedded multiple times)
		$atts->uniqid = $atts->slug.'-'.uniqid();

		if (!$this->content_base) {$retval = sprintf('[%s] Content directory is not available.',self::TITLE); goto DONE;}
		// check if we already have a thumbnail
		$video_details = $this->get_video_details($atts->id);
		if (!$video_details->status) {$retval = sprintf('[%s] %s',self::TITLE, implode(', ',$video_details->notes)); goto DONE;}

		// insert vimeo attributes
		if ($video_details->status) {
			foreach (['alt','title','title-text'] as $attr) {
				foreach (['title','author_name','description'] as $info) {
					$atts->{$attr} = str_replace('@'.$info.'@', $video_details->embed->data->{$info}??'', $atts->{$attr}??'');
				}
			}
		}

		$thumbnail = '';
		$sources = [];
		$click_handler = 'onclick="ma_gdpr_vimeo.click(this)"';

		if ($atts->thumbnail) {
			if (is_numeric($atts->thumbnail)) {
				// numeric value => media id
				// get available image sizes
				$metadata = wp_get_attachment_metadata($atts->thumbnail);
				$image_sizes = [];
				// add original size
				$img_src = wp_get_attachment_image_src($atts->thumbnail,'full');	// '0': url, '1': width, '2': height, '3': resized
				$mime_type = get_post_mime_type($atts->thumbnail);
				$image_sizes['original'] = [
					'file'		=> $metadata['file'],
					'width'		=> $metadata['width'],
					'height'	=> $metadata['height'],
					'mime-type'	=> $mime_type,
					'filesize'	=> $metadata['filesize']??null,
					'url'		=> $img_src[0],
					'key'		=> 'full',
				];
				// add resized sizes
				foreach ($metadata['sizes'] as $key => $data) {
					// retrieve url for specific size
					$img_src = wp_get_attachment_image_src($atts->thumbnail,$key);	// '0': url, '1': width, '2': height, '3': resized
					$data['url'] = $img_src['0']; 
					$data['key'] = $key;
					$image_sizes[$key] = $data;
				}
				// sort image sizes by width ascending
				uasort($image_sizes, function($a,$b){
					if ($a['width'] == $b['width']) {return 0;}
					return ($a['width'] < $b['width']) ? -1 : 1;
				});

				// get largest image 
				$largest = (object)end($image_sizes);

				foreach ($image_sizes as $key => $data) {
					// to improve thumbnail quality, use higher res image if we reach half its size 
					$sources[] = '<source media="(min-width:'.($data['width']/2).'px)" type="'.$data['mime-type'].'" srcset="'.$data['url'].'">';
				}
				// create thumbnail
				$thumbnail .= sprintf('<picture class="ma-gdpr-vimeo-thumbnail" '.$click_handler.'>%s <img loading="lazy" src="%s" width="%s" height="%s" alt="%s" title="%s"></picture>',
									implode('',array_reverse($sources)), $largest->url, $largest->width, $largest->height, $atts->alt??'', $atts->title??'');
			} else {
				$sources = [];
				$thumbnail .= '<picture class="ma-gdpr-vimeo-thumbnail" '.$click_handler.'>';
				$thumbnail .= sprintf('<img loading="lazy" src="%s" alt="%s" title="%s">',$atts->thumbnail, $atts->alt, $atts->title);
				$thumbnail .= '</picture>';
			}
		} else {
			$thmbs = $video_details->thumbnails;
			foreach(get_object_vars($thmbs) as $size => $name) {
				$type = substr(strrchr($name,'.'),1);
				// to improve thumbnail quality, use higher res image if we reach half its size 
				$sources[] = '<source media="(min-width:'.($size/2).'px)" type="image/'.$type.'" srcset="'.$this->content_base->url.'/'.$atts->id.'/'.$name.'">';
			}
			// get smallest thumbnail as img src
			$thmbs = (array)$thmbs;
			$smallest = array_shift($thmbs);
			$img_src = $this->content_base->url.'/'.$atts->id.'/'.$smallest;
			$thumbnail .= '<picture id="'.$atts->uniqid.'-thumbnail'.'" class="ma-gdpr-vimeo-thumbnail" '.$click_handler.'>' . implode('',array_reverse($sources)) . '<img src="'.$img_src.'" alt="'.$atts->alt.'" title="'.$atts->title.'"></picture>';
		}

		// calculate dimensions of video block depending on width and aspect ratio
		list ($arw,$arh) = explode('/',str_replace(':','/',$atts->{'aspect-ratio'}),2); // aspect ratio elements
		list ($width_value, $width_unit) = ['100','%']; // default width value and unit
		// split width value and unit
		preg_match('/^(\d+)(.+)$/',$atts->width,$matches);
		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 url 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'});

		// title overlay
		$title_overlay = !empty($atts->{'title-text'})
			? sprintf('<div class="ma-gdpr-vimeo-title %1$s" %2$s>%3$s</div>',
				$atts->{'title-class'} ?? '',
				$atts->{'title-style'} ? 'style="'.$atts->{'title-style'}.'"' : '',
				$atts->{'title-text'}
				)
			: '';

		// play button style, color
		$play_button_style = '';
		if ($atts->{'play-button-style'}) {$play_button_style .= $atts->{'play-button-style'}.';';}
		if ($atts->{'play-button-color'}) {$play_button_style .= 'color:'.$atts->{'play-button-color'}.';';}
		if ($play_button_style) {$play_button_style = 'style="'.$play_button_style.'"';}

		// gdpr text size
		$gdpr_text_size = '';
		if ($atts->{'gdpr-text-size'}) {$gdpr_text_size = 'font-size:'.$atts->{'gdpr-text-size'}.';';}

		// vimeo iframe
		$content = <<<END_OF_CONTENT
		<iframe src="https://player.vimeo.com/video/{$video_details->api->data->id}?h={$video_details->parsed->embed_code}&app_id={$video_details->parsed->app_id}&autoplay=0&dnt=1" 
			style="position:absolute;top:0;left:0;width:100%;height:100%;" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
		<script src="https://player.vimeo.com/api/player.js"></script>
END_OF_CONTENT;


		$aspect_ratio		= str_replace(':','/',$atts->{'aspect-ratio'});
		$new_window			= $atts->{'new-window'};
		$vimeo_params		= count($vimeo_parameters) ? base64_encode(json_encode($vimeo_parameters)) : '';
		$play_button 		= $atts->{'play-button'} ?? '';
		$play_button_class	= $atts->{'play-button-class'} ?? '';
		$notice_class		= $atts->{'notice-class'} ?? '';
		$notice_style		= $atts->{'notice-style'} ?? '';
		$gdpr_text			= $atts->{'gdpr-text'};
		$content_b64		= base64_encode($content);

		// @since 1.2.0 width/height/aspect-ratio now set via CSS var for older browsers not supporting aspect-ratio, like e.g. Safari <V15
		$retval = <<<END_OF_HTML
		<div id="{$atts->uniqid}" data-video-id="{$atts->id}" class="ma-gdpr-vimeo-wrapper" style="--_width:{$block_width};--_height:{$block_height};--_aspect-ratio:{$aspect_ratio};" data-new-window="{$new_window}" data-parameters="{$vimeo_params}">
			{$thumbnail}
			<svg id="{$atts->uniqid}-button" class="ma-gdpr-vimeo-button button-{$play_button} {$play_button_class}" tabindex="0" role="button" aria-label="play video" {$play_button_style} {$click_handler}><use xlink:href="#ma-gdpr-vimeo-play-button-{$play_button}"></use></svg>
			{$title_overlay}
			<div class="ma-gdpr-vimeo-notice {$notice_class}" style="font-size:{$gdpr_text_size}; {$notice_style}">{$gdpr_text}</div>
			<div id="{$atts->uniqid}-content" class="ma-gdpr-vimeo-content" style="display:none">{$content_b64}</div>
		</div>
END_OF_HTML;
		$this->footercode_needed = true;

		DONE:
		$et = microtime(true);
		if (WP_DEBUG && $this->timing>1) {error_log(sprintf('  %s(%s) Timing: %.5f sec.', __METHOD__, json_encode($shortcode_atts), $et-$st));}
		$this->timing_total_runtime += $et-$st;
		return $retval;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Enables emission of footercode (styles, scripts, svg) for the video embed. 
	 * Can be used for pages where video is embedded dynamically by an AJAX call.
	 * On the parent page, call MA_GDPR_Vimeo::enable_footercode()
	 * @since 1.2.0
	 */
	public static function enable_footercode() {
		$GLOBALS[__CLASS__]->footercode_needed = true;
	}
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Emits the footer code (styles, script, svg) to handle the Vimeo embedding
	 */
	public function footercode() {
		$st = microtime(true);

		if (!$this->footercode_needed) {goto DONE;}

		// debugging info
		echo sprintf('<span id="%2$s-info" data-nosnippet style="display:none">%1$s %2$s %3$s</span>', $this->get_script_details()->type, self::SLUG, self::VERSION); 
		
		// emit style
		// @since 1.2.0: @supports rule provides wrapper height for older browsers not supporting aspect-ratio, like e.g. Safari <V15
		$style = <<<'END_OF_STYLE'
		<style id="ma-gdpr-vimeo-style">
			.ma-gdpr-vimeo-wrapper {position:relative; display:flex; isolation:isolate; width:var(--_width);aspect-ratio:var(--_aspect-ratio);}
			@supports not (aspect-ratio:1/1) {.ma-gdpr-vimeo-wrapper{height:var(--_height);padding-top:var(--_height);}}
			.ma-gdpr-vimeo-thumbnail {position:absolute; z-index:1; top:0; left:0; width:100%; height:100%; display:flex; cursor:pointer; }
			.ma-gdpr-vimeo-thumbnail img {width:100%; height:100%; object-fit:cover; object-position:50% 50%;}
			.ma-gdpr-vimeo-button {position:absolute; z-index:4; top:50%; left:50%; transform:translate(-50%,-50%); width:70px; height:70px; cursor:pointer; color:white;}
			.ma-gdpr-vimeo-button.button-vimeo {color:black; filter:drop-shadow(0px 0px 4px darkgray);}
			.ma-gdpr-vimeo-button.button-circle {filter:drop-shadow(0px 0px 4px darkgray);}
			.ma-gdpr-vimeo-button.button-circle-o {filter:drop-shadow(0px 0px 4px darkgray);}
			.ma-gdpr-vimeo-notice {position:absolute; z-index:2; 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-vimeo-notice:empty {display:none;}
			.ma-gdpr-vimeo-title {position:absolute; z-index:3; width:100%; top:1em; padding:0 1em; color:white; text-shadow: black 1px 1px 2px;}
		</style>
END_OF_STYLE;
		if ($this->footercode_minimize) { 
			$style = preg_replace('/\/\*.*?\*\//','',$style); 
			$style = preg_replace('/\r?\n */','',$style); 
			$style = preg_replace('/\t/','',$style); 
		}
		echo $style;

		// emit code
		// Vimeo Player API: https://developer.vimeo.com/player/sdk/embed
		$script = <<<'END_OF_SCRIPT'
		<script id="ma-gdpr-vimeo-script" type="text/javascript">
		"use strict";

		/* Check for Builder preview panes */
		if (
			(typeof window.parent?.bricksData?.wpEditor != 'undefined') /* Bricks*/
		|| 	(window.parent?.angular) /* Oxygen */
		) { 
			/* Dummy Object w/o functionality for Bricks Builder */ 
			window.ma_gdpr_vimeo = {
				init: function(){},
				click: function($target){},
			}
		}
		
		if ((typeof window.ma_gdpr_vimeo == 'undefined')){
			window.ma_gdpr_vimeo = {
				debug: (new URLSearchParams(window.location.search)).get('ma-gdpr-vimeo-debug')!=null,
				player_observer_timer: 1000,
				player_observer_interval: null,
				players: {}, /* list of active players */
				last_played: null,

				init: function(){
					this.debug && console.log('MA GDPR Vimeo initialized.');
				},

				player_observer_init: function(){
					if (this.player_observer_interval == null) {
						this.player_observer_interval = setInterval(ma_gdpr_vimeo.player_observer, this.player_observer_timer);
						this.debug && console.log('MA GDPR Vimeo Player Observer initialized.');
					}
				},

				player_observer: function() {
					/* pauses video if iframe is not visible anymore - e.g. closed modal */
					ma_gdpr_vimeo.debug && console.log('MA GDPR Vimeo Player Observer (entries: '+Object.values(ma_gdpr_vimeo.players).length+')');
					for (let $playerID in ma_gdpr_vimeo.players) {
						let $player = ma_gdpr_vimeo.players[$playerID];
						ma_gdpr_vimeo.debug && console.log('MA GDPR Vimeo Player Observer checking player '+$playerID+'...');
						
						$player.player.getPaused().then(function ($paused) {
							let $state = $paused ? 'STOP' : 'PLAY';
							ma_gdpr_vimeo.debug && console.log('MA GDPR Vimeo Player Observer got status of video '+$playerID+' as '+$state); 
							if ($state=='PLAY') {
								ma_gdpr_vimeo.last_played = $playerID;
								let $iframe = document.getElementById($playerID);
								if ($iframe) {
									ma_gdpr_vimeo.debug && console.log('MA GDPR Vimeo Player Observer checking iframe offsetParent '+$iframe.offsetParent);
									if (($iframe.offsetParent === null)) {
										ma_gdpr_vimeo.debug && console.log('MA GDPR Vimeo Player Observer pauses hidden video '+$playerID);
										$player.player.pauseVideo();
									}
								}
							}
						});
					}
				},
				player_stop_all_except: function($apiID) {
					/* loop through players and pause all except this one */
					ma_gdpr_vimeo.debug && console.log('Looping players', ma_gdpr_vimeo.players);
					for (let $playerID in ma_gdpr_vimeo.players) {
						ma_gdpr_vimeo.debug && console.log('Checking player '+$playerID);
						if ($playerID != $apiID) {
							ma_gdpr_vimeo.debug && console.log('Pausing player '+$playerID);
							ma_gdpr_vimeo.players[$playerID].player.pause().then(function() {
								ma_gdpr_vimeo.debug && console.log('Paused player '+$playerID);
							}).catch(function($error) {
								console.log('error: '+$error.name);
							});
						}
					}
				},

				click: function($target){
					let $wrapper = $target.closest('.ma-gdpr-vimeo-wrapper'); 
					if (!$wrapper) return;
					if ($wrapper.getAttribute('data-new-window') == '1') {
						/* get the video id from the wrapper */
						let $video_id = $wrapper.getAttribute('data-video-id');
						window.open('https://vimeo.com/'+$video_id);
						return;
					}
					/* initialize the player observer */
					ma_gdpr_vimeo.player_observer_init();

					/* replace wrapper content with video iframe */
					let b64 = $wrapper.querySelector('.ma-gdpr-vimeo-content').innerText;
					let content = decodeURIComponent(atob(b64).split('').map(function(c) {
						return '%'+('00'+c.charCodeAt(0).toString(16)).slice(-2);
					}).join(''));
					$wrapper.innerHTML = content;

					/* check if Vimeo API has already been loaded */
					if (document.querySelectorAll('#ma-gdpr-vimeo-player-api').length==0 ) {
						/* load the Vimeo API */
						ma_gdpr_vimeo.debug && console.log('Loading Vimeo API...');
						let $script = document.createElement('script');
						$script.id = 'ma-gdpr-vimeo-player-api';
						$script.src = 'https://player.vimeo.com/api/player.js';
						/* handler for Vimeo API loaded */
						$script.onload = function ($script) {
							ma_gdpr_vimeo.debug && console.log('Vimeo API loaded.');
							ma_gdpr_vimeo.video_start($wrapper);
						};
						document.body.appendChild($script);
					} else {
						/* Vimeo API is already loaded */
						ma_gdpr_vimeo.video_start($wrapper);
					}

				},

				video_start: function($wrapper) {
					/* get the video id from the parent div's id attribute */
					let $videoID = $wrapper.getAttribute('data-video-id');
					let $playerID = $wrapper.getAttribute('id');
					ma_gdpr_vimeo.debug && console.log('Starting video '+$videoID+' from wrapper '+$playerID);
					/* get the inner dimensions of the wrapper */
					const $wrapperWidth = window.getComputedStyle($wrapper).width;
					const $wrapperHeight = window.getComputedStyle($wrapper).height;
					ma_gdpr_vimeo.debug && console.log('Video WxH',[$wrapperWidth,$wrapperHeight]);

					/* remove styles from wrapper */
					$wrapper.style.height = $wrapperHeight;
					$wrapper.style.padding = 'unset';

					/* connect player */
					let $iframe = document.querySelector('#'+$playerID+' iframe');
					let $player = new Vimeo.Player($iframe);
					ma_gdpr_vimeo.players[$playerID] = {player:$player};

					/* handler to stop other videos */
					$player.on('play', function($data) {
						let $playerID = this.element.offsetParent.getAttribute('id');
						ma_gdpr_vimeo.debug && console.log('Event play', this, $playerID, $data);
						ma_gdpr_vimeo.player_stop_all_except($playerID);
					});
					/* now start video */
					$player.play();
					ma_gdpr_vimeo.last_played = $playerID;
				},
			};
			ma_gdpr_vimeo.init();
		}

		/* Accessibility: Handle Space or Enter as play click */
		document.querySelectorAll('.ma-gdpr-vimeo-button').forEach( ($elm) => {
			$elm.addEventListener('keyup', function($event) {
				if ($event.key==='Enter') {
					$event.preventDefault();
					$event.stopPropagation();
					$event.target.parentNode.querySelector('.ma-gdpr-vimeo-thumbnail').click();
				}
			});
		});
		/* Accessibility: Handle Space or Enter on BODY for playing or stopped video */
		document.addEventListener('keyup', function($event) {
			if (($event.key==='Enter') && ($event.target?.tagName==='BODY')) {
				ma_gdpr_vimeo.debug && console.log('Last played: '+ma_gdpr_vimeo.last_played);
				if (!ma_gdpr_vimeo.last_played) return;
				$event.preventDefault();
				$event.stopPropagation();
				ma_gdpr_vimeo.players[ma_gdpr_vimeo.last_played].player.getPaused().then(function($paused){
					if ($paused) {
						ma_gdpr_vimeo.debug && console.log('Start playing '+ma_gdpr_vimeo.last_played);
						let $player = ma_gdpr_vimeo.players[ma_gdpr_vimeo.last_played];
						$player && $player.player && (typeof $player.player.play!=='undefined') && $player.player.play();
					} else {
						ma_gdpr_vimeo.debug && console.log('Stopping all');
						ma_gdpr_vimeo.player_stop_all_except('');
					}
				});
			}
		});

		</script>
END_OF_SCRIPT;
		if ($this->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-vimeo-symbols" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
					aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;">
			<defs>
				<symbol id="ma-gdpr-vimeo-play-button-vimeo" viewBox="0 0 500 350" >
					<path fill="currentColor" 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"/>
				</symbol>
				<symbol id="ma-gdpr-vimeo-play-button-circle" viewBox="0 0 24 28" >
					<path fill="currentColor" d="M12 2c6.625 0 12 5.375 12 12s-5.375 12-12 12-12-5.375-12-12 5.375-12 12-12zM18 14.859c0.313-0.172 0.5-0.5 0.5-0.859s-0.187-0.688-0.5-0.859l-8.5-5c-0.297-0.187-0.688-0.187-1-0.016-0.313 0.187-0.5 0.516-0.5 0.875v10c0 0.359 0.187 0.688 0.5 0.875 0.156 0.078 0.328 0.125 0.5 0.125s0.344-0.047 0.5-0.141z"/>
				</symbol>
				<symbol id="ma-gdpr-vimeo-play-button-circle-o" viewBox="0 0 24 28" >
					<path fill="currentColor" d="M18.5 14c0 0.359-0.187 0.688-0.5 0.859l-8.5 5c-0.156 0.094-0.328 0.141-0.5 0.141s-0.344-0.047-0.5-0.125c-0.313-0.187-0.5-0.516-0.5-0.875v-10c0-0.359 0.187-0.688 0.5-0.875 0.313-0.172 0.703-0.172 1 0.016l8.5 5c0.313 0.172 0.5 0.5 0.5 0.859zM20.5 14c0-4.688-3.813-8.5-8.5-8.5s-8.5 3.813-8.5 8.5 3.813 8.5 8.5 8.5 8.5-3.813 8.5-8.5zM24 14c0 6.625-5.375 12-12 12s-12-5.375-12-12 5.375-12 12-12 12 5.375 12 12z"/>
				</symbol>
				<symbol id="ma-gdpr-vimeo-play-button-play" viewBox="0 0 24 28" >
					<path fill="currentColor" d="M21.625 14.484l-20.75 11.531c-0.484 0.266-0.875 0.031-0.875-0.516v-23c0-0.547 0.391-0.781 0.875-0.516l20.75 11.531c0.484 0.266 0.484 0.703 0 0.969z"/>
				</symbol>
			</defs>
		</svg>
END_OF_SYMBOL;
		if ($this->footercode_minimize) { $symbol = preg_replace('/\r?\n[\t ]*/','',$symbol); }
		echo $symbol;

		DONE:
		$et = microtime(true);
		if (WP_DEBUG && $this->timing>1) {error_log(sprintf('%s() Timing: %.5f sec.', __METHOD__, $et-$st));}
		$this->timing_total_runtime += $et-$st;
	}

	//===================================================================================================================
	// UTILS
	//===================================================================================================================
	//-------------------------------------------------------------------------------------------------------------------
	/**
	 * Returns the script details as type, version, combined.
	 * @return object	The script  type and version:
	 * - `type`: The type as 'Plugin' or 'Code Snippet'.
	 * - `version`: The version.
	 * - `combined`: The combination of type and version.
	 * - `full`: The combination of type, title and version
	 */
	public function get_script_details() {
		$type = basename(__FILE__) == 'ma-gdpr-vimeo.php' ? 'Plugin' : 'Code Snippet';
		$retval =(object)[
			'type'		=> $type,
			'version'	=> self::VERSION,
			'combined'	=> sprintf('%s %s', $type, self::VERSION),
			'full'		=> sprintf('%s "%s" %s', $type, self::TITLE, self::VERSION),
		];
		return $retval;;
	}


}


//===================================================================================================================
// Initialize
new MA_GDPR_Vimeo();
	
endif;
Erstveröffentlichung: 03.04.2023 auf Code Snippet: DSGVO-konforme Vimeo Videos
magnifier