work on player: integrate vuejs + noscript; remove TemplateMixin for Component and ExposedData; rewrite most of the player; clean up files; do lot of other things

This commit is contained in:
bkfox 2018-02-08 06:46:42 +01:00
parent 343279a777
commit d80322dd15
36 changed files with 12711 additions and 1740 deletions

View File

@ -97,7 +97,7 @@ class ProgramAdmin(NameableAdmin):
@admin.register(Diffusion)
class DiffusionAdmin(admin.ModelAdmin):
def archives(self, obj):
sounds = [ str(s) for s in obj.get_archives()]
sounds = [ str(s) for s in obj.get_sounds(archive=True)]
return ', '.join(sounds) if sounds else ''
def conflicts_(self, obj):

View File

@ -297,7 +297,7 @@ class Source:
A playlist from a program uses all its available archives.
"""
if diffusion:
self.playlist = diffusion.playlist
self.playlist = diffusion.get_playlist(archive = True)
return
program = program or self.program

View File

@ -1,3 +1,4 @@
#! /usr/bin/env python3
"""
Monitor sound files; For each program, check for:
- new files;

View File

@ -176,7 +176,7 @@ class Monitor:
last_diff = self.last_diff_start
diff = None
if last_diff and not last_diff.is_expired():
archives = last_diff.diffusion.get_archives()
archives = last_diff.diffusion.sounds(archive = True)
if archives.filter(pk = sound.pk).exists():
diff = last_diff.diffusion
@ -294,7 +294,8 @@ class Monitor:
.filter(source = log.source, pk__gt = log.pk) \
.exclude(sound__type = Sound.Type.removed)
remaining = log.diffusion.get_archives().exclude(pk__in = sounds) \
remaining = log.diffusion.sounds(archive = True) \
.exclude(pk__in = sounds) \
.values_list('path', flat = True)
return log.diffusion, list(remaining)
@ -312,7 +313,7 @@ class Monitor:
.filter(type = Diffusion.Type.normal, **kwargs) \
.distinct().order_by('start')
diff = diff.first()
return (diff, diff and diff.playlist or [])
return (diff, diff and diff.get_playlist(archive = True) or [])
def handle_pl_sync(self, source, playlist, diff = None, date = None):
"""

View File

@ -59,6 +59,7 @@ class Related(models.Model):
related_type = models.ForeignKey(
ContentType,
blank = True, null = True,
on_delete=models.SET_NULL,
)
related_id = models.PositiveIntegerField(
blank = True, null = True,
@ -332,6 +333,7 @@ class Program(Nameable):
station = models.ForeignKey(
Station,
verbose_name = _('station'),
on_delete=models.CASCADE,
)
active = models.BooleanField(
_('active'),
@ -438,6 +440,7 @@ class Stream(models.Model):
program = models.ForeignKey(
Program,
verbose_name = _('related program'),
on_delete=models.CASCADE,
)
delay = models.TimeField(
_('delay'),
@ -483,6 +486,7 @@ class Schedule(models.Model):
program = models.ForeignKey(
Program,
verbose_name = _('related program'),
on_delete=models.CASCADE,
)
time = models.TimeField(
_('time'),
@ -839,6 +843,7 @@ class Diffusion(models.Model):
program = models.ForeignKey (
Program,
verbose_name = _('program'),
on_delete=models.CASCADE,
)
# specific
type = models.SmallIntegerField(
@ -904,34 +909,31 @@ class Diffusion(models.Model):
self._local_end = tz.localtime(self.end, tz.get_current_timezone())
return self._local_end
@property
def playlist(self):
"""
List of archives' path; uses get_archives
"""
playlist = self.get_archives().values_list('path', flat = True)
return list(playlist)
def is_live(self):
return self.type == self.Type.normal and \
not self.get_archives().count()
not self.get_sounds(archive = True).count()
def get_archives(self):
"""
Return a list of available archives sounds for the given episode,
ordered by path.
"""
sounds = self.initial.sound_set if self.initial else self.sound_set
return sounds.filter(type = Sound.Type.archive).order_by('path')
def get_excerpts(self):
def get_playlist(self, **types):
"""
Return a list of available archives sounds for the given episode,
ordered by path.
Returns sounds as a playlist (list of *local* file path).
The given arguments are passed to ``get_sounds``.
"""
sounds = self.initial.sound_set if self.initial else self.sound_set
return sounds.filter(type = Sound.Type.excerpt).order_by('path')
return list(self.get_sounds(archives = True) \
.filter(path__isnull = False) \
.values_list('path', flat = True))
def get_sounds(self, **types):
"""
Return a queryset of sounds related to this diffusion,
ordered by type then path.
**types: filter on the given sound types name, as `archive=True`
"""
sounds = (self.initial or self).sound_set.order_by('type', 'path')
_in = [ getattr(Sound.Type, name)
for name, value in types.items() if value ]
return sounds.filter(type__in = _in)
def is_date_in_range(self, date = None):
"""
@ -1012,6 +1014,7 @@ class Sound(Nameable):
Program,
verbose_name = _('program'),
blank = True, null = True,
on_delete=models.SET_NULL,
help_text = _('program related to it'),
)
diffusion = models.ForeignKey(
@ -1026,11 +1029,13 @@ class Sound(Nameable):
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
blank = True, null = True
)
# FIXME: url() does not use the same directory than here
# should we use FileField for more reliability?
path = models.FilePathField(
_('file'),
path = settings.AIRCOX_PROGRAMS_DIR,
match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \
.replace('.', r'\.') + ')$',
match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT)
.replace('.', r'\.') + ')$',
recursive = True,
blank = True, null = True,
unique = True,
@ -1229,6 +1234,7 @@ class Port (models.Model):
station = models.ForeignKey(
Station,
verbose_name = _('station'),
on_delete=models.CASCADE,
)
direction = models.SmallIntegerField(
_('direction'),
@ -1464,6 +1470,7 @@ class Log(models.Model):
station = models.ForeignKey(
Station,
verbose_name = _('station'),
on_delete=models.CASCADE,
help_text = _('related station'),
)
source = models.CharField(

View File

@ -30,9 +30,10 @@ import bleach
import aircox.models
import aircox_cms.settings as settings
from aircox_cms.utils import image_url
from aircox_cms.models.lists import *
from aircox_cms.models.sections import *
from aircox_cms.template import TemplateMixin
from aircox_cms.sections import *
from aircox_cms.utils import image_url
@register_setting
@ -300,6 +301,10 @@ class BasePage(Page):
if self.allow_comments and \
WebsiteSettings.for_site(request.site).allow_comments:
context['comment_form'] = CommentForm()
context['settings'] = {
'debug': settings.DEBUG
}
return context
def serve(self, request):
@ -331,7 +336,7 @@ class BasePage(Page):
#
# Publications
#
class PublicationRelatedLink(RelatedLinkBase,TemplateMixin):
class PublicationRelatedLink(RelatedLinkBase,Component):
template = 'aircox_cms/snippets/link.html'
parent = ParentalKey('Publication', related_name='links')
@ -392,7 +397,6 @@ class Publication(BasePage):
FieldPanel('tags'),
FieldPanel('focus'),
], heading=_('Content')),
InlinePanel('links', label=_('Links'))
] + Page.promote_panels
settings_panels = Page.settings_panels + [
FieldPanel('publish_as'),
@ -550,7 +554,6 @@ class DiffusionPage(Publication):
FieldPanel('tags'),
FieldPanel('focus'),
], heading=_('Content')),
InlinePanel('links', label=_('Links'))
] + Page.promote_panels
settings_panels = Publication.settings_panels + [
FieldPanel('diffusion')
@ -605,37 +608,16 @@ class DiffusionPage(Publication):
return item
def get_archive(self):
"""
Return the diffusion's archive as podcast
"""
if not self.publish_archive or not self.diffusion:
return
sound = self.diffusion.get_archives() \
.filter(public = True).first()
if sound:
sound.detail_url = self.url
return sound
def get_podcasts(self):
"""
Return a list of podcasts, with archive as the first item of the
list when available.
"""
if not self.diffusion:
return
podcasts = []
archive = self.get_archive()
if archive:
podcasts.append(archive)
qs = self.diffusion.get_excerpts().filter(public = True)
podcasts.extend(qs[:])
for podcast in podcasts:
podcast.detail_url = self.url
return podcasts
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context['podcasts'] = self.diffusion and SectionPlaylist(
title=_('Podcasts'),
page = self,
sounds = self.diffusion.get_sounds(
archive = self.publish_archive, excerpt = True
)
)
return context
def save(self, *args, **kwargs):
if self.diffusion:

536
aircox_cms/models/lists.py Normal file
View File

@ -0,0 +1,536 @@
"""
Generic list manipulation used to render list of items
Includes various usefull class and abstract models to make lists and
list items.
"""
import datetime
import re
from enum import IntEnum
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils import timezone as tz
from django.utils.functional import cached_property
from wagtail.wagtailadmin.edit_handlers import *
from wagtail.wagtailcore.models import Page, Orderable
from wagtail.wagtailimages.models import Image
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from aircox_cms.utils import related_pages_filter
class ListItem:
"""
Generic normalized element to add item in lists that are not based
on Publication.
"""
title = ''
headline = ''
url = ''
cover = None
date = None
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.specific = self
class RelatedLinkBase(Orderable):
"""
Base model to make a link item. It can link to an url, or a page and
includes some common fields.
"""
url = models.URLField(
_('url'),
null=True, blank=True,
help_text = _('URL of the link'),
)
page = models.ForeignKey(
Page,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _('Use a page instead of a URL')
)
icon = models.ForeignKey(
Image,
verbose_name = _('icon'),
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _(
'icon from the gallery'
),
)
icon_path = models.CharField(
_('icon path'),
null=True, blank=True,
max_length=128,
help_text = _(
'icon from a given URL or path in the directory of static files'
)
)
text = models.CharField(
_('text'),
max_length = 64,
null = True, blank=True,
help_text = _('text of the link'),
)
info = models.CharField(
_('info'),
max_length = 128,
null=True, blank=True,
help_text = _(
'description displayed in a popup when the mouse hovers '
'the link'
)
)
class Meta:
abstract = True
panels = [
MultiFieldPanel([
FieldPanel('text'),
FieldPanel('info'),
ImageChooserPanel('icon'),
FieldPanel('icon_path'),
FieldPanel('url'),
PageChooserPanel('page'),
], heading=_('link'))
]
def icon_url(self):
"""
Return icon_path as a complete url, since it can either be an
url or a path to static file.
"""
if self.icon_path.startswith('http://') or \
self.icon_path.startswith('https://'):
return self.icon_path
return static(self.icon_path)
def as_dict(self):
"""
Return compiled values from parameters as dict with
'url', 'icon', 'text'
"""
if self.page:
url, text = self.page.url, self.text or self.page.title
else:
url, text = self.url, self.text or self.url
return {
'url': url,
'text': text,
'info': self.info,
'icon': self.icon,
'icon_path': self.icon_path and self.icon_url(),
}
class BaseList(models.Model):
"""
Generic list
"""
class DateFilter(IntEnum):
none = 0x00
previous = 0x01
next = 0x02
before_related = 0x03
after_related = 0x04
class RelationFilter(IntEnum):
none = 0x00
subpages = 0x01
siblings = 0x02
subpages_or_siblings = 0x03
# rendering
use_focus = models.BooleanField(
_('focus available'),
default = False,
help_text = _('if true, highlight the first focused article found')
)
count = models.SmallIntegerField(
_('count'),
default = 30,
help_text = _('number of items to display in the list'),
)
asc = models.BooleanField(
verbose_name = _('ascending order'),
default = True,
help_text = _('if selected sort list in the ascending order by date')
)
# selectors
date_filter = models.SmallIntegerField(
verbose_name = _('filter on date'),
choices = [ (int(y), _(x.replace('_', ' ')))
for x,y in DateFilter.__members__.items() ],
blank = True, null = True,
help_text = _(
'select pages whose date follows the given constraint'
)
)
model = models.ForeignKey(
ContentType,
verbose_name = _('filter on page type'),
blank = True, null = True,
on_delete=models.SET_NULL,
help_text = _('if set, select only elements that are of this type'),
limit_choices_to = related_pages_filter,
)
related = models.ForeignKey(
Page,
verbose_name = _('related page'),
blank = True, null = True,
on_delete=models.SET_NULL,
help_text = _(
'if set, select children or siblings of this page'
),
related_name = '+'
)
relation = models.SmallIntegerField(
verbose_name = _('relation'),
choices = [ (int(y), _(x.replace('_', ' ')))
for x,y in RelationFilter.__members__.items() ],
default = 1,
help_text = _(
'when the list is related to a page, only select pages that '
'correspond to this relationship'
),
)
search = models.CharField(
verbose_name = _('filter on search'),
blank = True, null = True,
max_length = 128,
help_text = _(
'keep only pages that matches the given search'
)
)
tags = models.CharField(
verbose_name = _('filter on tag'),
blank = True, null = True,
max_length = 128,
help_text = _(
'keep only pages with the given tags (separated by a colon)'
)
)
panels = [
MultiFieldPanel([
FieldPanel('count'),
FieldPanel('use_focus'),
FieldPanel('asc'),
], heading=_('rendering')),
MultiFieldPanel([
FieldPanel('date_filter'),
FieldPanel('model'),
PageChooserPanel('related'),
FieldPanel('relation'),
FieldPanel('search'),
FieldPanel('tags'),
], heading=_('filters'))
]
class Meta:
abstract = True
def __get_related(self, qs):
related = self.related and self.related.specific
filter = self.RelationFilter
if self.relation in (filter.subpages, filter.subpages_or_siblings):
qs_ = qs.descendant_of(related)
if self.relation == filter.subpages_or_siblings and \
not qs.count():
qs_ = qs.sibling_of(related)
qs = qs_
else:
qs = qs.sibling_of(related)
date = related.date if hasattr(related, 'date') else \
related.first_published_at
if self.date_filter == self.DateFilter.before_related:
qs = qs.filter(date__lt = date)
elif self.date_filter == self.DateFilter.after_related:
qs = qs.filter(date__gte = date)
return qs
def get_queryset(self):
"""
Get queryset based on the arguments. This class is intended to be
reusable by other classes if needed.
"""
# FIXME: check if related is published
from aircox_cms.models import Publication
# model
if self.model:
qs = self.model.model_class().objects.all()
else:
qs = Publication.objects.all()
qs = qs.live().not_in_menu()
# related
if self.related:
qs = self.__get_related(qs)
# date_filter
date = tz.now()
if self.date_filter == self.DateFilter.previous:
qs = qs.filter(date__lt = date)
elif self.date_filter == self.DateFilter.next:
qs = qs.filter(date__gte = date)
# sort
qs = qs.order_by('date', 'pk') \
if self.asc else qs.order_by('-date', '-pk')
# tags
if self.tags:
qs = qs.filter(tags__name__in = ','.split(self.tags))
# search
if self.search:
# this qs.search does not return a queryset
qs = qs.search(self.search)
return qs
def get_context(self, request, qs = None, paginate = True):
"""
Return a context object using the given request and arguments.
@param paginate: paginate and include paginator into context
Context arguments:
- object_list: queryset of the list's objects
- paginator: [if paginate] paginator object for this list
- list_url_args: GET arguments of the url as string
! Note: BaseList does not inherit from Wagtail.Page, and calling
this method won't call other super() get_context.
"""
qs = qs or self.get_queryset()
paginator = None
context = {}
if qs.count():
if paginate:
context.update(self.paginate(request, qs))
else:
context['object_list'] = qs[:self.count]
else:
# keep empty queryset
context['object_list'] = qs
context['list_url_args'] = self.to_url(full_url = False)
context['list_selector'] = self
return context
def paginate(self, request, qs):
# paginator
paginator = Paginator(qs, self.count)
try:
qs = paginator.page(request.GET.get('page') or 1)
except PageNotAnInteger:
qs = paginator.page(1)
except EmptyPage:
qs = paginator.page(paginator.num_pages)
return {
'paginator': paginator,
'object_list': qs
}
def to_url(self, page = None, **kwargs):
"""
Return a url to a given page with GET corresponding to this
list's parameters.
@param page: if given use it to prepend url with page's url instead of giving only
GET parameters
@param **kwargs: override list parameters
If there is related field use it to get the page, otherwise use
the given list_page or the first BaseListPage it finds.
"""
params = {
'asc': self.asc,
'date_filter': self.get_date_filter_display(),
'model': self.model and self.model.model,
'relation': self.relation,
'search': self.search,
'tags': self.tags
}
params.update(kwargs)
if self.related:
params['related'] = self.related.pk
params = '&'.join([
key if value == True else '{}={}'.format(key, value)
for key, value in params.items() if value
])
if not page:
return params
return page.url + '?' + params
@classmethod
def from_request(cl, request, related = None):
"""
Return a context from the request's GET parameters. Context
can be used to update relative informations, more information
on this object from BaseList.get_context()
@param request: get params from this request
@param related: reference page for a related list
@return context object from BaseList.get_context()
This function can be used by other views if needed
Parameters:
* asc: if present, sort ascending instead of descending
* date_filter: one of DateFilter attribute's key.
* model: ['program','diffusion','event'] type of the publication
* relation: one of RelationFilter attribute's key
* related: list is related to the method's argument `related`.
It can be a page id.
* tag: tag to search for
* search: query to search in the publications
* page: page number
"""
date_filter = request.GET.get('date_filter')
model = request.GET.get('model')
relation = request.GET.get('relation')
if relation is not None:
try:
relation = int(relation)
except:
relation = None
related_= request.GET.get('related')
if related_:
try:
related_ = int(related_)
related_ = Page.objects.filter(pk = related_).first()
related_ = related_ and related_.specific
except:
related_ = None
kwargs = {
'asc': 'asc' in request.GET,
'date_filter':
int(getattr(cl.DateFilter, date_filter))
if date_filter and hasattr(cl.DateFilter, date_filter)
else None,
'model':
ProgramPage if model == 'program' else
DiffusionPage if model == 'diffusion' else
EventPage if model == 'event' else None,
'related': related_,
'relation': relation,
'tags': request.GET.get('tags'),
'search': request.GET.get('search'),
}
base_list = cl(
count = 30, **{ k:v for k,v in kwargs.items() if v }
)
return base_list.get_context(request)
class DatedBaseList(models.Model):
"""
List that display items per days. Renders a navigation section on the
top.
"""
nav_days = models.SmallIntegerField(
_('navigation days count'),
default = 7,
help_text = _('number of days to display in the navigation header '
'when we use dates')
)
nav_per_week = models.BooleanField(
_('navigation per week'),
default = False,
help_text = _('if selected, show dates navigation per weeks instead '
'of show days equally around the current date')
)
hide_icons = models.BooleanField(
_('hide icons'),
default = False,
help_text = _('if selected, images of publications will not be '
'displayed in the list')
)
class Meta:
abstract = True
panels = [
MultiFieldPanel([
FieldPanel('nav_days'),
FieldPanel('nav_per_week'),
FieldPanel('hide_icons'),
], heading=_('Navigation')),
]
@staticmethod
def str_to_date(date):
"""
Parse a string and return a regular date or None.
Format is either "YYYY/MM/DD" or "YYYY-MM-DD" or "YYYYMMDD"
"""
try:
exp = r'(?P<year>[0-9]{4})(-|\/)?(?P<month>[0-9]{1,2})(-|\/)?' \
r'(?P<day>[0-9]{1,2})'
date = re.match(exp, date).groupdict()
return datetime.date(
year = int(date['year']), month = int(date['month']),
day = int(date['day'])
)
except:
return None
def get_nav_dates(self, date):
"""
Return a list of dates availables for the navigation
"""
if self.nav_per_week:
first = date.weekday()
else:
first = int((self.nav_days - 1) / 2)
first = date - tz.timedelta(days = first)
return [ first + tz.timedelta(days=i)
for i in range(0, self.nav_days) ]
def get_date_context(self, date = None):
"""
Return a dict that can be added to the context to be used by
a date_list.
"""
today = tz.now().date()
if not date:
date = today
# next/prev weeks/date bunch
dates = self.get_nav_dates(date)
next = date + tz.timedelta(days=self.nav_days)
prev = date - tz.timedelta(days=self.nav_days)
# context dict
return {
'nav_dates': {
'today': today,
'date': date,
'next': next,
'prev': prev,
'dates': dates,
}
}

View File

@ -0,0 +1,644 @@
from enum import IntEnum
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.template import Template, Context
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.functional import cached_property
from django.urls import reverse
from modelcluster.models import ClusterableModel
from modelcluster.fields import ParentalKey
from wagtail.wagtailadmin.edit_handlers import *
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtailcore.models import Page
from wagtail.wagtailcore.fields import RichTextField
from wagtail.wagtailsnippets.models import register_snippet
import aircox.models
from aircox_cms.models.lists import *
from aircox_cms.views.components import Component, ExposedData
from aircox_cms.utils import related_pages_filter
@register_snippet
class Region(ClusterableModel):
"""
Region is a container of multiple items of different types
that are used to render extra content related or not the current
page.
A section has an assigned position in the page, and can be restrained
to a given type of page.
"""
name = models.CharField(
_('name'),
max_length=32,
blank = True, null = True,
help_text=_('name of this section (not displayed)'),
)
position = models.CharField(
_('position'),
max_length=16,
blank = True, null = True,
help_text = _('name of the template block in which the section must '
'be set'),
)
order = models.IntegerField(
_('order'),
default = 100,
help_text = _('order of rendering, the higher the latest')
)
model = models.ForeignKey(
ContentType,
verbose_name = _('model'),
blank = True, null = True,
help_text=_('this section is displayed only when the current '
'page or publication is of this type'),
limit_choices_to = related_pages_filter,
)
page = models.ForeignKey(
Page,
verbose_name = _('page'),
blank = True, null = True,
help_text=_('this section is displayed only on this page'),
)
panels = [
MultiFieldPanel([
FieldPanel('name'),
FieldPanel('position'),
FieldPanel('model'),
FieldPanel('page'),
], heading=_('General')),
# InlinePanel('items', label=_('Region Items')),
]
@classmethod
def get_sections_at (cl, position, page = None):
"""
Return a queryset of sections that are at the given position.
Filter out Region that are not for the given page.
"""
qs = Region.objects.filter(position = position)
if page:
qs = qs.filter(
models.Q(page__isnull = True) |
models.Q(page = page)
)
qs = qs.filter(
models.Q(model__isnull = True) |
models.Q(
model = ContentType.objects.get_for_model(page).pk
)
)
return qs.order_by('order','pk')
def add_item(self, item):
"""
Add an item to the section. Automatically save the item and
create the corresponding SectionPlace.
"""
item.section = self
item.save()
def render(self, request, page = None, context = None, *args, **kwargs):
return ''.join([
item.specific.render(request, page, context, *args, **kwargs)
for item in self.items.all().order_by('order','pk')
])
def __str__(self):
return '{}: {}'.format(self.__class__.__name__, self.name or self.pk)
@register_snippet
class Section(Component, models.Model):
"""
Section is a widget configurable by user that can be rendered inside
Regions.
"""
template_name = 'aircox_cms/sections/section.html'
section = ParentalKey(Region, related_name='items')
order = models.IntegerField(
_('order'),
default = 100,
help_text = _('order of rendering, the higher the latest')
)
real_type = models.CharField(
max_length=32,
blank = True, null = True,
)
title = models.CharField(
_('title'),
max_length=32,
blank = True, null = True,
)
show_title = models.BooleanField(
_('show title'),
default = False,
help_text=_('if set show a title at the head of the section'),
)
css_class = models.CharField(
_('CSS class'),
max_length=64,
blank = True, null = True,
help_text=_('section container\'s "class" attribute')
)
template_name = 'aircox_cms/sections/item.html'
panels = [
MultiFieldPanel([
FieldPanel('section'),
FieldPanel('title'),
FieldPanel('show_title'),
FieldPanel('order'),
FieldPanel('css_class'),
], heading=_('General')),
]
# TODO make it reusable
@cached_property
def specific(self):
"""
Return a downcasted version of the model if it is from another
model, or itself
"""
if not self.real_type or type(self) != Section:
return self
return getattr(self, self.real_type)
def save(self, *args, **kwargs):
if type(self) != Section and not self.real_type:
self.real_type = type(self).__name__.lower()
return super().save(*args, **kwargs)
def __str__(self):
return '{}: {}'.format(
(self.real_type or 'section item').replace('section','section '),
self.title or self.pk
)
class SectionRelativeItem(Section):
is_related = models.BooleanField(
_('is related'),
default = False,
help_text=_(
'if set, section is related to the page being processed '
'e.g rendering a list of links will use thoses of the '
'publication instead of an assigned one.'
)
)
class Meta:
abstract=True
panels = Section.panels.copy()
panels[-1] = MultiFieldPanel(
panels[-1].children + [ FieldPanel('is_related') ],
heading = panels[-1].heading
)
def related_attr(self, page, attr):
"""
Return an attribute from the given page if self.is_related,
otherwise retrieve the attribute from self.
"""
return self.is_related and hasattr(page, attr) \
and getattr(page, attr)
@register_snippet
class SectionText(Section):
template_name = 'aircox_cms/sections/text.html'
body = RichTextField()
panels = Section.panels + [
FieldPanel('body'),
]
def get_context(self, request, page):
from wagtail.wagtailcore.rich_text import expand_db_html
context = super().get_context(request, page)
context['content'] = expand_db_html(self.body)
return context
@register_snippet
class SectionImage(SectionRelativeItem):
class ResizeMode(IntEnum):
max = 0x00
min = 0x01
crop = 0x02
image = models.ForeignKey(
'wagtailimages.Image',
verbose_name = _('image'),
related_name='+',
blank=True, null=True,
help_text=_(
'If this item is related to the current page, this image will '
'be used only when the page has not a cover'
)
)
width = models.SmallIntegerField(
_('width'),
blank=True, null=True,
help_text=_('if set and > 0, sets a maximum width for the image'),
)
height = models.SmallIntegerField(
_('height'),
blank=True, null=True,
help_text=_('if set 0 and > 0, sets a maximum height for the image'),
)
resize_mode = models.SmallIntegerField(
verbose_name = _('resize mode'),
choices = [ (int(y), _(x)) for x,y in ResizeMode.__members__.items() ],
default = int(ResizeMode.max),
help_text=_('if the image is resized, set the resizing mode'),
)
panels = Section.panels + [
ImageChooserPanel('image'),
MultiFieldPanel([
FieldPanel('width'),
FieldPanel('height'),
FieldPanel('resize_mode'),
], heading=_('Resizing'))
]
cache = ""
def get_filter(self):
return \
'original' if not (self.height or self.width) else \
'width-{}'.format(self.width) if not self.height else \
'height-{}'.format(self.height) if not self.width else \
'{}-{}x{}'.format(
self.get_resize_mode_display(),
self.width, self.height
)
def ensure_cache(self, image):
"""
Ensure that we have a generated image and that it is put in cache.
We use this method since generating dynamic signatures don't generate
static images (and we need it).
"""
# Note: in order to put the generated image in db, we first need a way
# to get save events from related page or image.
if self.cache:
return self.cache
if self.width or self.height:
template = Template(
'{% load wagtailimages_tags %}\n' +
'{{% image source {filter} as img %}}'.format(
filter = self.get_filter()
) +
'<img src="{{ img.url }}">'
)
context = Context({
"source": image
})
self.cache = template.render(context)
else:
self.cache = '<img src="{}"/>'.format(image.file.url)
return self.cache
def get_context(self, request, page):
from wagtail.wagtailimages.views.serve import generate_signature
context = super().get_context(request, page)
image = self.related_attr(page, 'cover') or self.image
if not image:
return context
context['content'] = self.ensure_cache(image)
return context
@register_snippet
class SectionLinkList(ClusterableModel, Section):
template_name = 'aircox_cms/sections/link_list.html'
panels = Section.panels + [
InlinePanel('links', label=_('Links')),
]
@register_snippet
class SectionLink(RelatedLinkBase, Component):
"""
Render a link to a page or a given url.
Can either be used standalone or in a SectionLinkList
"""
template_name = 'aircox_cms/snippets/link.html'
parent = ParentalKey(
'SectionLinkList', related_name = 'links',
null = True
)
def __str__(self):
return 'link: {} #{}'.format(
self.text or (self.page and self.page.title) or self.title,
self.pk
)
@register_snippet
class SectionList(BaseList, SectionRelativeItem):
"""
This one is quite badass, but needed: render a list of pages
using given parameters (cf. BaseList).
If focus_available, the first article in the list will be the last
article with a focus, and will be rendered in a bigger size.
"""
template_name = 'aircox_cms/sections/list.html'
# TODO/FIXME: focus, quid?
# TODO: logs in menu show headline???
url_text = models.CharField(
_('text of the url'),
max_length=32,
blank = True, null = True,
help_text = _('use this text to display an URL to the complete '
'list. If empty, no link is displayed'),
)
panels = SectionRelativeItem.panels + [
FieldPanel('url_text'),
] + BaseList.panels
def get_context(self, request, page):
import aircox_cms.models as cms
if self.is_related and not self.related:
# set current page if there is not yet a related page only
self.related = page
context = BaseList.get_context(self, request, paginate = False)
if not context['object_list'].count():
self.hide = True
return {}
context.update(SectionRelativeItem.get_context(self, request, page))
if self.url_text:
self.related = self.related and self.related.specific
target = None
if self.related and hasattr(self.related, 'get_list_page'):
target = self.related.get_list_page()
if not target:
settings = cms.WebsiteSettings.for_site(request.site)
target = settings.list_page
context['url'] = self.to_url(page = target) + '&view=list'
return context
SectionList._meta.get_field('count').default = 5
@register_snippet
class SectionLogsList(Section):
template_name = 'aircox_cms/sections/logs_list.html'
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
null = True,
on_delete=models.SET_NULL,
help_text = _('(required) the station on which the logs happened')
)
count = models.SmallIntegerField(
_('count'),
default = 5,
help_text = _('number of items to display in the list (max 100)'),
)
class Meta:
verbose_name = _('list of logs')
verbose_name_plural = _('lists of logs')
panels = Section.panels + [
FieldPanel('station'),
FieldPanel('count'),
]
@staticmethod
def as_item(log):
"""
Return a log object as a DiffusionPage or ListItem.
Supports: Log/Track, Diffusion
"""
from aircox_cms.models import DiffusionPage
if log.diffusion:
return DiffusionPage.as_item(log.diffusion)
track = log.track
return ListItem(
title = '{artist} -- {title}'.format(
artist = track.artist,
title = track.title,
),
headline = track.info,
date = log.date,
info = '',
css_class = 'track'
)
def get_context(self, request, page):
context = super().get_context(request, page)
context['object_list'] = [
self.as_item(item)
for item in self.station.on_air(count = min(self.count, 100))
]
return context
@register_snippet
class SectionTimetable(Section,DatedBaseList):
template_name = 'aircox_cms/sections/timetable.html'
class Meta:
verbose_name = _('Section: Timetable')
verbose_name_plural = _('Sections: Timetable')
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
help_text = _('(required) related station')
)
target = models.ForeignKey(
'aircox_cms.TimetablePage',
verbose_name = _('timetable page'),
blank = True, null = True,
help_text = _('select a timetable page used to show complete timetable'),
)
nav_visible = models.BooleanField(
_('show date navigation'),
default = True,
help_text = _('if checked, navigation dates will be shown')
)
# TODO: put in multi-field panel of DatedBaseList
panels = Section.panels + DatedBaseList.panels + [
MultiFieldPanel([
FieldPanel('nav_visible'),
FieldPanel('station'),
FieldPanel('target'),
], heading=_('Timetable')),
]
def get_queryset(self, context):
from aircox_cms.models import DiffusionPage
diffs = []
for date in context['nav_dates']['dates']:
items = aircox.models.Diffusion.objects.at(self.station, date)
items = [ DiffusionPage.as_item(item) for item in items ]
diffs.append((date, items))
return diffs
def get_context(self, request, page):
context = super().get_context(request, page)
context.update(self.get_date_context())
context['object_list'] = self.get_queryset(context)
context['target'] = self.target
if not self.nav_visible:
del context['nav_dates']['dates'];
return context
@register_snippet
class SectionPublicationInfo(Section):
template_name = 'aircox_cms/sections/publication_info.html'
class Meta:
verbose_name = _('Section: publication\'s info')
verbose_name_plural = _('Sections: publication\'s info')
@register_snippet
class SectionSearchField(Section):
template_name = 'aircox_cms/sections/search_field.html'
default_text = models.CharField(
_('default text'),
max_length=32,
default=_('search'),
help_text=_('text to display when the search field is empty'),
)
class Meta:
verbose_name = _('Section: search field')
verbose_name_plural = _('Sections: search field')
panels = Section.panels + [
FieldPanel('default_text'),
]
@register_snippet
class SectionPlaylist(Section):
"""
User playlist. Can be used to add sounds in it -- there should
only be one for the moment.
"""
class Track(ExposedData):
"""
Class exposed to Javascript playlist manager as Track.
"""
fields = {
'name': 'name',
'duration': lambda e, o: (
o.duration.hour, o.duration.minute, o.duration.second
),
'sources': lambda e, o: [ o.url() ],
'detail_url':
lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \
and o.diffusion.page.url
,
'cover':
lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \
and o.diffusion.page.icon
,
}
user_playlist = models.BooleanField(
_('user playlist'),
default = False,
help_text = _(
'if set, this playlist is to be editable by the user'
)
)
single_mode = models.BooleanField(
_('single_mode'),
default = False,
help_text = _(
'enable single mode by default on this playlist'
)
)
tracks = None
template_name = 'aircox_cms/sections/playlist.html'
panels = Section.panels + [
FieldPanel('user_playlist'),
FieldPanel('single_mode'),
]
def __init__(self, *args, sounds = None, tracks = None, page = None, **kwargs):
"""
Init playlist section. If ``sounds`` is given initialize playlist
tracks with it. If ``page`` is given use it for Track infos
related to a page (cover, detail_url, ...)
"""
self.tracks = (tracks or []) + [
self.Track(object = sound, detail_url = page and page.url,
cover = page and page.icon)
for sound in sounds or []
]
super().__init__(*args, **kwargs)
def get_context(self, request, page):
context = super().get_context(request, page)
context.update({
'is_default': self.user_playlist,
'modifiable': self.user_playlist,
'storage_key': self.user_playlist and str(self.pk),
'tracks': self.tracks
})
if not self.user_playlist and not self.tracks:
self.hide = True
return context
@register_snippet
class SectionPlayer(Section):
"""
Radio stream player.
"""
template_name = 'aircox_cms/sections/playlist.html'
live_title = models.CharField(
_('live title'),
max_length = 32,
help_text = _('text to display when it plays live'),
)
streams = models.TextField(
_('audio streams'),
help_text = _('one audio stream per line'),
)
class Meta:
verbose_name = _('Section: Player')
panels = Section.panels + [
FieldPanel('live_title'),
FieldPanel('streams'),
]
def get_context(self, request, page):
context = super().get_context(request, page)
context['tracks'] = [SectionPlaylist.Track(
name = self.live_title,
sources = self.streams.split('\r\n'),
data_url = 'https://aircox.radiocampus.be/aircox/on_air', # reverse('aircox.on_air'),
interval = 10,
run = True,
)]
return context

