diff --git a/Classes/Controller/YoutubeController.php b/Classes/Controller/YoutubeController.php index 621ed4de9d6334c30d73b001c7ba457a28b4db71..798849edf836fc88f8f7c0a39d2ff248d4ac1924 100644 --- a/Classes/Controller/YoutubeController.php +++ b/Classes/Controller/YoutubeController.php @@ -35,6 +35,7 @@ use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection; use TYPO3\CMS\Core\Resource\FileReference; use TYPO3\CMS\Core\Resource\FileRepository; use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\VersionNumberUtility; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Service\ImageService; @@ -76,6 +77,7 @@ class YoutubeController extends ActionController { $aspectRatio = $this->settings['aspectRatio'] ?? '16:9'; $showApiResult = (bool) ($this->settings['showApiResult'] ?? FALSE); $queryString = $this->settings['queryString'] ?? ''; + $isShorts = (bool) ($this->settings['isShorts'] ?? FALSE); $debugOutput = ''; $youtubeParameters = [ @@ -88,6 +90,7 @@ class YoutubeController extends ActionController { foreach ($filterIds as &$filterId) { $filterId = trim($filterId, ' '); } + unset($filterId); // Get input values @@ -150,7 +153,8 @@ class YoutubeController extends ActionController { $id, $aspectRatio, $thumbnailType, - $apiKey + $apiKey, + $isShorts ); $jsonArray['items'] = $this->mapJsonArrayWithPossibleCustomThumbnails($jsonArray['items']); diff --git a/Classes/Preview/PreviewService.php b/Classes/Preview/PreviewService.php index bfff77cabaf93b60c1bcfe259eb6f937e5c88c65..8abce0e8d27a8253096d40caeefd89fdaa284332 100644 --- a/Classes/Preview/PreviewService.php +++ b/Classes/Preview/PreviewService.php @@ -46,26 +46,28 @@ class PreviewService { $pluginConfiguration = GeneralUtility::xml2array( $row['pi_flexform'], 'T3DataStructure' - )['data']['sDEF']['lDEF']; + ); + + // Extract values from different sections of the Flexform, check for existence of 'sAppearance' and 'sBehavior' + $settingsDef = $pluginConfiguration['data']['sDEF']['lDEF'] ?? []; + // Fallback to sDEF if sAppearance doesn't exist + $settingsAppearance = $pluginConfiguration['data']['sAppearance']['lDEF'] ?? $settingsDef; + // Fallback to sDEF if sBehavior doesn't exist + $settingsBehavior = $pluginConfiguration['data']['sBehavior']['lDEF'] ?? $settingsDef; $templateData = [ - 'youtubeId' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.id'), - 'maxResults' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.maxResults'), - 'showTitle' => (int) ($this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.showTitle') ?? 1), - 'showDescription' => (int) ($this->passVDefOnKeyToTemplate( - $pluginConfiguration, - 'settings.showDescription' - ) ?? 1), - 'disableLightbox' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.disableLightbox'), - 'disableLightboxMobile' => $this->passVDefOnKeyToTemplate( - $pluginConfiguration, - 'settings.disableLightboxMobile' - ), - 'aspectRatio' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.aspectRatio'), - 'thumbnailType' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.thumbnailType'), - 'thumbnailImagesCount' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.thumbnailImages'), - 'showApiResult' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.showApiResult'), - 'urlParameters' => $this->passVDefOnKeyToTemplate($pluginConfiguration, 'settings.urlParameters'), + 'youtubeId' => $this->passVDefOnKeyToTemplate($settingsDef, 'settings.id'), + 'maxResults' => $this->passVDefOnKeyToTemplate($settingsDef, 'settings.maxResults'), + 'isShorts' => (int) ($this->passVDefOnKeyToTemplate($settingsDef, 'settings.isShorts') ?? 1), + 'showTitle' => (int) ($this->passVDefOnKeyToTemplate($settingsAppearance, 'settings.showTitle') ?? 1), + 'showDescription' => (int) ($this->passVDefOnKeyToTemplate($settingsAppearance, 'settings.showDescription') ?? 1), + 'disableLightbox' => (int) ($this->passVDefOnKeyToTemplate($settingsBehavior, 'settings.disableLightbox') ?? 1), + 'disableLightboxMobile' => (int) ($this->passVDefOnKeyToTemplate($settingsBehavior, 'settings.disableLightboxMobile') ?? 1), + 'aspectRatio' => $this->passVDefOnKeyToTemplate($settingsAppearance, 'settings.aspectRatio'), + 'thumbnailType' => $this->passVDefOnKeyToTemplate($settingsAppearance, 'settings.thumbnailType'), + 'thumbnailImagesCount' => $this->passVDefOnKeyToTemplate($settingsAppearance, 'settings.thumbnailImages'), + 'showApiResult' => $this->passVDefOnKeyToTemplate($settingsBehavior, 'settings.showApiResult'), + 'urlParameters' => $this->passVDefOnKeyToTemplate($settingsDef, 'settings.urlParameters'), 'header' => $row['header'], ]; diff --git a/Classes/Service/YoutubeService.php b/Classes/Service/YoutubeService.php index cd435a754cf4601a1037f64f449066424157f60c..06c8ad2533a2b24942c5cad12b926fda19842f63 100644 --- a/Classes/Service/YoutubeService.php +++ b/Classes/Service/YoutubeService.php @@ -75,6 +75,7 @@ class YoutubeService { * @param string $aspectRatio 16:9 (default) or 4:3 (ONLY used if byAspectRation is set as thumbnail type) * @param string $thumbnailType maxres, standard, high, medium, default, byAspectRatio (default) * @param string $apiKey + * @param bool $isShorts indicates whether the video is a "YouTube Shorts" video * @return array * @throws Exception */ @@ -83,7 +84,8 @@ class YoutubeService { $youtubeId = '', $aspectRatio = '16:9', $thumbnailType = 'byAspectRatio', - $apiKey = '' + $apiKey = '', + $isShorts = FALSE ): array { if (count($jsonArray) <= 0) { return $jsonArray; @@ -147,12 +149,16 @@ class YoutubeService { } } + $videoUrl = $isShorts + ? 'https://youtube.com/shorts/' . $field['id'] + : 'https://www.youtube.com/watch?v=' . $field['id']; + // Don't cache the preview URL. YouTube has a CDN and delivers much faster. $result[] = [ 'title' => $field['snippet']['title'], 'description' => strip_tags($field['snippet']['description']), 'thumbnail' => $previewImage['url'], - 'url' => 'https://www.youtube.com/watch?v=' . $field['id'], + 'url' => $videoUrl, 'publishedAt' => $field['snippet']['publishedAt'], ]; } diff --git a/Configuration/FlexForms/flexform_sgyoutube_youtube.xml b/Configuration/FlexForms/flexform_sgyoutube_youtube.xml index 95a19adb570c843b08f32fc885caacf41370b3b4..ba29196c4bc3c6dbc786053a0688aed290581056 100644 --- a/Configuration/FlexForms/flexform_sgyoutube_youtube.xml +++ b/Configuration/FlexForms/flexform_sgyoutube_youtube.xml @@ -25,6 +25,17 @@ <eval>trim,required</eval> </config> </settings.id> + <settings.isShorts> + <exclude>0</exclude> + <label>LLL:EXT:sg_youtube/Resources/Private/Language/locallang.xlf:flexform.isShorts</label> + <description> + LLL:EXT:sg_youtube/Resources/Private/Language/locallang.xlf:flexform.isShorts.description + </description> + <config> + <type>check</type> + <default>0</default> + </config> + </settings.isShorts> <settings.filterId> <exclude>0</exclude> <label>LLL:EXT:sg_youtube/Resources/Private/Language/locallang.xlf:flexform.filterIds diff --git a/Resources/Private/Language/de.locallang.xlf b/Resources/Private/Language/de.locallang.xlf index cdc8bb22105979c3e29e369c574746d718816bba..5e052af0a3fd4e58f10be75d573c1ed17d80edc6 100644 --- a/Resources/Private/Language/de.locallang.xlf +++ b/Resources/Private/Language/de.locallang.xlf @@ -137,6 +137,14 @@ <source><![CDATA[Specify as a list separated by commas (ABCD_12, EFGH_34).]]></source> <target><![CDATA[Als Liste durch Kommas getrennt angeben (ABCD_12, EFGH_34).]]></target> </trans-unit> + <trans-unit id="flexform.isShorts" resname="flexform.isShorts" approved="yes"> + <source><![CDATA[Video is a "YouTube shorts" video (https://www.youtube.com/intl/en_EN/creators/shorts/)]]></source> + <target><![CDATA[Video ist ein "YouTube Shorts"-Video (https://www.youtube.com/intl/de_ALL/creators/shorts/)]]></target> + </trans-unit> + <trans-unit id="flexform.isShorts.description" resname="flexform.isShorts.description" approved="yes"> + <source><![CDATA[Make sure to select a thumbnail type, with enough height and choose the 4:3 aspect ratio.]]></source> + <target><![CDATA[Stellen Sie sicher, dass Sie einen Thumbnail-Typ mit ausreichender Höhe und das Seitenverhältnis 4:3 wählen.)]]></target> + </trans-unit> <trans-unit id="flexform.queryString" resname="flexform.queryString" approved="yes"> <source><![CDATA[Query String]]></source> <target><![CDATA[Abfragetext]]></target> diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index db6d92a6d3d19a51da6a1ee2a8b263a89c5580d2..1cc3022bb1ef544ad12642210f1bec6d93860912 100644 --- a/Resources/Private/Language/locallang.xlf +++ b/Resources/Private/Language/locallang.xlf @@ -113,6 +113,12 @@ <trans-unit id="flexform.id" resname="flexform.id"> <source><![CDATA[ID of Channel (UC), Playlist (PL) or Single Video (youtube.com/watch?v=%ID%)]]></source> </trans-unit> + <trans-unit id="flexform.isShorts" resname="flexform.isShorts"> + <source><![CDATA[Video is a "YouTube shorts" video (https://www.youtube.com/intl/en_EN/creators/shorts/)]]></source> + </trans-unit> + <trans-unit id="flexform.isShorts.description" resname="flexform.isShorts.description"> + <source><![CDATA[Make sure to select a thumbnail type, with enough height and choose the 4:3 aspect ratio.]]></source> + </trans-unit> <trans-unit id="flexform.layout" resname="flexform.layout"> <source><![CDATA[Layout Style]]></source> </trans-unit> diff --git a/Resources/Private/Templates/Bootstrap5/Youtube/Index.html b/Resources/Private/Templates/Bootstrap5/Youtube/Index.html index b4ee1b63ea4d6490b27889ee87c948ebdf49d76c..86e2565936fdfdd8a3759e52a9d34ec5f7409688 100644 --- a/Resources/Private/Templates/Bootstrap5/Youtube/Index.html +++ b/Resources/Private/Templates/Bootstrap5/Youtube/Index.html @@ -163,9 +163,7 @@ <f:section name="videoItem"> <f:variable name="urlParameters">{f:if(condition: '{settings.urlParameters}', then: '{settings.urlParameters}', else: '{settings.globalUrlParameters}')}</f:variable> - <f:variable name="feedItemUrl"> - <vi:urlWithQueryParameters url="{feedItem.url}" parameters="{urlParameters}" /> - </f:variable> + <f:variable name="feedItemUrl"><vi:urlWithQueryParameters url="{feedItem.url}" parameters="{urlParameters}" /></f:variable> <f:variable name="feedItemId">{contentUid}-{settings.id}-{feedIterator}</f:variable> <f:variable name="hasText"><f:if condition="{settings.showTitle} || {settings.showDescription}">1</f:if></f:variable> <f:variable name="hasDescription"><f:if condition="{settings.showDescription} && {feedItem.description}">1</f:if></f:variable> @@ -175,7 +173,7 @@ <div class="sg-video__item {itemClasses}"> <f:if condition="{feedItem.thumbnail}"> - <a class="sg-video-item overflow-hidden text-light sg-video__link position-relative {f:if(condition: '!{hasText} || {isRowsLayout}', then: 'rounded')} {f:if(condition: '{isRowsLayout}', then: 'col-12 col-sm-3 shadow', else: 'card-img-top')}" href="{feedItemUrl}" data-disable-lightbox="{settings.disableLightbox}" target="_blank" data-disable-lightbox-mobile="{settings.disableLightboxMobile}" data-additional-url-parameters="{urlParameters}" data-video-type="youtube"> + <a class="sg-video-item overflow-hidden text-light sg-video__link position-relative {f:if(condition: '!{hasText} || {isRowsLayout}', then: 'rounded')} {f:if(condition: '{isRowsLayout}', then: 'col-12 col-sm-3 shadow', else: 'card-img-top')}" href="{feedItemUrl}" data-disable-lightbox="{settings.disableLightbox}" target="_blank" data-disable-lightbox-mobile="{settings.disableLightboxMobile}" data-additional-url-parameters="{urlParameters}" data-video-type="youtube" data-is-shorts="{settings.isShorts}"> <div class="sg-video__svg position-absolute top-50p start-50p translate-middle z-1"> <span class="sg-video__svg-inner d-flex shadow text-bg-black bg-opacity-50 rounded-circle justify-content-center p-2"> <vi:renderSvg color="currentColor" name="solid-play" width="24" height="24"></vi:renderSvg> @@ -185,10 +183,10 @@ <div class="overflow-hidden"> <f:if condition="{feedItem.thumbnailImageObject}"> <f:then> - <vi:picture class="sg-video__image object-fit-cover h-100 w-100" width="{thumbnailWidth}" height="{thumbnailHeight}" image="{feedItem.thumbnailImageObject}" alt="{feedItem.title}" /> + <vi:picture class="sg-video__image object-fit-cover h-100 w-100{f:if(condition: '{settings.isShorts}', then: ' sg-video__image--shorts')}" width="{thumbnailWidth}" height="{thumbnailHeight}" image="{feedItem.thumbnailImageObject}" alt="{feedItem.title}" /> </f:then> <f:else> - <vi:picture class="sg-video__image object-fit-cover h-100 w-100" width="{thumbnailWidth}" height="{thumbnailHeight}" image="{feedItem.thumbnail}" alt="{feedItem.title}" treatIdAsReference="TRUE" /> + <vi:picture class="sg-video__image object-fit-cover h-100 w-100{f:if(condition: '{settings.isShorts}', then: ' sg-video__image--shorts')}" width="{thumbnailWidth}" height="{thumbnailHeight}" image="{feedItem.thumbnail}" alt="{feedItem.title}" treatIdAsReference="TRUE" /> </f:else> </f:if> </div> diff --git a/Resources/Private/Templates/Youtube/Backend.html b/Resources/Private/Templates/Youtube/Backend.html index 06e529151acbccf053d6b01a2fba5343306333b2..66ba1b7aa8849af0176c572b934e50f339c6ad74 100644 --- a/Resources/Private/Templates/Youtube/Backend.html +++ b/Resources/Private/Templates/Youtube/Backend.html @@ -87,6 +87,15 @@ </td> </tr> + <tr> + <th scope="row"> + <f:translate key="flexform.isShorts" extensionName="SgYoutube"/> + </th> + <td> + <f:render partial="BooleanIcon" arguments="{boolValue: '{data.isShorts}'}"/> + </td> + </tr> + <tr> <th scope="row"> <f:translate key="flexform.thumbnailType" extensionName="SgYoutube"/> diff --git a/Resources/Public/JavaScript/Modules/sgVideoLightbox.js b/Resources/Public/JavaScript/Modules/sgVideoLightbox.js index 3a531e64c8226b76b5c4e0786d7de99f2cb9b205..5b6921c7eb51f90706b4d5b6765b71b7b94b0e9a 100644 --- a/Resources/Public/JavaScript/Modules/sgVideoLightbox.js +++ b/Resources/Public/JavaScript/Modules/sgVideoLightbox.js @@ -30,9 +30,10 @@ export default class SgVideoLightbox { */ static openLightbox(event) { event.preventDefault(); + const isShorts = event.target.closest('a')?.dataset?.isShorts === '1'; switch (event.target.closest('a')?.dataset?.videoType) { case 'youtube': { - SgVideoLightbox.openYouTubeLightBox(event); + SgVideoLightbox.openYouTubeLightBox(event, isShorts); break; } case 'vimeo': { @@ -51,9 +52,10 @@ export default class SgVideoLightbox { */ static disableLightbox(event) { event.preventDefault(); + const isShorts = event.target.closest('a')?.dataset?.isShorts === '1'; switch (event.target.closest('a')?.dataset?.videoType) { case 'youtube': { - SgVideoLightbox.disableYouTubeLightbox(event); + SgVideoLightbox.disableYouTubeLightbox(event, isShorts); break; } case 'vimeo': { @@ -91,9 +93,10 @@ export default class SgVideoLightbox { /** * Opens the lightbox with the youtube player (uses youtube-nocookie) * - * @param event + * @param {Event} event + * @param {boolean} isShorts */ - static openYouTubeLightBox(event) { + static openYouTubeLightBox(event, isShorts) { let url = event.target.closest('.sg-video-item').href; const videoId = SgVideoLightbox.getYouTubeVideoIdFromUrl(url); url = `https://www.youtube-nocookie.com/embed/${videoId}`; @@ -102,10 +105,12 @@ export default class SgVideoLightbox { event.target.closest('a').dataset.additionalUrlParameters, ); + const iframeClass = isShorts ? 'sg-video-iframe sg-video-youtube-iframe sg-video-youtube-shorts-iframe' : 'sg-video-iframe sg-video-youtube-iframe'; + const instance = BasicLightbox.create( ` <iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" - class="sg-video-iframe sg-video-youtube-iframe" src="${url}"></iframe> + class="${iframeClass}" src="${url}"></iframe> `, { closable: true, @@ -118,9 +123,11 @@ export default class SgVideoLightbox { /** * Replace the image with an iframe * - * @param event + * @param {Event} event + * @param {boolean} isShorts + * */ - static disableYouTubeLightbox(event) { + static disableYouTubeLightbox(event, isShorts) { event.preventDefault(); const item = event.currentTarget; item.classList.add('no-lightbox'); @@ -130,16 +137,25 @@ export default class SgVideoLightbox { item.dataset.additionalUrlParameters, ); const videoImage = item.querySelector('.sg-video__image'); - const height = videoImage.offsetHeight; - const width = videoImage.offsetWidth; + const originalWidth = videoImage.offsetWidth; + const originalHeight = videoImage.offsetHeight; + + // Calculate iframe dimensions + let iframeWidth = originalWidth; + let iframeHeight = originalHeight; + if (isShorts) { + // Maintain 9:16 aspect ratio for Shorts + iframeWidth = originalWidth; + iframeHeight = Math.round((iframeWidth / 9) * 16); + } const iframe = document.createElement('iframe'); - iframe.width = width; - iframe.height = height; + iframe.width = iframeWidth; + iframe.height = iframeHeight; iframe.style.border = 'none'; iframe.allowFullscreen = true; - iframe.allow = - 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; + iframe.classList.add(isShorts ? 'sg-video-youtube-shorts-iframe' : ''); + iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; iframe.src = `https://www.youtube-nocookie.com/embed/${videoId}`; if (videoImage.parentElement.nodeName.toLowerCase() === 'picture') { @@ -174,14 +190,14 @@ export default class SgVideoLightbox { .createRange() .createContextualFragment( `<div class="sg-video-item sg-card-shadow" ` + - `style="height: ${height}px; width: ${width}px;">` + - `<div class="embed-container" style="padding-bottom: calc(${height} / ${width} * 100%);">` + - `<iframe ` + - `width="${width}" height="${height}" ` + - `src="${iframeUrl}&dnt=1" frameborder="0" ` + - `allow="autoplay; fullscreen; picture-in-picture" allowfullscreen>` + - `</iframe>` + - `</div></div>`, + `style="height: ${height}px; width: ${width}px;">` + + `<div class="embed-container" style="padding-bottom: calc(${height} / ${width} * 100%);">` + + `<iframe ` + + `width="${width}" height="${height}" ` + + `src="${iframeUrl}&dnt=1" frameborder="0" ` + + `allow="autoplay; fullscreen; picture-in-picture" allowfullscreen>` + + `</iframe>` + + `</div></div>`, ); thumbnailElement?.replaceWith(nodes); } @@ -205,22 +221,18 @@ export default class SgVideoLightbox { * @return {string|null} */ static getYouTubeVideoIdFromUrl(url) { - let matches = url.match(/watch\?v=(.*)&list=(.*)/); + // Match standard YouTube URL or Shorts URL + const matches = url.match(/watch\?v=([^&?]*)(?:&list=([^&?]*))?|shorts\/([^&?/]+)/); if (!matches) { - // check if the list parameter is missing - matches = url.match(/watch\?v=([^&?]*)/); - if (!matches) { - return null; - } - } - let [, videoString] = matches; - let queryParameterSeparator = '?'; - if (matches[2]) { - videoString += `?list=${matches[2]}`; - queryParameterSeparator = '&'; + return null; } - return `${videoString + queryParameterSeparator}autoplay=1&rel=0`; + // Determine the video ID and construct the appropriate URL + const videoId = matches[1] || matches[3]; + const listParam = matches[2] ? `?list=${matches[2]}` : ''; + const autoplayParams = listParam ? '&autoplay=1&rel=0' : '?autoplay=1&rel=0'; + + return `${videoId}${listParam}${autoplayParams}`; } /** diff --git a/Resources/Public/Sass/Modules/Bootstrap5/_sg-video.scss b/Resources/Public/Sass/Modules/Bootstrap5/_sg-video.scss index db061a007bf0503c9f6d1c0ebe5463a82da1a7cf..0f281bdf227c2de13c71e47e831278f57fae0349 100644 --- a/Resources/Public/Sass/Modules/Bootstrap5/_sg-video.scss +++ b/Resources/Public/Sass/Modules/Bootstrap5/_sg-video.scss @@ -40,6 +40,14 @@ $basicLightbox__timing: ease !default; .sg-video__image { transition: transform 0.3s ease; + + &--shorts { + height: auto; + aspect-ratio: 9 / 16; + object-fit: cover; + + max-height: 900px; + } } .sg-video__svg { @@ -146,6 +154,18 @@ iframe.sg-video-iframe, aspect-ratio: 16/9; } +iframe.sg-video-youtube-shorts-iframe { + aspect-ratio: 9 / 16; + + &.sg-video-iframe { + width: 90vw; + max-width: 480px; + height: auto; + max-height: 900px; + margin: 0 auto; + } +} + .basicLightbox .sg-cookie-optin-iframe-consent { pointer-events: all; }