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 all 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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ jobs:
pip install ruff
- name: Run Ruff
run: |
ruff djangocms_video
ruff check djangocms_video
5 changes: 2 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@ concurrency:
jobs:
tests:
name: ${{ matrix.database }} Python ${{ matrix.python-version }}
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04

strategy:
fail-fast: false
matrix:
python-version:
- '3.8'
- '3.9'
- '3.10'
- '3.11'
Expand All @@ -38,7 +37,7 @@ jobs:
run: |
python -m pip install --upgrade pip setuptools wheel
python -m pip install --upgrade 'tox>=4.0.0rc3'

- name: Run tox targets for ${{ matrix.python-version }}
run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .)

Expand Down
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.conf import settings
from django.utils.translation import gettext_lazy as _

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)
22 changes: 22 additions & 0 deletions djangocms_video/migrations/0012_hlsstreamsource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 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 = [
('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.URLField(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
15 changes: 3 additions & 12 deletions tests/requirements/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,11 @@
from subprocess import run

CONFIG_MATRIX = [
["python3.8", "Django>=3.2a1,<3.3", "django-cms>=3.8,<3.9", "py38-django32-cms38.txt",],
["python3.8", "Django>=3.2a1,<3.3", "django-cms>=3.9,<3.10", "py38-django32-cms39.txt",],
["python3.8", "Django>=3.2a1,<3.3", "django-cms>=3.10,<3.11", "py38-django32-cms310.txt",],
["python3.9", "Django>=4.2,<5.0", "django-cms>=3.11,<4.0", "py39-django42-cms311.txt",],

["python3.9", "Django>=3.2a1,<3.3", "django-cms>=3.8,<3.9", "py39-django32-cms38.txt",],
["python3.9", "Django>=3.2a1,<3.3", "django-cms>=3.9,<3.10", "py39-django32-cms39.txt",],
["python3.9", "Django>=3.2a1,<3.3", "django-cms>=3.10,<3.11", "py39-django32-cms310.txt",],
["python3.10", "Django>=4.2,<5.0", "django-cms>=3.11,<4.0", "py310-django42-cms311.txt",],

["python3.10", "Django>=3.2a1,<3.3", "django-cms>=3.8,<3.9", "py310-django32-cms38.txt",],
["python3.10", "Django>=3.2a1,<3.3", "django-cms>=3.9,<3.10", "py310-django32-cms39.txt",],
["python3.10", "Django>=3.2a1,<3.3", "django-cms>=3.10,<3.11", "py310-django32-cms310.txt",],
["python3.10", "Django>=4.2a1,<5.0", "django-cms>=3.11,<4.0", "py310-django42-cms311.txt",],

["python3.11", "Django>=4.2a1,<5.0", "django-cms>=3.11,<4.0", "py311-django42-cms311.txt",],
["python3.11", "Django>=4.2,<5.0", "django-cms>=3.11,<4.0", "py311-django42-cms311.txt",],
]

if __name__ == "__main__":
Expand Down
Loading
Loading