Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implemented HLS stream source as new plugin type. #84

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ The canonical URL can be reconfigured with a configuration setting::

DJANGOCMS_VIDEO_YOUTUBE_EMBED_URL = '//www.youtube-nocookie.com/embed/{}'

In addition to regular video files and YouTube videos, the plugin also supports `HLS <https://en.wikipedia.org/wiki/HTTP_Live_Streaming>`_ or HTTP Live Streams as source. These streams are played back using an html <video> element with the added support of `hls.js <https://hlsjs.video-dev.org>`_. An HLS source is defined by an URL pointing to a .m3u8 file served via HTTP.

By default the HLS source includes the hls.js javascript file from a content delivery network. If you wish to override this, customize the following variable::

DJANGOCMS_VIDEO_HLSJS_SOURCE = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js'

Running Tests
-------------

Expand Down
34 changes: 33 additions & 1 deletion djangocms_video/cms_plugins.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from django.utils.translation import gettext_lazy as _
from django.conf import settings

from . import forms, models

DEFAULT_HLSJS_SOURCE = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js'

class VideoPlayerPlugin(CMSPluginBase):
model = models.VideoPlayer
name = _('Video player')
text_enabled = True
allow_children = True
child_classes = ['VideoSourcePlugin', 'VideoTrackPlugin']
child_classes = ['VideoSourcePlugin', 'VideoTrackPlugin', 'HlsStreamSourcePlugin']
form = forms.VideoPlayerPluginForm

fieldsets = [
Expand All @@ -32,13 +34,17 @@ class VideoPlayerPlugin(CMSPluginBase):
'fields': (
'poster',
'attributes',
'show_controls',
'autoplay',
)
})
]

def render(self, context, instance, placeholder):
context = super().render(context, instance, placeholder)
context['video_template'] = instance.template
context['show_controls'] = instance.show_controls
context['autoplay'] = instance.autoplay
return context

def get_render_template(self, context, instance, placeholder):
Expand Down Expand Up @@ -72,6 +78,31 @@ def get_render_template(self, context, instance, placeholder):
return 'djangocms_video/{}/source.html'.format(context.get('video_template', 'default'))


class HlsStreamSourcePlugin(CMSPluginBase):
model = models.HlsStreamSource
name = _('HLS Stream Source')
module = _('Video player')
require_parent = True
parent_classes = ['VideoPlayerPlugin']

fieldsets = [
(None, {
'fields': (
'hls_source_url',
)
}),
]

def render(self, context, instance, placeholder):
context = super().render(context, instance, placeholder)
context['source_id'] = instance.id
context['hlsjs_source'] = getattr(settings, 'DJANGOCMS_VIDEO_HLSJS_SOURCE', DEFAULT_HLSJS_SOURCE)
return context

def get_render_template(self, context, instance, placeholder):
return 'djangocms_video/{}/hls_stream_source.html'.format(context.get('video_template', 'default'))


class VideoTrackPlugin(CMSPluginBase):
model = models.VideoTrack
name = _('Track')
Expand Down Expand Up @@ -101,5 +132,6 @@ def get_render_template(self, context, instance, placeholder):


plugin_pool.register_plugin(VideoPlayerPlugin)
plugin_pool.register_plugin(HlsStreamSourcePlugin)
plugin_pool.register_plugin(VideoSourcePlugin)
plugin_pool.register_plugin(VideoTrackPlugin)
23 changes: 23 additions & 0 deletions djangocms_video/migrations/0012_hlsstreamsource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.0.9 on 2024-11-30 13:11

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('cms', '0035_auto_20230822_2208_squashed_0036_auto_20240311_1028'),
('djangocms_video', '0011_alter_videoplayer_cmsplugin_ptr_and_more'),
]

