Skip to content

Commit

Permalink
✨(video_player) add lazy load video player option
Browse files Browse the repository at this point in the history
Add an option to lazy load iframe video player.
Instead of always loading the external video player,
it only loads the video player if the user clicks on the
big play ▶ icon.
  • Loading branch information
igobranco committed Jan 24, 2025
1 parent 5deda56 commit 964b360
Show file tree
Hide file tree
Showing 9 changed files with 239 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,13 @@ class Base(StyleguideMixin, DRFMixin, RichieCoursesConfigurationMixin, Configura
# Define which node level can be processed to search for pageindex extension
RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL = 0

# Wheither you want to show the video iframe directly or prefer to lazy load it
RICHIE_VIDEO_LAZY_LOAD = values.Value(
False,
environ_name="RICHIE_VIDEO_LAZY_LOAD",
environ_prefix=None,
)

# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
Expand Down
7 changes: 7 additions & 0 deletions sandbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,13 @@ class Base(StyleguideMixin, DRFMixin, RichieCoursesConfigurationMixin, Configura
# Define which node level can be processed to search for pageindex extension
RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL = 0

# Wheither you want to show the video iframe directly or prefer to lazy load it
RICHIE_VIDEO_LAZY_LOAD = values.Value(
False,
environ_name="RICHIE_VIDEO_LAZY_LOAD",
environ_prefix=None,
)

@classmethod
def _get_environment(cls):
"""Environment in which the application is launched."""
Expand Down
1 change: 1 addition & 0 deletions src/frontend/js/components/Icon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export enum IconTypeEnum {
TWITTER = 'icon-twitter',
UNIVERSITY = 'icon-univerity',
WARNING = 'icon-warning',
VIDEO_PLAY = 'icon-video-play',
}

export const Icon = ({ name, title, className = '', size = 'medium', ...props }: Props) => {
Expand Down
35 changes: 35 additions & 0 deletions src/frontend/scss/components/_subheader.scss
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,41 @@ $r-subheader-search-title-width: 19rem !default; // aligned on computed search r
position: relative;
padding-bottom: 56.25%; // Aspect ratio 16/9

.video-player-image {
img {
filter: brightness(0.85);
object-fit: cover;
}
img,
span {
position: absolute;
width: 100%;
top: 0;
bottom: 0;
margin: auto;
}
span {
text-align: center;
font: 48px/1.5 sans-serif;
fill: white;
display: flex;
justify-content: center;
align-items: center;
}
span svg {
transition: 0.5s;
width: 85px;
height: 85px;
}
img:hover,
span:hover svg {
fill-opacity: 1;
filter: drop-shadow(3px 3px 30px rgb(0 0 0 / 0.65));
}
span svg {
filter: drop-shadow(3px 3px 12px rgb(0 0 0 / 0.25));
}
}
iframe {
height: 100%;
position: absolute;
Expand Down
4 changes: 4 additions & 0 deletions src/richie/apps/core/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ def site_metas(request: HttpRequest):
settings.RICHIE_MINIMUM_COURSE_RUNS_ENROLLMENT_COUNT
)

context["RICHIE_VIDEO_LAZY_LOAD"] = getattr(
settings, "RICHIE_VIDEO_LAZY_LOAD", False
)

return context


Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,71 @@
{% load i18n cms_tags %}
{% load i18n cms_tags extra_tags thumbnail static %}
{% comment %}
This is a copy of original template from plugin just to clean <iframe> from
obsolete attribute "frameborder" and invalid "allowfullscreen" attribute value.

For performance reasons instead of loading the video iframe directly,
it changes the default template with an hidden iframe that is only visible
when the user clicks on the image with a big play icon '▶', the image comes
from the video poster or the course cover.
Only after the user clicks on the play icon '▶', the browser loads the external
video player iframe.
Additionaly, it tries to autoplay the external video player, this feature depends
on the browser and external video platform implementations.
{% endcomment %}

{% if instance.embed_link %}
{# show iframe if embed_link is provided #}
<div class="aspect-ratio">
<iframe
title="{% if instance.label %}{{ instance.label }}{% else %}{% trans "Video" %}{% endif %}"
src="{{ instance.embed_link_with_parameters }}"
{{ instance.attributes_str }}
allowfullscreen
></iframe>
{% if RICHIE_VIDEO_LAZY_LOAD %}
<a class="video-player-image" onclick="this.style.display='none'; this.nextSibling.style.display='block'; this.nextSibling.src=this.nextSibling.getAttribute('data-src');" href="javascript:void(0)" title='{% trans "Click to play video" %}' aria-label='{% trans "Click to play video" %}'>
{% if instance.poster %}
<img
src='{% thumbnail instance.poster.url 300x170 replace_alpha='#FFFFFF' crop upscale subject_location=instance.poster.subject_location %}'
srcset='
{% thumbnail instance.poster 300x170 replace_alpha='#FFFFFF' crop upscale subject_location=instance.poster.subject_location %} 300w
{% if instance.poster.width >= 600 %},{% thumbnail instance.poster 600x340 replace_alpha='#FFFFFF' crop upscale subject_location=instance.poster.subject_location %} 600w{% endif %}
{% if instance.poster.width >= 900 %},{% thumbnail instance.poster 900x510 replace_alpha='#FFFFFF' crop upscale subject_location=instance.poster.subject_location %} 900w{% endif %}
'
sizes='(max-width:62em) 100vw, 660px'
alt='{% if instance.poster.default_alt_text %}{{ instance.poster.default_alt_text }}{% else %}{% trans 'course cover image' %}{% endif %}'
/>
{% else %}
{% placeholder_as_plugins "course_cover" as cover_plugins %}
{% blockplugin cover_plugins.0 %}
<img
src='{% thumbnail instance.picture 300x170 replace_alpha='#FFFFFF' crop upscale subject_location=instance.picture.subject_location %}'
srcset='
{% thumbnail instance.picture 300x170 replace_alpha='#FFFFFF' crop upscale subject_location=instance.picture.subject_location %} 300w
{% if instance.picture.width >= 600 %},{% thumbnail instance.picture 600x340 replace_alpha='#FFFFFF' crop upscale subject_location=instance.picture.subject_location %} 600w{% endif %}
{% if instance.picture.width >= 900 %},{% thumbnail instance.picture 900x510 replace_alpha='#FFFFFF' crop upscale subject_location=instance.picture.subject_location %} 900w{% endif %}
'
sizes='(max-width:62em) 100vw, 660px'
alt='{% if instance.picture.default_alt_text %}{{ instance.picture.default_alt_text }}{% else %}{% trans 'course cover image' %}{% endif %}'
/>
{% endblockplugin %}
{% endif %}
<span>
<svg aria-hidden="true">
<use href="#icon-video-play" />
</svg>
</span>
</a>
<iframe
title="{% if instance.label %}{{ instance.label }}{% else %}{% trans "Video" %}{% endif %}"
data-src="{{ instance.embed_link_with_parameters}}{% if '?' not in instance.embed_link_with_parameters %}?{% endif %}&autoplay=1"
{{ instance.attributes_str }}
allowfullscreen
allow="autoplay"
style="display: none;"
></iframe>
{% else %}
<iframe
title="{% if instance.label %}{{ instance.label }}{% else %}{% trans "Video" %}{% endif %}"
src="{{ instance.embed_link_with_parameters }}"
{{ instance.attributes_str }}
allowfullscreen
></iframe>
{% endif %}
</div>
{% with disabled=instance.embed_link %}
{% for plugin in instance.child_plugin_instances %}
Expand All @@ -32,9 +85,9 @@

{% comment %}
# Available variables:
{{ instance.template }}
{{ instance.template }}
{{ instance.label }}
{{ instance.embed_link }}
{{ instance.poster }}
{{ instance.attributes_str }}
{% endcomment %}
{{ instance.attributes_str }}
{% endcomment %}
7 changes: 6 additions & 1 deletion src/richie/apps/core/templates/richie/icons.html
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@
<symbol id="icon-archive" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.2 3H4.8C3.9 3 3 3.81 3 4.8v2.709c0 .648.387 1.206.9 1.521V19.2c0 .99.99 1.8 1.8 1.8h12.6c.81 0 1.8-.81 1.8-1.8V9.03c.513-.315.9-.873.9-1.521V4.8c0-.99-.9-1.8-1.8-1.8Zm-5.4 10.8h-3.6a.903.903 0 0 1-.9-.9c0-.495.405-.9.9-.9h3.6c.495 0 .9.405.9.9s-.405.9-.9.9Zm5.4-6.3H4.8V4.8h14.4v2.7Z" fill="currentColor" fill-rule="nonzero"/>
</symbol>


<symbol id="icon-video-play" viewBox="0 0 85 85" xmlns="http://www.w3.org/2000/svg">
<path style="fill:currentColor;stroke-width:.135807" d="M83.362 117.436a9.427 9.427 0 0 1-9.426 9.426 9.427 9.427 0 0 1-9.427-9.426 9.427 9.427 0 0 1 9.427-9.427 9.427 9.427 0 0 1 9.426 9.427z" transform="matrix(4.17778 0 0 4.17778 -269.089 -450.822)"/>
<path d="M204.11 0C91.388 0 0 91.388 0 204.111c0 112.725 91.388 204.11 204.11 204.11 112.729 0 204.11-91.385 204.11-204.11C408.221 91.388 316.839 0 204.11 0Zm82.437 229.971-126.368 72.471c-17.003 9.75-30.781 1.763-30.781-17.834V140.012c0-19.602 13.777-27.575 30.781-17.827l126.368 72.466c17.004 9.752 17.004 25.566 0 35.32z" style="fill:#fff" transform="scale(.19444)"/>
</symbol>

</defs>
</svg>
75 changes: 75 additions & 0 deletions tests/apps/core/test_videoplayer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""
Test the custom video player with a performance improvement.
"""

from django.test.utils import override_settings

import lxml.html # nosec
from cms.test_utils.testcases import CMSTestCase

from richie.apps.courses.factories import CourseFactory, VideoSample


class CoursesTemplatesCourseDetailRenderingCMSTestCase(CMSTestCase):
"""
Test the custom video player with a performance improvement.
"""

video_sample_without_image = VideoSample(
"Anant Agarwal: Why massively open online courses (still) matter",
None,
"//www.youtube.com/embed/rYwTA5RA9eU",
)

@override_settings(RICHIE_VIDEO_LAZY_LOAD=True)
def test_templates_course_detail_teaser_video_cover_empty_lazy_play(self):
"""
When the `course_teaser` placeholder is filled with a VideoPlayerPlugin.
The course page should return an empty video cover image if:
- the video poster image is empty;
- the course page hasn't any `course_cover` placeholder.
When the `RICHIE_VIDEO_LAZY_LOAD` is activated, the video iframe should be hidden.
"""
video_sample = self.video_sample_without_image
course = CourseFactory(fill_teaser=video_sample, should_publish=True)

response = self.client.get(course.extended_object.get_absolute_url())
self.assertEqual(response.status_code, 200)
html = lxml.html.fromstring(response.content)
iframe = html.cssselect(".subheader__teaser .aspect-ratio iframe")[0]
self.assertEqual(iframe.get("data-src"), video_sample.url + "?&autoplay=1")
self.assertEqual(iframe.get("title"), video_sample.label)
self.assertEqual(iframe.get("style"), "display: none;")
self.assertIn("allowfullscreen", iframe.keys())
# no video cover image
self.assertEqual(
len(html.cssselect(".subheader__teaser .aspect-ratio a img")), 0
)

@override_settings(RICHIE_VIDEO_LAZY_LOAD=True)
def test_templates_course_detail_teaser_video_cover_from_course_cover(self):
"""
When the `course_teaser` placeholder is filled with a VideoPlayerPlugin.
The course page show the course cover image if:
- the video poster image is empty;
- the course page has a `course_cover` placeholder.
When the `RICHIE_VIDEO_LAZY_LOAD` is activated, the video iframe should be hidden.
"""
cover_file_name = cover_file_name = "cover.jpg"
video_sample = self.video_sample_without_image
course = CourseFactory(
fill_teaser=video_sample,
fill_cover={"original_filename": cover_file_name},
should_publish=True,
)

response = self.client.get(course.extended_object.get_absolute_url())
self.assertEqual(response.status_code, 200)
html = lxml.html.fromstring(response.content)
iframe = html.cssselect(".subheader__teaser .aspect-ratio iframe")[0]
self.assertEqual(iframe.get("data-src"), video_sample.url + "?&autoplay=1")
self.assertEqual(iframe.get("title"), video_sample.label)
self.assertEqual(iframe.get("style"), "display: none;")
self.assertIn("allowfullscreen", iframe.keys())
img = html.cssselect(".subheader__teaser .aspect-ratio a img")[0]
self.assertIn(cover_file_name, img.get("src"))
41 changes: 41 additions & 0 deletions tests/apps/courses/test_templates_course_detail_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,24 @@ def test_templates_course_detail_teaser_video_cover_empty(self):
self.assertEqual(iframe.get("title"), video_sample.label)
self.assertEqual(iframe.get("src"), video_sample.url)

@override_settings(RICHIE_VIDEO_LAZY_LOAD=True)
def test_templates_course_detail_teaser_video_cover_empty_lazy(self):
"""
With video in `course_teaser` placeholder and no image in `course_cover` placeholder,
video component should be present on the `course_teaser` placeholder.
When the `RICHIE_VIDEO_LAZY_LOAD` is activated, the video iframe should be hidden.
"""
video_sample = random.choice(VIDEO_SAMPLE_LINKS)
course = CourseFactory(fill_teaser=video_sample, should_publish=True)

response = self.client.get(course.extended_object.get_absolute_url())
self.assertEqual(response.status_code, 200)
html = lxml.html.fromstring(response.content)
iframe = html.cssselect(".subheader__teaser .aspect-ratio iframe")[0]
self.assertIn("allowfullscreen", iframe.keys())
self.assertEqual(iframe.get("title"), video_sample.label)
self.assertEqual(iframe.get("data-src"), video_sample.url + "?&autoplay=1")

def test_templates_course_detail_teaser_empty_cover_image(self):
"""
Without video in `course_teaser` placeholder and with image in `course_cover` placeholder,
Expand Down Expand Up @@ -636,6 +654,29 @@ def test_templates_course_detail_teaser_video_cover_image(self):
self.assertEqual(iframe.get("title"), video_sample.label)
self.assertEqual(iframe.get("src"), video_sample.url)

@override_settings(RICHIE_VIDEO_LAZY_LOAD=True)
def test_templates_course_detail_teaser_video_cover_image_lazy(self):
"""
With video in `course_teaser` placeholder and with image in `course_cover` placeholder,
video component should be present on the `course_teaser` placeholder.
When the `RICHIE_VIDEO_LAZY_LOAD` is activated, the video iframe should be hidden.
"""
video_sample = random.choice(VIDEO_SAMPLE_LINKS)
course = CourseFactory(
fill_teaser=video_sample,
fill_cover=pick_image("cover")(video_sample.image),
should_publish=True,
)

response = self.client.get(course.extended_object.get_absolute_url())

self.assertEqual(response.status_code, 200)
html = lxml.html.fromstring(response.content)
iframe = html.cssselect(".subheader__teaser .aspect-ratio iframe")[0]
self.assertIn("allowfullscreen", iframe.keys())
self.assertEqual(iframe.get("title"), video_sample.label)
self.assertEqual(iframe.get("data-src"), video_sample.url + "?&autoplay=1")


# pylint: disable=too-many-public-methods
class RunsCourseCMSTestCase(CMSTestCase):
Expand Down

0 comments on commit 964b360

Please sign in to comment.