diff --git a/cms/models.py b/cms/models.py index fcd0d0e..ba65c57 100644 --- a/cms/models.py +++ b/cms/models.py @@ -414,6 +414,9 @@ class DiffusionPage(Publication): related_name = 'page', on_delete=models.SET_NULL, null=True, + limit_choices_to = { + 'initial__isnull': True, + }, ) class Meta: @@ -423,7 +426,7 @@ class DiffusionPage(Publication): content_panels = [ FieldPanel('diffusion'), ] + Publication.content_panels + [ - InlinePanel('tracks', label=_('Tracks')) + InlinePanel('tracks', label=_('Tracks')), ] diff --git a/cms/sections.py b/cms/sections.py index 1a90c03..fec4362 100644 --- a/cms/sections.py +++ b/cms/sections.py @@ -890,8 +890,8 @@ class SectionLogsList(SectionItem): @register_snippet class SectionTimetable(SectionItem,DatedListBase): class Meta: - verbose_name = _('timetable') - verbose_name_plural = _('timetable') + verbose_name = _('Section: Timetable') + verbose_name_plural = _('Sections: Timetable') panels = SectionItem.panels + DatedListBase.panels @@ -914,8 +914,8 @@ class SectionTimetable(SectionItem,DatedListBase): @register_snippet class SectionPublicationInfo(SectionItem): class Meta: - verbose_name = _('section with publication\'s info') - verbose_name = _('sections with publication\'s info') + verbose_name = _('Section: publication\'s info') + verbose_name_plural = _('Sections: publication\'s info') @register_snippet class SectionSearchField(SectionItem): @@ -933,8 +933,8 @@ class SectionSearchField(SectionItem): ) class Meta: - verbose_name = _('search field') - verbose_name_plural = _('search fields') + verbose_name = _('Section: search field') + verbose_name_plural = _('Sections: search field') panels = SectionItem.panels + [ PageChooserPanel('page'), @@ -946,6 +946,32 @@ class SectionSearchField(SectionItem): context = super().get_context(request, page) list_page = self.page or ListPage.objects.live().first() context['list_page'] = list_page - print(context, self.template) return context + +@register_snippet +class SectionPlayer(SectionItem): + 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 = SectionItem.panels + [ + FieldPanel('live_title'), + FieldPanel('streams'), + ] + + def get_context(self, request, page): + context = super().get_context(request, page) + context['streams'] = self.streams.split('\r\n') + return context + + diff --git a/cms/static/cms/js/player.js b/cms/static/cms/js/player.js new file mode 100644 index 0000000..144b576 --- /dev/null +++ b/cms/static/cms/js/player.js @@ -0,0 +1,280 @@ +// TODO +// - multiple sources for an item +// - 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; + var minutes = Math.floor(seconds / 60); + seconds -= minutes; + + var str = hours ? (hours < 10 ? '0' + hours : hours) + ':' : ''; + str += (minutes < 10 ? '0' + minutes : minutes) + ':'; + str += (seconds < 10 ? '0' + seconds : seconds); + return str; +} + + +function Sound(title, detail, stream, duration) { + this.title = title; + this.detail = detail; + this.stream = stream; + this.duration = duration; +} + +Sound.prototype = { + title: '', + detail: '', + stream: '', + duration: undefined, + + item: undefined, + + get seekable() { + return this.duration != undefined; + }, +} + + +function PlayerPlaylist(player) { + this.player = player; + this.playlist = player.player.querySelector('.playlist'); + this.item_ = player.player.querySelector('.playlist .item'); + this.items = [] +} + +PlayerPlaylist.prototype = { + items: undefined, + + find: function(stream) { + return this.items.find(function(v) { + return v.stream == item; + }); + }, + + add: function(sound, container) { + if(this.find(sound.stream)) + return; + + var item = this.item_.cloneNode(true); + item.removeAttribute('style'); + + console.log(sound) + item.querySelector('.title').innerHTML = sound.title; + if(sound.seekable) + item.querySelector('.duration').innerHTML = + duration_str(sound.duration); + if(sound.detail) + item.querySelector('.detail').href = sound.detail; + + item.sound = sound; + sound.item = item; + + var self = this; + item.querySelector('.action.remove').addEventListener( + 'click', function(event) { self.remove(sound); }, false + ); + + (container || this.playlist).appendChild(item); + this.items.push(sound); + this.save(); + }, + + remove: function(sound) { + var index = this.items.indexOf(sound); + if(index != -1) + this.items.splice(index,1); + this.playlist.removeChild(sound.item); + this.save(); + }, + + save: function() { + var list = []; + for(var i in this.items) { + var sound = Object.assign({}, this.items[i]) + delete sound.item; + list.push(sound); + } + this.player.store.set('playlist', list); + }, + + load: function() { + var list = []; + var container = document.createDocumentFragment(); + for(var i in list) + this.add(list[i], container) + this.playlist.appendChild(container); + }, +} + + +function Player(id) { + this.store = new Store('player'); + + // html items + this.player = document.getElementById(id); + this.box = this.player.querySelector('.box'); + this.audio = this.player.querySelector('audio'); + this.controls = { + duration: this.box.querySelector('.duration'), + progress: this.player.querySelector('progress'), + single: this.player.querySelector('input.single'), + } + + this.playlist = new PlayerPlaylist(this); + this.playlist.load(); + + this.init_events(); + this.load(); +} + +Player.prototype = { + /// current item being played + sound: undefined, + + init_events: function() { + var self = this; + + function time_from_progress(event) { + bounding = self.controls.progress.getBoundingClientRect() + offset = (event.clientX - bounding.left); + return offset * self.audio.duration / bounding.width; + } + + function update_info() { + var controls = self.controls; + // progress + if( !self.sound.seekable || + self.audio.duration == Infinity) { + controls.duration.innerHTML = ''; + controls.progress.value = 0; + return; + } + + var pos = self.audio.currentTime; + controls.progress.value = pos; + controls.progress.max = self.audio.duration; + controls.duration.innerHTML = duration_str(sound.duration); + } + + // audio + this.audio.addEventListener('playing', function() { + self.player.setAttribute('state', 'playing'); + }, false); + + this.audio.addEventListener('pause', function() { + self.player.setAttribute('state', 'paused'); + }, false); + + this.audio.addEventListener('loadstart', function() { + self.player.setAttribute('state', 'stalled'); + }, false); + + this.audio.addEventListener('loadeddata', function() { + self.player.removeAttribute('state'); + }, false); + + this.audio.addEventListener('timeupdate', update_info, false); + + this.audio.addEventListener('ended', function() { + if(!self.controls.single.checked) + self.next(true); + }, false); + + // buttons + this.box.querySelector('button.play').onclick = function() { + self.play(); + }; + + // progress + progress = this.controls.progress; + progress.addEventListener('click', function(event) { + player.audio.currentTime = time_from_progress(event); + }, false); + + progress.addEventListener('mouseout', update_info, false); + + progress.addEventListener('mousemove', function(event) { + if(self.audio.duration == Infinity) + return; + + var pos = time_from_progress(event); + self.controls.duration.innerHTML = duration_str(pos); + }, false); + }, + + play: function() { + if(this.audio.paused) + this.audio.play(); + else + this.audio.pause(); + }, + + unselect: function(sound) { + sound.item.removeAttribute('selected'); + }, + + select: function(sound, play = true) { + if(this.sound) + this.unselect(this.sound); + + this.audio.pause(); + + // if stream is a list, use + if(sound.stream.splice) { + this.audio.src=""; + + var sources = this.audio.querySelectorAll('source'); + for(var i in sources) + this.audio.removeChild(sources[i]); + + for(var i in sound.stream) { + var source = document.createElement('source'); + source.src = sound.stream[i]; + } + } + else + this.audio.src = sound.stream; + this.audio.load(); + + this.sound = sound; + sound.item.setAttribute('selected', 'true'); + + this.box.querySelector('.title').innerHTML = sound.title; + if(play) + this.play(); + }, + + next: function() { + var index = this.playlist.items.indexOf(this.sound); + if(index < 0) + return; + + index++; + if(index < this.playlist.items.length) + this.select(this.playlist.items[index], true); + }, + + save: function() { + this.store.set('player', { + single: this.controls.single.checked, + sound: this.sound && this.sound.stream, + }); + }, + + load: function() { + var data = this.store.get('player'); + this.controls.single.checked = data.single; + this.sound = this.playlist.find(data.stream); + }, + + update_on_air: function() { + + }, +} + + diff --git a/cms/static/cms/js/utils.js b/cms/static/cms/js/utils.js new file mode 100644 index 0000000..a028efb --- /dev/null +++ b/cms/static/cms/js/utils.js @@ -0,0 +1,68 @@ + +/// 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'); +} + + +/// Utility to store objects in local storage. Data are stringified in JSON +/// format in order to keep type. +function Store(prefix) { + this.prefix = prefix; +} + +Store.prototype = { + // save data to localstorage, or remove it if data is null + set: function(key, data) { + key = this.prefix + '.' + key; + if(data == undefined) { + localStorage.removeItem(prefix); + return; + } + localStorage.setItem(key, JSON.stringify(data)) + }, + + // load data from localstorage + get: function(key) { + try { + key = this.prefix + '.' + key; + var data = localStorage.getItem(key); + if(data) + return JSON.parse(data); + } + catch(e) { console.log(e, data); } + }, + + // return true if the given item is stored + exists: function(key) { + key = this.prefix + '.' + key; + return (localStorage.getItem(key) != null); + }, + + // update a field in the stored data + update: function(key, field_key, value) { + data = this.get(key) || {}; + if(value) + data[field_key] = value; + else + delete data[field_key]; + this.set(key, data); + }, +} + + diff --git a/cms/templates/cms/base_site.html b/cms/templates/cms/base_site.html index aa6c86a..11033f3 100644 --- a/cms/templates/cms/base_site.html +++ b/cms/templates/cms/base_site.html @@ -25,6 +25,9 @@ {% block css_extras %}{% endblock %} {% endblock %} + + + {{ page.title }} diff --git a/cms/templates/cms/sections/section_player.html b/cms/templates/cms/sections/section_player.html new file mode 100644 index 0000000..e5feb80 --- /dev/null +++ b/cms/templates/cms/sections/section_player.html @@ -0,0 +1,61 @@ +{% extends 'cms/sections/section_item.html' %} + +{% load staticfiles %} +{% load i18n %} + + +{% block content %} + +
+
+ + + + +

{{ self.live_title }}

+ +
+
+ + + + +
+
+
+ +
+
+

+ +
+
+ + +{% endblock %} + + + diff --git a/cms/templates/cms/snippets/date_list.html b/cms/templates/cms/snippets/date_list.html index 6408e08..960e553 100644 --- a/cms/templates/cms/snippets/date_list.html +++ b/cms/templates/cms/snippets/date_list.html @@ -1,27 +1,5 @@ {% load i18n %} - - {# FIXME: get current complete URL #}
{% if nav_dates %} @@ -31,7 +9,7 @@ {% endif %} {% for day in nav_dates.dates %} - {{ day|date:'D. d' }} @@ -47,7 +25,7 @@ {% for day, list in object_list %}