File diff suppressed because it is too large Load Diff

View File

@ -2,19 +2,21 @@ import os
from django.conf import settings
def ensure (key, default):
globals()[key] = getattr(settings, key, default)
ensure('AIRCOX_CMS_BLEACH_COMMENT_TAGS', [
AIRCOX_CMS_BLEACH_COMMENT_TAGS = [
'i', 'emph', 'b', 'strong', 'strike', 's',
'p', 'span', 'quote','blockquote','code',
'sup', 'sub', 'a',
])
]
ensure('AIRCOX_CMS_BLEACH_COMMENT_ATTRS', {
AIRCOX_CMS_BLEACH_COMMENT_ATTRS = {
'*': ['title'],
'a': ['href', 'rel'],
})
}
# import settings
for k, v in settings.__dict__.items():
if not k.startswith('__') and k not in globals():
globals()[k] = v

View File

@ -1,15 +1,15 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.utils import timezone as tz
from django.utils.translation import ugettext as _, ugettext_lazy
from django.contrib.contenttypes.models import ContentType
from wagtail.wagtailcore.models import Page, Site, PageRevision
import aircox.models as aircox
import aircox_cms.models as models
import aircox_cms.sections as sections
import aircox_cms.models.sections as sections
import aircox_cms.utils as utils
# on a new diffusion
@ -88,7 +88,7 @@ def station_post_saved(sender, instance, created, *args, **kwargs):
)
homepage.add_child(instance = programs)
section = sections.Section(
section = sections.Region(
name = _('programs'),
position = 'post_content',
page = programs,

View File

@ -13,3 +13,29 @@ window.addEventListener('scroll', function(e) {
});
/// TODO: later get rid of it in order to use Vue stuff
/// Helper to provide a tab+panel functionnality; the tab and the selected
/// element will have an attribute "selected".
/// We assume a common ancestor between tab and panel at a maximum level
/// of 2.
/// * tab: corresponding tab
/// * panel_selector is used to select the right panel object.
function select_tab(tab, panel_selector) {
var parent = tab.parentNode.parentNode;
var panel = parent.querySelector(panel_selector);
// unselect
var qs = parent.querySelectorAll('*[selected]');
for(var i = 0; i < qs.length; i++)
if(qs[i] != tab && qs[i] != panel)
qs[i].removeAttribute('selected');
panel.setAttribute('selected', 'true');
tab.setAttribute('selected', 'true');
}

694
aircox_cms/static/aircox_cms/js/player.js Executable file → Normal file
View File

@ -1,450 +1,314 @@
// TODO
// - live streams as item;
// - add to playlist button
//
/// Return a human-readable string from seconds
function duration_str(seconds) {
seconds = Math.floor(seconds);
var hours = Math.floor(seconds / 3600);
seconds -= hours * 3600;
var minutes = Math.floor(seconds / 60);
seconds -= minutes * 60;
var str = hours ? (hours < 10 ? '0' + hours : hours) + ':' : '';
str += (minutes < 10 ? '0' + minutes : minutes) + ':';
str += (seconds < 10 ? '0' + seconds : seconds);
return str;
}
/* Implementation status: -- TODO
* - actions:
* - add to user playlist
* - go to detail
* - remove from playlist: for user playlist
* - save sound infos:
* - while playing: save current position
* - otherwise: remove from localstorage
* - save playlist in localstorage
* - proper design
* - mini-button integration in lists (list of diffusion articles)
*/
function Sound(title, detail, duration, streams, cover, on_air) {
this.title = title;
this.detail = detail;
this.duration = duration;
this.streams = streams.splice ? streams.sort() : [streams];
this.cover = cover;
this.on_air = on_air;
}
Sound.prototype = {
title: '',
detail: '',
streams: undefined,
duration: undefined,
cover: undefined,
on_air: false,
item: undefined,
position_item: undefined,
get seekable() {
return this.duration != undefined;
},
make_item: function(playlist, base_item) {
if(this.item)
return;
var item = base_item.cloneNode(true);
item.removeAttribute('style');
item.querySelector('.title').innerHTML = this.title;
if(this.seekable)
item.querySelector('.duration').innerHTML =
duration_str(this.duration);
if(this.detail)
item.querySelector('.detail').href = this.detail;
if(playlist.player.show_cover && this.cover)
item.querySelector('img.cover').src = this.cover;
item.sound = this;
this.item = item;
this.position_item = item.querySelector('.position');
// events
var self = this;
item.querySelector('.action.remove').addEventListener(
'click', function(event) { playlist.remove(self); }, false
);
item.querySelector('.action.add').addEventListener(
'click', function(event) {
player.playlist.add(new Sound(
title = self.title,
detail = self.detail,
duration = self.duration,
streams = self.streams,
cover = self.cover,
on_air = self.on_air
));
}, false
);
item.addEventListener('click', function(event) {
if(event.target.className.indexOf('action') != -1)
return;
playlist.select(self, true)
}, false);
},
}
var State = Object.freeze({
Stop: Symbol('Stop'),
Loading: Symbol('Loading'),
Play: Symbol('Play'),
});
function Playlist(player) {
this.player = player;
this.playlist = player.player.querySelector('.playlist');
this.item_ = player.player.querySelector('.playlist .item');
this.sounds = []
}
class Track {
// Create a track with the given data.
// If url and interval are given, use them to retrieve regularely
// the track informations
constructor(data) {
Object.assign(this, data);
Playlist.prototype = {
on_air: undefined,
sounds: undefined,
sound: undefined,
/// Find a sound by its streams, and return it if found
find: function(streams) {
streams = streams.splice ? streams.sort() : streams;
return this.sounds.find(function(sound) {
// comparing array
if(!sound.streams || sound.streams.length != streams.length)
return false;
for(var i = 0; i < streams.length; i++)
if(sound.streams[i] != streams[i])
return false;
return true
});
},
add: function(sound, container, position) {
var sound_ = this.find(sound.streams);
if(sound_)
return sound_;
if(sound.on_air)
this.on_air = sound;
sound.make_item(this, this.item_);
container = container || this.playlist;
if(position != undefined) {
container.insertBefore(sound.item, container.children[position]);
this.sounds.splice(position, 0, sound);
}
else {
container.appendChild(sound.item);
this.sounds.push(sound);
}
this.save();
return sound;
},
remove: function(sound) {
var index = this.sounds.indexOf(sound);
if(index != -1)
this.sounds.splice(index,1);
this.playlist.removeChild(sound.item);
this.save();
if(this.sound == sound) {
this.player.stop()
this.next(false);
}
},
select: function(sound, play = true) {
this.player.playlist = this;
if(this.sound == sound) {
if(play)
this.player.play();
return;
}
if(this.sound)
this.unselect(this.sound);
this.sound = sound;
// audio
this.player.load_sound(this.sound);
// attributes
var container = this.player.player;
sound.item.setAttribute('selected', 'true');
if(!sound.on_air)
sound.item.querySelector('.content').parentNode.appendChild(
this.player.progress.item //,
// sound.item.querySelector('.content .duration')
)
if(sound.seekable)
container.setAttribute('seekable', 'true');
else
container.removeAttribute('seekable');
// play
if(play)
this.player.play();
},
unselect: function(sound) {
sound.item.removeAttribute('selected');
},
next: function(play = true) {
var index = this.sounds.indexOf(this.sound);
if(index < 0)
return;
index++;
if(index < this.sounds.length)
this.select(this.sounds[index]);
},
// storage
save: function() {
var list = [];
for(var i in this.sounds) {
var sound = Object.assign({}, this.sounds[i])
if(sound.on_air)
continue;
delete sound.item;
list.push(sound);
}
this.player.store.set('playlist', list);
},
load: function() {
var list = this.player.store.get('playlist');
var container = document.createDocumentFragment();
for(var i in list) {
var sound = list[i];
sound = new Sound(sound.title, sound.detail, sound.duration,
sound.streams, sound.cover, sound.on_air)
this.add(sound, container)
}
this.playlist.appendChild(container);
},
}
var ActivePlayer = null;
function Player(id, on_air_url, show_cover) {
this.id = id;
this.on_air_url = on_air_url;
this.show_cover = show_cover;
this.store = new Store('player_' + id);
// html sounds
this.player = document.getElementById(id);
this.audio = this.player.querySelector('audio');
this.on_air = this.player.querySelector('.on_air');
this.progress = {
item: this.player.querySelector('.controls .progress'),
bar: this.player.querySelector('.controls .progress progress'),
duration: this.player.querySelector('.controls .progress .duration')
}
this.controls = {
single: this.player.querySelector('input.single'),
}
this.playlist = new Playlist(this);
this.playlist.load();
this.init_events();
this.load();
}
Player.prototype = {
/// current item being played
sound: undefined,
on_air_url: undefined,
get sound() {
return this.playlist.sound;
},
init_events: function() {
var self = this;
function time_from_progress(event) {
bounding = self.progress.bar.getBoundingClientRect()
offset = (event.clientX - bounding.left);
return offset * self.audio.duration / bounding.width;
}
function update_info() {
var progress = self.progress;
var pos = self.audio.currentTime;
var position = self.sound.position_item;
// progress
if(!self.audio || !self.audio.seekable ||
!pos || self.audio.duration == Infinity)
{
position.innerHTML = '';
progress.bar.value = 0;
return;
if(this.data_url) {
if(!this.interval)
this.data_url = undefined;
if(this.run) {
this.run = false;
this.start();
}
progress.bar.value = pos;
progress.bar.max = self.audio.duration;
position.innerHTML = duration_str(pos);
}
}
// audio
this.audio.addEventListener('playing', function() {
self.player.setAttribute('state', 'playing');
}, false);
start() {
if(this.run || !this.interval || !this.data_url)
return;
this.run = true;
this.fetch_data();
}
this.audio.addEventListener('pause', function() {
self.player.setAttribute('state', 'paused');
}, false);
stop() {
this.run = false;
}
this.audio.addEventListener('loadstart', function() {
self.player.setAttribute('state', 'loading');
}, false);
this.audio.addEventListener('loadeddata', function() {
self.player.removeAttribute('state');
}, false);
this.audio.addEventListener('timeupdate', update_info, false);
this.audio.addEventListener('ended', function() {
self.player.removeAttribute('state');
if(!self.controls.single.checked)
self.playlist.next(true);
}, false);
// progress
progress = this.progress.bar;
progress.addEventListener('click', function(event) {
self.audio.currentTime = time_from_progress(event);
event.preventDefault();
event.stopImmediatePropagation();
}, false);
progress.addEventListener('mouseout', update_info, false);
progress.addEventListener('mousemove', function(event) {
if(self.audio.duration == Infinity || isNaN(self.audio.duration))
return;
var pos = time_from_progress(event);
var position = self.sound.position_item;
position.innerHTML = duration_str(pos);
}, false);
},
update_on_air: function() {
if(!this.on_air_url)
fetch_data() {
if(!this.run || !this.interval || !this.data_url)
return;
var self = this;
window.setTimeout(function() {
self.update_on_air();
}, 60*5000);
if(!this.playlist.on_air)
return;
var req = new XMLHttpRequest();
req.open('GET', this.on_air_url, true);
req.open('GET', this.data_url, true);
req.onreadystatechange = function() {
if(req.readyState != 4 || (req.status != 200 &&
req.status != 0))
if(req.readyState != 4 || (req.status && req.status != 200))
return;
if(!req.responseText.length)
return;
var data = JSON.parse(req.responseText)
// TODO: more consistent API
var data = JSON.parse(req.responseText);
if(data.type == 'track')
data = {
title: '♫ ' + (data.artist ? data.artist + ' — ' : '') +
name: '♫ ' + (data.artist ? data.artist + ' — ' : '') +
data.title,
url: ''
data_url: ''
}
else
data = {
title: data.title,
info: '',
url: data.url
data_url: data.url
}
var on_air = self.playlist.on_air;
on_air = on_air.item.querySelector('.content');
if(data.url)
on_air.innerHTML =
'<a href="' + data.url + '">' + data.title + '</a>';
else
on_air.innerHTML = data.title;
Object.assign(self, data);
};
req.send();
},
play: function() {
if(ActivePlayer && ActivePlayer != this) {
ActivePlayer.stop();
}
ActivePlayer = this;
if(this.run && this.interval)
this._trigger_fetch();
}
if(this.audio.paused)
this.audio.play();
else
this.audio.pause();
},
stop: function() {
this.audio.pause();
this.player.removeAttribute('state');
},
__mime_type: function(path) {
ext = path.substr(path.lastIndexOf('.')+1);
return 'audio/' + ext;
},
load_sound: function(sound) {
var audio = this.audio;
audio.pause();
var sources = audio.querySelectorAll('source');
for(var i = 0; i < sources.length; i++)
audio.removeChild(sources[i]);
streams = sound.streams;
for(var i = 0; i < streams.length; i++) {
var source = document.createElement('source');
source.src = streams[i];
source.type = this.__mime_type(source.src);
audio.appendChild(source);
}
audio.load();
},
save: function() {
// TODO: move stored sound into playlist
this.store.set('player', {
single: this.controls.single.checked,
sound: this.playlist.sound && this.playlist.sound.streams,
});
},
load: function() {
var data = this.store.get('player');
if(!data)
_trigger_fetch() {
if(!this.run || !this.data_url)
return;
this.controls.single.checked = data.single;
if(data.sound)
this.playlist.sound = this.playlist.find(data.sound);
},
var self = this;
if(this.interval)
window.setTimeout(function() {
self.fetch_data();
}, this.interval*1000);
else
this.fetch_data();
}
}
/// Current selected sound (being played)
var CurrentSound = null;
var Sound = Vue.extend({
template: '#template-sound',
delimiters: ['[[', ']]'],
data: function() {
return {
mounted: false,
// sound state,
state: State.Stop,
// current position in playing sound
position: 0,
// estimated position when user mouse over progress bar
seek_position: null,
// url to the page related to the sound
detail_url: '',
};
},
computed: {
// sound can be seeked
seekable: function() {
// seekable: for the moment only when we have a podcast file
// note: need mounted because $refs is not reactive
return this.mounted && this.duration && this.$refs.audio.seekable;
},
// sound duration in seconds
duration: function() {
if(this.track.duration)
return this.track.duration[0] * 3600 +
this.track.duration[1] * 60 +
this.track.duration[2];
return null;
},
},
props: {
track: { type: Object, required: true },
},
mounted() {
this.mounted = true;
console.log(this.track, this.track.detail_url);
this.detail_url = this.track.detail_url;
this.storage_key = "sound." + this.track.sources[0];
var pos = localStorage.getItem(this.storage_key)
if(pos) try {
// go back of 5 seconds
pos = parseFloat(pos) - 5;
if(pos > 0)
this.$refs.audio.currentTime = pos;
} catch (e) {}
},
methods: {
//
// Common methods
//
stop() {
this.$refs.audio.pause();
CurrentSound = null;
},
play(reset = false) {
if(CurrentSound && CurrentSound != this)
CurrentSound.stop();
CurrentSound = this;
if(reset)
this.$refs.audio.currentTime = 0;
this.$refs.audio.play();
},
play_stop() {
if(this.state == State.Stop)
this.play();
else
this.stop();
},
add_to_playlist() {
if(!DefaultPlaylist)
return;
var tracks = DefaultPlaylist.tracks;
if(tracks.indexOf(this.track) == -1)
DefaultPlaylist.tracks.push(this.track);
},
remove() {
this.stop();
var tracks = this.$parent.tracks;
var i = tracks.indexOf(this.track);
if(i == -1)
return;
tracks.splice(i, 1);
},
//
// Events
//
timeUpdate() {
this.position = this.$refs.audio.currentTime;
if(this.state == State.Play)
localStorage.setItem(
this.storage_key, this.$refs.audio.currentTime
);
},
ended() {
this.state = State.Stop;
this.$refs.audio.currentTime = 0;
localStorage.removeItem(this.storage_key);
this.$emit('ended', this);
},
_as_progress_time(event) {
bounding = this.$refs.progress.getBoundingClientRect()
offset = (event.clientX - bounding.left);
return offset * this.$refs.audio.duration / bounding.width;
},
progress_mouse_out(event) {
this.seek_position = null;
},
progress_mouse_move(event) {
if(this.$refs.audio.duration == Infinity ||
isNaN(this.$refs.audio.duration))
return;
this.seek_position = this._as_progress_time(event);
},
progress_clicked(event) {
this.$refs.audio.currentTime = this._as_progress_time(event);
this.play();
event.stopImmediatePropagation();
},
}
});
/// User's default playlist
DefaultPlaylist = null;
var Playlist = Vue.extend({
template: '#template-playlist',
delimiters: ['[[', ']]'],
data() {
return {
// if true, use this playlist as user's default playlist
default: false,
// single mode enabled
single_mode: false,
// playlist can be modified by user
modifiable: false,
// if set, save items into localstorage using this root key
storage_key: null,
// sounds info
tracks: [],
};
},
mounted() {
// set default
if(this.default) {
if(DefaultPlaylist)
this.tracks = DefaultPlaylist.tracks;
else
DefaultPlaylist = this;
}
// storage_key
if(this.storage_key) {
tracks = localStorage.getItem('playlist.' + this.storage_key);
if(tracks)
this.tracks = JSON.parse(tracks);
}
},
methods: {
sound_ended(sound) {
// ensure sound is stopped (beforeDestroy())
sound.stop();
// next only when single mode
if(this.single_mode)
return;
var sounds = this.$refs.sounds;
var id = sounds.findIndex(s => s == sound);
if(id < 0 || id+1 >= sounds.length)
return
id++;
sounds[id].play(true);
},
},
watch: {
tracks: {
handler() {
if(!this.storage_key)
return;
localStorage.setItem('playlist.' + this.storage_key,
JSON.stringify(this.tracks));
},
deep: true,
}
}
});
Vue.component('a-sound', Sound);
Vue.component('a-playlist', Playlist);

10798
aircox_cms/static/lib/vue.js Normal file

File diff suppressed because it is too large Load Diff

6
aircox_cms/static/lib/vue.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -58,7 +58,7 @@ class TemplateMixin(models.Model):
Rendering is similar to pages, using 'template' attribute set
by default to the app_label/sections/model_name_snake_case.html
If the default template is not found, use SectionItem's one,
If the default template is not found, use Section's one,
that can have a context attribute 'content' that is used to render
content.
"""

View File

@ -1,4 +1,4 @@
{% load staticfiles %}
{% load static %}
{% load i18n %}
{% load wagtailcore_tags %}
@ -26,14 +26,33 @@
{% block css_extras %}{% endblock %}
{% endblock %}
{% if settings.DEBUG %}
<script src="{% static 'lib/vue.js' %}">
{% else %}
<script src="{% static 'lib/vue.min.js' %}">
{% endif %}
<script src="{% static 'aircox_cms/js/bootstrap.js' %}"></script>
<script src="{% static 'aircox_cms/js/utils.js' %}"></script>
<script src="{% static 'aircox_cms/js/player.js' %}"></script>
<title>{{ page.title }}</title>
{# TODO: include vues somewhere else #}
{% include "aircox_cms/vues/player.html" %}
<script>
window.addEventListener('loaded', function() {
new Vue({
el: "#app",
delimiters: ['${', '}'],
});
}, false);
</script>
</head>
{% spaceless %}
<body>
<body id="app">
<nav class="top">
<div class="menu row">
{% render_sections position="top" %}

View File

@ -1,5 +1,6 @@
{% extends "aircox_cms/publication.html" %}
{% load i18n %}
{% load aircox_cms %}
{% block content_extras %}
{% with tracks=page.tracks.all %}
@ -32,39 +33,9 @@
</section>
{% endif %}
{% with podcasts=self.get_podcasts %}
{% if podcasts %}
<section class="podcasts list">
<h2>{% trans "Podcasts" %}</h2>
<div id="player_diff_{{ page.id }}" class="player">
{% include 'aircox_cms/snippets/player.html' %}
<script>
var podcasts = new Player('player_diff_{{ page.id }}', undefined, true)
{% for item in podcasts %}
{% if not item.embed %}
podcasts.playlist.add(new Sound(
title='{{ item.name|escape }}',
detail='{{ item.detail_url }}',
duration={{ item.duration|date:"H*3600+i*60+s" }},
streams='{{ item.url }}',
{% if page and page.cover %}cover='{{ page.icon }}',{% endif %}
undefined
));
{% endif %}
{% endfor %}
</script>
<p>
{% for item in podcasts %}
{% if item.embed %}
{{ item.embed|safe }}
{% endif %}
{% endfor %}
</p>
</div>
</section>
{% render_section section=podcasts %}
{% endif %}
{% endwith %}
{% endblock %}

View File

@ -20,11 +20,6 @@
{% with list_paginator=paginator %}
{% include "aircox_cms/snippets/list.html" %}
{% endwith %}
{% else %}
{# detail view #}
{% if page.links.count %}
{% include "aircox_cms/sections/section_link_list.html" %}
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "aircox_cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/item.html" %}
{% load wagtailimages_tags %}
{% load aircox_cms %}

View File

@ -1,4 +1,4 @@
{% extends "aircox_cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/item.html" %}
{% block content %}
{% with url=url url_text=self.url_text %}

View File

@ -1,4 +1,4 @@
{% extends "aircox_cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/item.html" %}
{% block content %}
{% with item_date_format="H:i" list_css_class="date_list" list_no_cover=True list_no_headline=True %}

View File

@ -0,0 +1,54 @@
{% extends 'aircox_cms/sections/item.html' %}
{% load staticfiles %}
{% load i18n %}
{% load aircox_cms %}
{% block content %}
{% with playlist_id="playlist"|gen_id %}
<a-playlist class="playlist" id="{{ playlist_id }}">
<noscript>
{% for track in tracks %}
<li class="item">
<span class="name">{{ track.name }} ({{ track.duration|date:"H\"i's" }}): </span>
<span class="podcast">
{% if not track.embed %}
<audio src="{{ track.url|escape }}" controls>
{% else %}
{{ track.embed|safe }}
{% endif %}
</span>
</li>
{% endfor %}
</noscript>
<script>
window.addEventListener('load', function() {
var playlist = new Playlist({
data: {
id: "{{ playlist_id }}",
{% if is_default %}
default: true,
{% endif %}
{% if single_mode %}
single_mode: true,
{% endif %}
{% if modifiable %}
modifiable: true,
{% endif %}
{% if storage_key %}
storage_key: "{{ storage_key }}",
{% endif %}
tracks: [
{% for track in tracks %}
new Track({{ track.to_json }}),
{% endfor %}
],
},
});
playlist.$mount('#{{ playlist_id }}');
}, false);
</script>
</a-playlist>
{% endwith %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "aircox_cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/item.html" %}
{% load i18n %}
{% load static %}

View File

@ -1,4 +1,4 @@
{% extends "aircox_cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/item.html" %}
{% load i18n %}
{% load static %}

View File

@ -1,25 +0,0 @@
{% extends 'aircox_cms/sections/section_item.html' %}
{% block content %}
<div id="player" class="player">
{% include "aircox_cms/snippets/player.html" %}
</div>
<script>
var player = new Player('player', '{% url 'aircox.on_air' %}', true);
var sound = player.playlist.add(
new Sound(
'{{ self.live_title }}',
'', undefined,
streams=[ {% for stream in streams %}'{{ stream }}',{% endfor %} ],
cover = undefined,
on_air = true
), undefined, 0
);
sound.item.className += ' live';
player.playlist.select(sound, false);
player.update_on_air();
</script>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "aircox_cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/item.html" %}
{% block content %}
{% include "aircox_cms/snippets/date_list.html" %}

View File

@ -1,49 +0,0 @@
{% load staticfiles %}
{% load i18n %}
<audio preload="metadata">
{% trans "Your browser does not support the <code>audio</code> element." %}
{% for stream in streams %}
<source src="{{ stream }}" />
{% endfor %}
</audio>
<div class="playlist">
<li class='item list_item flex_row' style="display: none;">
<div class="button">
<img src="" class="cover"/>
<img src="{% static "aircox/images/play.png" %}" class="play"
title="{% trans "play" %}" />
<img src="{% static "aircox/images/pause.png" %}" class="pause"
title="{% trans "pause" %}" />
<img src="{% static "aircox/images/loading.png" %}" class="loading"
title="{% trans "loading..." %}" />
</div>
<div class="flex_item">
<h3 class="title flex_item">{{ self.live_title }}</h3>
<div class="content flex_row">
<span class="info position flex_item"></span>
<span class="info duration flex_item"></span>
</div>
</div>
<span class="actions">
<a class="action add" title="{% trans "add to the player" %}">+</a>
<a class="action detail" title="{% trans "more informations" %}"></a>
<a class="action remove" title="{% trans "remove this sound" %}"></a>
</span>
</li>
</div>
<div class="controls">
<div class="progress">
<!-- <div class="info duration"></span> -->
<progress class="flex_item progress" value="0" max="1"></progress>
</div>
<input type="checkbox" class="single" id="player_single_mode">
<label for="player_single_mode" class="info"
title="{% trans "enable and disable single mode" %}">↻</label>
</div>

View File

@ -0,0 +1,82 @@
{% load staticfiles %}
{% load i18n %}
<script type="text/x-template" id="template-sound">
<div class="sound">
<audio preload="metadata" ref="audio"
@pause="state = State.Stop"
@playing="state = State.Play"
@ended="ended"
@timeupdate="timeUpdate"
>
<source v-for="source in track.sources" :src="source">
</audio>
<img :src="track.cover" v-if="track.cover" class="icon cover">
<button class="button" @click="play_stop">
<img class="icon pause"
src="{% static "aircox/images/pause.png" %}"
title="{% trans "Click to pause" %}"
v-if="state === State.Play" >
<img class="icon loading"
src="{% static "aircox/images/loading.png" %}"
title="{% trans "Loading... Click to pause" %}"
v-else-if="state === State.Loading" >
<img class="icon play"
src="{% static "aircox/images/play.png" %}"
title="{% trans "Click to play" %}"
v-else >
</button>
<div>
<h3>
<a :href="detail_url">[[ track.name ]]</a>
</h3>
<span v-if="track.duration" class="info">
[[ (track.duration[0] && track.duration[0] + '"') || '' ]]
[[ track.duration[1] + "'" + track.duration[2] ]]
</span>
</div>
<div class="actions">
<a class="action remove"
title="{% trans "Remove from playlist" %}"
v-if="this.$parent.modifiable"
@click="remove"
>✖</a>
<a class="action add"
title="{% trans "Add to my playlist" %}"
@click="add_to_playlist"
v-else
>+</a>
</div>
<div class="content flex_row" v-show="track.duration != null">
<span v-if="seek_position !== null">[[ seek_position ]]</span>
<span v-else>[[ position ]]</span>
<progress class="flex_item progress" ref="progress"
v-show="track.duration"
v-on:click.prevent="progress_clicked"
v-on:mousemove = "progress_mouse_move"
v-on:mouseout = "progress_mouse_out"
:value="position" :max="duration"
></progress>
</div>
</div>
</script>
<script type="text/x-template" id="template-playlist">
<div class="playlist">
<a-sound v-for="track in tracks" ref="sounds"
:id="track.id" :track="track"
@ended="sound_ended"
@beforeDestroy="sound_ended"
/>
<div v-show="tracks.length > 1" class="playlist_footer">
<input type="checkbox" class="single" id="[[ playlist ]]_single_mode"
value="true" v-model="single_mode">
<label for="[[ playlist ]]_single_mode" class="info"
title="{% trans "Enable and disable single mode" %}">↻</label>
</div>
</div>
</script>

View File

@ -1,10 +1,31 @@
import random
from django import template
from django.utils.safestring import mark_safe
from aircox_cms.sections import Section
from aircox_cms.models.sections import Region
register = template.Library()
@register.filter
def gen_id(prefix, sep = "-"):
"""
Generate a random element id
"""
return sep.join([
prefix,
str(random.random())[2:],
str(random.random())[2:],
])
@register.filter
def concat(a,b):
"""
Concat two strings together
"""
return str(a) + str(b)
@register.filter
def around(page_num, n):
"""
@ -12,11 +33,25 @@ def around(page_num, n):
"""
return range(page_num-n, page_num+n+1)
@register.simple_tag(takes_context=True)
def render_section(context, section, **kwargs):
"""
Render a section from the current page. By default retrieve required
information from the context
"""
return mark_safe(section.render(
context = context.flatten(),
request = context['request'],
page = context['page'],
**kwargs
))
@register.simple_tag(takes_context=True)
def render_sections(context, position = None):
"""
Render all sections at the given position (filter out base on page
models' too, cf. Section.model).
models' too, cf. Region.model).
"""
request = context.get('request')
page = context.get('page')
@ -24,7 +59,7 @@ def render_sections(context, position = None):
section.render(request, page=page, context = {
'settings': context.get('settings')
})
for section in Section.get_sections_at(position, page)
for section in Region.get_sections_at(position, page)
))
@register.simple_tag(takes_context=True)

View File

@ -1,6 +1,13 @@
import inspect
from django.core.urlresolvers import reverse
from wagtail.wagtailcore.models import Page
def image_url(image, filter_spec):
"""
Return an url for the given image -- shortcut function for
wagtailimages' serve.
"""
from wagtail.wagtailimages.views.serve import generate_signature
signature = generate_signature(image.id, filter_spec)
url = reverse('wagtailimages_serve', args=(signature, image.id, filter_spec))
@ -8,12 +15,41 @@ def image_url(image, filter_spec):
return url
def get_station_settings(station):
"""
Get WebsiteSettings for the given station.
"""
import aircox_cms.models as models
return models.WebsiteSettings.objects \
.filter(station = station).first()
def get_station_site(station):
"""
Get the site of the given station.
"""
settings = get_station_settings(station)
return settings and settings.site
def related_pages_filter(reset_cache=False):
"""
Return a dict that can be used to filter foreignkey to pages'
subtype declared in aircox_cms.models.
This value is stored in cache, but it is possible to reset the
cache using the `reset_cache` parameter.
"""
import aircox_cms.models as cms
if not reset_cache and hasattr(related_pages_filter, 'cache'):
return related_pages_filter.cache
related_pages_filter.cache = {
'model__in': list(name.lower() for name, member in
inspect.getmembers(cms,
lambda x: inspect.isclass(x) and issubclass(x, Page)
)
if member != Page
),
}
return related_pages_filter.cache

View File

@ -1,33 +1 @@
#from django.shortcuts import render
#import django.views.generic as generic
#
#import foxcms.views as Views
#
#import aircox.sections as sections
#
#class DynamicListView(Views.View, generic.list.ListView):
# list_info = None
#
# def get_queryset(self):
# self.list_info = {}
# return sections.ListBase.from_request(request, context = self.list_info)
#
# #def get_ordering(self):
# # order = self.request.GET.get('order_by')
# # if order:
# # field = order[1:] if order['-'] else order
# # else:
# # field = 'pk'
# # if field not in self.model.ordering_fields:
# # return super().get_ordering()
# # TODO replace 'asc' in ListBase into sorting field
#
# def get_context_data(self, *args, **kwargs
# context = super().get_context_data(*args, **kwargs)
# if self.list_info:
# context.update(self.list_info)
# return context

View File

View File

@ -0,0 +1,111 @@
import json
from django.db import models
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from wagtail.wagtailcore.utils import camelcase_to_underscore
class Component:
"""
A Component is a small part of a rendered web page. It can be used
to create elements configurable by users.
"""
template_name = ""
"""
[class] Template file path
"""
hide = False
"""
The component can be hidden because there is no reason to display it
(e.g. empty list)
"""
@classmethod
def snake_name(cl):
if not hasattr(cl, '_snake_name'):
cl._snake_name = camelcase_to_underscore(cl.__name__)
return cl._snake_name
def get_context(self, request, page):
"""
Context attributes:
* self: section being rendered
* page: current page being rendered
* request: request used to render the current page
Other context attributes usable in the default section template:
* content: **safe string** set as content of the section
* hide: DO NOT render the section, render only an empty string
"""
return {
'self': self,
'page': page,
'request': request,
}
def render(self, request, page, context, *args, **kwargs):
"""
Render the component. ``Page`` is the current page being
rendered.
"""
# use a different object
context_ = self.get_context(request, *args, page=page, **kwargs)
if self.hide:
return ''
if context:
context_.update({
k: v for k, v in context.items()
if k not in context_
})
context_['page'] = page
return render_to_string(self.template_name, context_)
class ExposedData:
"""
Data object that aims to be exposed to Javascript. This provides
various utilities.
"""
model = None
"""
[class attribute] Related model/class object that is to be exposed
"""
fields = {}
"""
[class attribute] Fields of the model to be exposed, as a dict of
``{ exposed_field: model_field }``
``model_field`` can either be a function(exposed, object) or a field
name.
"""
data = None
"""
Exposed data of the instance
"""
def __init__(self, object = None, **kwargs):
self.data = {}
if object:
self.from_object(object)
self.data.update(kwargs)
def from_object(self, object):
fields = type(self).fields
for k,v in fields.items():
if self.data.get(k) != None:
continue
v = v(self, object) if callable(v) else \
getattr(object, v) if hasattr(object, v) else \
None
self.data[k] = v
def to_json(self):
"""
Return a json string of encoded data.
"""
return mark_safe(json.dumps(self.data))

View File

@ -1,13 +1,12 @@
gunicorn>=19.6.0
Django>=1.10.3
Django>=1.10.3,<2.0
wagtail>=1.5.3,<2.0
django-taggit>=0.18.3
watchdog>=0.8.3
psutil>=5.0.1
pyyaml>=3.12
dateutils>=0.6.6
bleach>=1.4.3
django-htmlmin>=0.10.0
wagtail>=1.5.3
django-overextends>=0.4.2
Pillow>=3.3.0
django-modelcluster==2.0