operations = [
migrations.CreateModel(
name='HlsStreamSource',
fields=[
('cmsplugin_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='%(app_label)s_%(class)s', serialize=False, to='cms.cmsplugin')),
('hls_source_url', models.CharField(max_length=1024, verbose_name='HLS Source URL')),
],
bases=('cms.cmsplugin',),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.0.9 on 2024-11-30 14:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('djangocms_video', '0012_hlsstreamsource'),
]

operations = [
migrations.AddField(
model_name='videoplayer',
name='autoplay',
field=models.BooleanField(default=False, help_text='If enabled, the video will automatically play once the page is loaded. This might not work depending on how the user has configured their browser.', verbose_name='Autoplay'),
),
migrations.AddField(
model_name='videoplayer',
name='show_controls',
field=models.BooleanField(default=True, help_text='If enabled, the video will be shown with Play, Pause and Seek elements that allow the user to control playback.', verbose_name='Show controls'),
),
]
26 changes: 26 additions & 0 deletions djangocms_video/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,23 @@ class VideoPlayer(CMSPlugin):
verbose_name=_('Attributes'),
blank=True,
)
show_controls = models.BooleanField(
verbose_name=_('Show controls'),
default=True,
help_text=_(
'If enabled, the video will be shown with Play, Pause and Seek '
'elements that allow the user to control playback.'
),
)
autoplay = models.BooleanField(
verbose_name=_('Autoplay'),
default=False,
help_text=_(
'If enabled, the video will automatically play once the page is '
'loaded. This might not work depending on how the user has '
'configured their browser.'
),
)

# Add an app namespace to related_name to avoid field name clashes
# with any other plugins that have a field with the same name as the
Expand Down Expand Up @@ -164,6 +181,15 @@ def copy_relations(self, oldinstance):
self.source_file = oldinstance.source_file


class HlsStreamSource(CMSPlugin):
"""
Renders the HTML <source> element inside of <video> for an HLS stream defined by a .m3u8 URL.
"""
hls_source_url = models.URLField(
verbose_name=_('HLS Source URL')
)


class VideoTrack(CMSPlugin):
"""
Renders the HTML <track> element inside <video>.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{% load i18n cms_tags sekizai_tags %}

{% if not disabled %}
{% with instance.hls_source_url as url %}
<source id="{{ source_id }}" src="{{ url }}" type="application/x-mpegURL" {{ instance.attributes_str }}>
{% endwith %}
{% endif %}

{% addtoblock "js" %}
<script src="{{ hlsjs_source }}"></script>
<script>
// Find first source ending in a .m3u8 path and return its url
function getHlsUrl(videoElement) {
const sources = videoElement.querySelectorAll("source");
for (source of sources) {
if (source.src.endsWith("m3u8")) {
return source.src;
}
}
return null;
}

function attachHlsStream(sourceElement) {
if (Hls.isSupported()) {
let video = sourceElement.parentElement;
let hls = new Hls();
let hlsUrl = getHlsUrl(video);
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
video.play();
});

hls.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('fatal media error encountered, try to recover');
hls.recoverMediaError();
break;
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('fatal network error encountered', data);
break;
default:
hls.destroy();
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = hlsUrl;
video.addEventListener('loadedmetadata', () => {
video.play();
});
} else {
console.error('HLS is not supported on this browser.');
}
}

attachHlsStream(document.getElementById('{{ source_id }}'));

</script>
{% endaddtoblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
{% endwith %}
{% else %}
{# render <source> or <track> plugins #}
<video controls {{ instance.attributes_str }}
{% if instance.poster %} poster="{{ instance.poster.url }}"{% endif %}>
<video {% if show_controls %} controls {% endif %}
{% if autoplay %} autoplay {% endif %}
{{ instance.attributes_str }}
{% if instance.poster %} poster="{{ instance.poster.url }}"{% endif %}>
{% for plugin in instance.child_plugin_instances %}
{% render_plugin plugin %}
{% endfor %}
Expand Down
1 change: 1 addition & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
'LANGUAGE_CODE': 'en',
'ALLOWED_HOSTS': ['localhost'],
'CMS_CONFIRM_VERSION4': True,
}


Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ deps =
commands =
{envpython} --version
{env:COMMAND:coverage} erase
{env:COMMAND:coverage} run setup.py test
{env:COMMAND:coverage} run tests/settings.py
{env:COMMAND:coverage} combine --keep
{env:COMMAND:coverage} report --show-missing

Expand Down
Loading