hot reload

This commit is contained in:
bkfox 2020-11-08 00:45:49 +01:00
parent 222300945e
commit 5fd72c33cc
19 changed files with 473 additions and 105 deletions

View File

@ -6,6 +6,7 @@ from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property from django.utils.functional import cached_property
from easy_thumbnails.files import get_thumbnailer
from aircox import settings, utils from aircox import settings, utils
from .program import Program, ProgramChildQuerySet, \ from .program import Program, ProgramChildQuerySet, \
@ -25,6 +26,24 @@ class Episode(Page):
def program(self): def program(self):
return getattr(self.parent, 'program', None) return getattr(self.parent, 'program', None)
@cached_property
def podcasts(self):
""" Return serialized data about podcasts. """
from ..serializers import PodcastSerializer
podcasts = [PodcastSerializer(s).data
for s in self.sound_set.public().order_by('type') ]
if self.cover:
options = {'size': (128,128), 'crop':'scale'}
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
else:
cover = None
for index, podcast in enumerate(podcasts):
podcasts[index]['cover'] = cover
podcasts[index]['page_url'] = self.get_absolute_url()
podcasts[index]['page_title'] = self.title
return podcasts
@program.setter @program.setter
def program(self, value): def program(self, value):
self.parent = value self.parent = value

View File

@ -7463,22 +7463,31 @@ section > .toolbar {
padding: 1em; padding: 1em;
margin-bottom: 1.5em; } margin-bottom: 1.5em; }
main .cover { main .cover.is-small {
margin: 1em 0em;
border: 0.2em black solid; }
main .small-cover {
width: 10em; } width: 10em; }
main .cover.is-tiny {
height: 2em; }
.sound-item .cover {
height: 5em; }
aside > section { aside > section {
margin-bottom: 2em; } margin-bottom: 2em; }
aside .cover { aside .cover {
margin-bottom: 2em; } margin-bottom: 2em; }
aside .small-cover { aside .cover.is-small {
width: 4em; } width: 10em; }
aside .cover.is-tiny {
height: 2em; }
aside .media .subtitle { aside .media .subtitle {
font-size: 1em; } font-size: 1em; }
.is-round, .sound-item .button {
border: 1px #7a7a7a solid;
border-radius: 1em; }

File diff suppressed because one or more lines are too long

View File

@ -7442,22 +7442,31 @@ section > .toolbar {
padding: 1em; padding: 1em;
margin-bottom: 1.5em; } margin-bottom: 1.5em; }
main .cover { main .cover.is-small {
margin: 1em 0em;
border: 0.2em black solid; }
main .small-cover {
width: 10em; } width: 10em; }
main .cover.is-tiny {
height: 2em; }
.sound-item .cover {
height: 5em; }
aside > section { aside > section {
margin-bottom: 2em; } margin-bottom: 2em; }
aside .cover { aside .cover {
margin-bottom: 2em; } margin-bottom: 2em; }
aside .small-cover { aside .cover.is-small {
width: 4em; } width: 10em; }
aside .cover.is-tiny {
height: 2em; }
aside .media .subtitle { aside .media .subtitle {
font-size: 1em; } font-size: 1em; }
.is-round, .sound-item .button {
border: 1px #7a7a7a solid;
border-radius: 1em; }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,19 +2,11 @@
{% comment %}List of a show's episodes for a specific{% endcomment %} {% comment %}List of a show's episodes for a specific{% endcomment %}
{% load i18n aircox %} {% load i18n aircox %}
{% block head_extra %}
<script id="page">
window.addEventListener('load', ev => {
Vue.set(aircox.app, 'page', {
podcasts: new aircox.Set(aircox.Sound, {items: {{ podcasts|json|safe }}})
});
});
</script>
{% endblock head_extra %}
{% include "aircox/program_sidebar.html" %} {% include "aircox/program_sidebar.html" %}
{% block content %} {% block content %}
<a-episode :page="{title: &quot;{{ page.title }}&quot;, podcasts: {{ object.podcasts|json }}}">
<template v-slot="{podcasts,page}">
{{ block.super }} {{ block.super }}
<div class="columns is-desktop"> <div class="columns is-desktop">
@ -46,9 +38,9 @@
</ul> </ul>
</section> </section>
{% if podcasts %} {% if object.podcasts %}
<section class="column"> <section class="column">
<a-playlist v-if="page" :set="page.podcasts" <a-playlist v-if="page" :set="podcasts"
name="{{ page.title }}" name="{{ page.title }}"
:player="player" :actions="['play']" :player="player" :actions="['play']"
@select="player.playItems('queue', $event.item)"> @select="player.playItems('queue', $event.item)">
@ -81,5 +73,6 @@
</section> </section>
{% endif %} {% endif %}
</template></a-episode>
{% endblock %} {% endblock %}

View File

@ -41,3 +41,15 @@ Context variables:
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block actions %}
{% if object.sound_set.public.count %}
<button class="button is-round" @click="player.playButtonClick($event)"
data-sounds="{{ object.podcasts|json }}">
<span class="icon is-small">
<span class="fas fa-play"></span>
</span>
</button>
{% endif %}
{% endblock %}

View File

@ -30,7 +30,7 @@ Context variables:
{% if has_cover|default_if_none:True %} {% if has_cover|default_if_none:True %}
<div class="media-left"> <div class="media-left">
<img src="{% thumbnail object.cover|default:station.default_cover 128x128 crop=scale %}" <img src="{% thumbnail object.cover|default:station.default_cover 128x128 crop=scale %}"
class="small-cover"> class="cover is-small">
</div> </div>
{% endif %} {% endif %}
<div class="media-content"> <div class="media-content">
@ -55,6 +55,8 @@ Context variables:
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% block actions %}{% endblock %}
</article> </article>
{% endif %} {% endif %}

View File

@ -4,7 +4,6 @@ import datetime
from django.views.generic import ListView from django.views.generic import ListView
from ..models import Diffusion, Episode, Program, StaticPage, Sound from ..models import Diffusion, Episode, Program, StaticPage, Sound
from ..serializers import PodcastSerializer
from .base import BaseView from .base import BaseView
from .program import ProgramPageDetailView from .program import ProgramPageDetailView
from .page import PageListView from .page import PageListView
@ -20,8 +19,6 @@ class EpisodeDetailView(ProgramPageDetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
if not 'tracks' in kwargs: if not 'tracks' in kwargs:
kwargs['tracks'] = self.object.track_set.order_by('position') kwargs['tracks'] = self.object.track_set.order_by('position')
if not 'podcasts' in kwargs:
kwargs['podcasts'] = [PodcastSerializer(s).data for s in self.object.sound_set.public() ]
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -1,6 +1,5 @@
import Vue from 'vue'; import Vue from 'vue';
export const defaultConfig = { export const defaultConfig = {
el: '#app', el: '#app',
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
@ -12,12 +11,12 @@ export const defaultConfig = {
}, },
computed: { computed: {
player() { return window.aircox.player; } player() { return window.aircox.player; },
}, },
} }
export default function App(config) { export default function App(config, sync=false) {
return (new AppConfig(config)).load() return (new AppConfig(config)).load(sync)
} }
/** /**
@ -30,30 +29,36 @@ export class AppConfig {
get config() { get config() {
let config = this._config instanceof Function ? this._config() : this._config; let config = this._config instanceof Function ? this._config() : this._config;
return {...defaultConfig, ...config}; for(var k of new Set([...Object.keys(config || {}), ...Object.keys(defaultConfig)])) {
if(!config[k] && defaultConfig[k])
config[k] = defaultConfig[k]
else if(config[k] instanceof Object)
config[k] = {...defaultConfig[k], ...config[k]}
}
return config;
} }
set config(value) { set config(value) {
this._config = value; this._config = value;
} }
load() { load(sync=false) {
var self = this; var self = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
window.addEventListener('load', () => { let func = () => { try { resolve(self.build()) } catch(error) { reject(error) }};
try { sync ? func() : window.addEventListener('load', func);
let config = self.config;
const el = document.querySelector(config.el)
if(!el) {
reject(`Error: missing element ${config.el}`);
return;
}
resolve(new Vue(config))
}
catch(error) { reject(error) }
})
}); });
} }
build() {
let config = this.config;
const el = document.querySelector(config.el)
if(!el) {
reject(`Error: missing element ${config.el}`);
return;
}
return new Vue(config);
}
} }

23
assets/public/episode.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<div>
<slot :page="page" :podcasts="podcasts"></slot>
</div>
</template>
<script>
import {Set} from './model';
import Sound from './sound';
import Page from './page';
export default {
extends: Page,
data() {
return {
podcasts: new Set(Sound, {items:this.page.podcasts}),
}
},
}
</script>

View File

@ -16,12 +16,14 @@ import {Set} from './model';
import './styles.scss'; import './styles.scss';
import Autocomplete from './autocomplete.vue'; import Autocomplete from './autocomplete';
import Player from './player.vue'; import Episode from './episode';
import Playlist from './playlist.vue'; import Player from './player';
import Playlist from './playlist';
import SoundItem from './soundItem'; import SoundItem from './soundItem';
Vue.component('a-autocomplete', Autocomplete) Vue.component('a-autocomplete', Autocomplete)
Vue.component('a-episode', Episode)
Vue.component('a-player', Player) Vue.component('a-player', Player)
Vue.component('a-playlist', Playlist) Vue.component('a-playlist', Playlist)
Vue.component('a-sound-item', SoundItem) Vue.component('a-sound-item', SoundItem)
@ -42,14 +44,40 @@ window.aircox = {
return this.playerApp && this.playerApp.$refs.player return this.playerApp && this.playerApp.$refs.player
}, },
loadPage(url) {
fetch(url).then(response => response.text())
.then(response => {
let doc = new DOMParser().parseFromString(response, 'text/html');
aircox.app && aircox.app.$destroy();
document.getElementById('app').innerHTML = doc.getElementById('app').innerHTML;
App(() => window.aircox.appConfig, true).then(app => {
aircox.app = app;
document.title = doc.title;
})
});
},
Set: Set, Sound: Sound, Set: Set, Sound: Sound,
}; };
window.Vue = Vue; window.Vue = Vue;
App({el: '#player'}).then(app => window.aircox.playerApp = app, App({el: '#player'}).then(app => window.aircox.playerApp = app);
() => undefined); App(() => window.aircox.appConfig).then(app => {
App(() => window.aircox.appConfig).then(app => { window.aircox.app = app }, window.aircox.app = app;
() => undefined) window.addEventListener('click', event => {
let target = event.target.tagName == 'A' ? event.target : event.target.closest('a');
if(!target || !target.hasAttribute('href'))
return;
let href = target.getAttribute('href');
if(href && href !='#') {
window.aircox.loadPage(href);
event.preventDefault();
event.stopPropagation();
}
}, true);
})

View File

@ -42,9 +42,14 @@ export default {
find(pred) { return this.set.find(pred) }, find(pred) { return this.set.find(pred) },
findIndex(pred) { return this.set.findIndex(pred) }, findIndex(pred) { return this.set.findIndex(pred) },
push(...items) { /**
* Add items to list, return index of the first provided item.
*/
push(item, ...items) {
let index = this.set.push(item);
for(var item of items) for(var item of items)
this.set.push(item); this.set.push(item);
return index;
}, },
remove(index, select=False) { remove(index, select=False) {

View File

@ -176,20 +176,21 @@ export class Set {
} }
/** /**
* Add item to set * Add item to set, return index.
*/ */
push(item, {args={},save=true}={}) { push(item, {args={},save=true}={}) {
item = item instanceof this.model ? item : new this.model(item, args); item = item instanceof this.model ? item : new this.model(item, args);
if(this.unique) { if(this.unique) {
let index = this.findIndex(item); let index = this.findIndex(item);
if(index > -1) if(index > -1)
return; return index;
} }
if(this.max && this.items.length >= this.max) if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max) this.items.splice(0,this.items.length-this.max)
this.items.push(item); this.items.push(item);
save && this.save(); save && this.save();
return this.items.length-1;
} }
/** /**

20
assets/public/page.vue Normal file
View File

@ -0,0 +1,20 @@
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
data() {
return {}
},
props: {
page: Object,
title: String,
},
}
</script>

View File

@ -3,6 +3,7 @@
<div :class="['player-panels', panel ? 'is-open' : '']"> <div :class="['player-panels', panel ? 'is-open' : '']">
<Playlist ref="pin" class="player-panel menu" v-show="panel == 'pin'" <Playlist ref="pin" class="player-panel menu" v-show="panel == 'pin'"
name="Pinned" name="Pinned"
:actions="['page']"
:editable="true" :player="self" :set="sets.pin" @select="play('pin', $event.index)" :editable="true" :player="self" :set="sets.pin" @select="play('pin', $event.index)"
listClass="menu-list" itemClass="menu-item"> listClass="menu-list" itemClass="menu-item">
<template v-slot:header=""> <template v-slot:header="">
@ -13,6 +14,7 @@
</template> </template>
</Playlist> </Playlist>
<Playlist ref="queue" class="player-panel menu" v-show="panel == 'queue'" <Playlist ref="queue" class="player-panel menu" v-show="panel == 'queue'"
:actions="['page']"
:editable="true" :player="self" :set="sets.queue" @select="play('queue', $event.index)" :editable="true" :player="self" :set="sets.queue" @select="play('queue', $event.index)"
listClass="menu-list" itemClass="menu-item"> listClass="menu-list" itemClass="menu-item">
<template v-slot:header=""> <template v-slot:header="">
@ -26,12 +28,11 @@
<div class="player-bar media"> <div class="player-bar media">
<div class="media-left"> <div class="media-left">
<div class="button" @click="togglePlay()" <button class="button" @click="togglePlay()"
:title="buttonTitle" :aria-label="buttonTitle"> :title="buttonTitle" :aria-label="buttonTitle">
<span class="fas fa-pause" v-if="playing"></span> <span class="fas fa-pause" v-if="playing"></span>
<span class="fas fa-play" v-else></span> <span class="fas fa-play" v-else></span>
</div> </button>
<slot name="sources"></slot>
</div> </div>
<div class="media-left media-cover" v-if="current && current.cover"> <div class="media-left media-cover" v-if="current && current.cover">
<img :src="current.cover" class="cover" /> <img :src="current.cover" class="cover" />
@ -218,13 +219,16 @@ export default {
/// Push and play items /// Push and play items
playItems(playlist, ...items) { playItems(playlist, ...items) {
this.push(playlist, ...items); let index = this.push(playlist, ...items);
let index = this.$refs[playlist].findIndex(items[0]);
this.$refs[playlist].selectedIndex = index; this.$refs[playlist].selectedIndex = index;
this.play(playlist, index); this.play(playlist, index);
}, },
playButtonClick(event) {
var items = JSON.parse(event.currentTarget.dataset.sounds);
this.playItems('queue', ...items);
},
/// Play live stream /// Play live stream
playLive() { playLive() {
this.load(null, {src: this.live.src}); this.load(null, {src: this.live.src});
@ -266,10 +270,6 @@ export default {
}, },
}, },
mounted() {
this.sources = this.$slots.sources;
},
components: { Playlist, Progress }, components: { Playlist, Progress },
} }
</script> </script>

View File

@ -1,7 +1,10 @@
<template> <template>
<div class="media"> <div class="media sound-item">
<div class="media-left" v-if="hasAction('play')"> <div class="media-left">
<button class="button" @click="$emit('togglePlay')"> <img class="cover is-tiny" :src="item.data.cover" v-if="item.data.cover">
</div>
<div class="media-left">
<button class="button" @click.stop="$emit('togglePlay')">
<div class="icon"> <div class="icon">
<span class="fa fa-pause" v-if="playing"></span> <span class="fa fa-pause" v-if="playing"></span>
<span class="fa fa-play" v-else></span> <span class="fa fa-play" v-else></span>
@ -10,9 +13,14 @@
</div> </div>
<div class="media-content"> <div class="media-content">
<slot name="content" :player="player" :item="item" :loaded="loaded"> <slot name="content" :player="player" :item="item" :loaded="loaded">
<h4 class="title is-4 is-inline-block"> <h4 class="title is-4">{{ name || item.name }}</h4>
{{ name || item.name }} <a class="subtitle is-6" v-if="hasAction('page') && item.data.page_url"
</h4> :href="item.data.page_url">
<i class="icon">
<i class="fas fa-link"></i>
</i>
{{ item.data.page_title }}
</a>
</slot> </slot>
</div> </div>
<div class="media-right"> <div class="media-right">
@ -33,7 +41,6 @@ export default {
props: { props: {
data: {type: Object, default: x => {}}, data: {type: Object, default: x => {}},
name: String, name: String,
cover: String,
player: Object, player: Object,
page_url: String, page_url: String,
actions: {type:Array, default: x => []}, actions: {type:Array, default: x => []},

View File

@ -167,7 +167,6 @@ a.navbar-item.is-active {
} }
//-- player //-- player
.player { .player {
box-shadow: 0em 1.5em 2.5em rgba(0, 0, 0, 0.6); box-shadow: 0em 1.5em 2.5em rgba(0, 0, 0, 0.6);
@ -257,16 +256,12 @@ section > .toolbar {
main { main {
.cover { .cover.is-small { width: 10em; }
margin: 1em 0em; .cover.is-tiny { height: 2em; }
border: 0.2em black solid;
}
.small-cover {
width: 10em;
}
} }
.sound-item .cover { height: 5em; }
aside { aside {
& > section { & > section {
margin-bottom: 2em; margin-bottom: 2em;
@ -276,9 +271,8 @@ aside {
margin-bottom: 2em; margin-bottom: 2em;
} }
.small-cover { .cover.is-small { width: 10em; }
width: 4em; .cover.is-tiny { height: 2em; }
}
.media .subtitle { .media .subtitle {
font-size: 1em; font-size: 1em;
@ -286,4 +280,8 @@ aside {
} }
.is-round, .sound-item .button {
border: 1px $grey solid;
border-radius: 1em;
}