streamer as separate application; working streamer monitor interface

This commit is contained in:
bkfox 2019-09-21 17:14:40 +02:00
parent 4e61ec1520
commit d3f39c5ade
39 changed files with 1347 additions and 148 deletions

View File

@ -22,13 +22,17 @@ __all__ = ['Sound', 'SoundQuerySet', 'Track']
class SoundQuerySet(models.QuerySet): class SoundQuerySet(models.QuerySet):
def station(self, station=None, id=None):
id = station.pk if id is None else id
return self.filter(program__station__id=id)
def episode(self, episode=None, id=None): def episode(self, episode=None, id=None):
return self.filter(episode=episode) if id is None else \ id = episode.pk if id is None else id
self.filter(episode__id=id) return self.filter(episode__id=id)
def diffusion(self, diffusion=None, id=None): def diffusion(self, diffusion=None, id=None):
return self.filter(episode__diffusion=diffusion) if id is None else \ id = diffusion.pk if id is None else id
self.filter(episode__diffusion__id=id) return self.filter(episode__diffusion__id=id)
def podcasts(self): def podcasts(self):
""" Return sounds available as podcasts """ """ Return sounds available as podcasts """
@ -49,6 +53,13 @@ class SoundQuerySet(models.QuerySet):
self = self.order_by('path') self = self.order_by('path')
return self.filter(path__isnull=False).values_list('path', flat=True) return self.filter(path__isnull=False).values_list('path', flat=True)
def search(self, query):
return self.filter(
Q(name__icontains=query) | Q(path__icontains=query) |
Q(program__title__icontains=query) |
Q(episode__title__icontains=query)
)
class Sound(models.Model): class Sound(models.Model):
""" """

View File

@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Diffusion, Log from .models import Diffusion, Log, Sound
__all__ = ['LogInfo', 'LogInfoSerializer'] __all__ = ['LogInfo', 'LogInfoSerializer']
@ -53,3 +53,18 @@ class LogInfoSerializer(serializers.Serializer):
cover = serializers.URLField(required=False) cover = serializers.URLField(required=False)
class SoundSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
class Meta:
model = Sound
fields = ['pk', 'name', 'path', 'program', 'episode', 'embed', 'type',
'duration', 'mtime', 'is_good_quality', 'is_public']
def get_field_names(self, *args):
names = super().get_field_names(*args)
if not self.context['request'].user.is_staff and self.instance \
and not self.instance.is_public:
names.remove('path')
return names

View File

@ -207,6 +207,97 @@ ul.menu-list li {
fieldset[disabled] .pagination-ellipsis { fieldset[disabled] .pagination-ellipsis {
cursor: not-allowed; } cursor: not-allowed; }
.dropdown {
display: inline-flex;
position: relative;
vertical-align: top; }
.dropdown.is-active .dropdown-menu, .dropdown.is-hoverable:hover .dropdown-menu {
display: block; }
.dropdown.is-right .dropdown-menu {
left: auto;
right: 0; }
.dropdown.is-up .dropdown-menu {
bottom: 100%;
padding-bottom: 4px;
padding-top: initial;
top: auto; }
.dropdown-menu {
display: none;
left: 0;
min-width: 12rem;
padding-top: 4px;
position: absolute;
top: 100%;
z-index: 20; }
.dropdown-content {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
padding-bottom: 0.5rem;
padding-top: 0.5rem; }
.dropdown-item {
color: #4a4a4a;
display: block;
font-size: 0.875rem;
line-height: 1.5;
padding: 0.375rem 1rem;
position: relative; }
a.dropdown-item,
button.dropdown-item {
padding-right: 3rem;
text-align: left;
white-space: nowrap;
width: 100%; }
a.dropdown-item:hover,
button.dropdown-item:hover {
background-color: whitesmoke;
color: #0a0a0a; }
a.dropdown-item.is-active,
button.dropdown-item.is-active {
background-color: #3273dc;
color: #fff; }
.dropdown-divider {
background-color: #dbdbdb;
border: none;
display: block;
height: 1px;
margin: 0.5rem 0; }
.autocomplete {
position: relative; }
.autocomplete .dropdown-menu {
display: block;
min-width: 100%;
max-width: 100%; }
.autocomplete .dropdown-menu.is-opened-top {
top: auto;
bottom: 100%; }
.autocomplete .dropdown-content {
overflow: auto;
max-height: 200px; }
.autocomplete .dropdown-item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.autocomplete .dropdown-item.is-hovered {
background: whitesmoke;
color: #0a0a0a; }
.autocomplete .dropdown-item.is-disabled {
opacity: 0.5;
cursor: not-allowed; }
.autocomplete.is-small {
border-radius: 2px;
font-size: 0.75rem; }
.autocomplete.is-medium {
font-size: 1.25rem; }
.autocomplete.is-large {
font-size: 1.5rem; }
/*! bulma.io v0.7.5 | MIT License | github.com/jgthms/bulma */ /*! bulma.io v0.7.5 | MIT License | github.com/jgthms/bulma */
@keyframes spinAround { @keyframes spinAround {
from { from {
@ -7177,6 +7268,9 @@ label.panel-block {
.is-borderless { .is-borderless {
border: none; } border: none; }
.has-text-nowrap {
white-space: nowrap; }
.has-background-transparent { .has-background-transparent {
background-color: transparent; } background-color: transparent; }
@ -7208,6 +7302,18 @@ a.navbar-item.is-active {
margin: 0em; margin: 0em;
padding: 0em; } padding: 0em; }
.navbar.toolbar {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em; }
.navbar.toolbar .title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px #b5b5b5 solid;
font-size: 1.25rem;
color: #7a7a7a;
font-weight: 300; }
.card .title { .card .title {
padding: 0.2em; padding: 0.2em;
font-size: 1.25rem; font-size: 1.25rem;
@ -7230,18 +7336,6 @@ a.navbar-item.is-active {
padding: 0.1em; padding: 0.1em;
font-size: 0.8em; } font-size: 0.8em; }
.filters {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em; }
.filters .title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px #b5b5b5 solid;
font-size: 1.25rem;
color: #7a7a7a;
font-weight: 300; }
.page > .cover { .page > .cover {
float: right; float: right;
max-width: 45%; } max-width: 45%; }

View File

@ -218,11 +218,47 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _nod
/*!******************************!*\ /*!******************************!*\
!*** ./assets/public/app.js ***! !*** ./assets/public/app.js ***!
\******************************/ \******************************/
/*! exports provided: app, default */ /*! exports provided: appBaseConfig, setAppConfig, getAppConfig, loadApp */
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"app\", function() { return app; });\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n\n\n\nvar app = null;\n/* harmony default export */ __webpack_exports__[\"default\"] = (app);\n\nfunction loadApp() {\n app = new vue__WEBPACK_IMPORTED_MODULE_0__[\"default\"]({\n el: '#app',\n delimiters: [ '[[', ']]' ],\n })\n}\n\nwindow.addEventListener('load', loadApp);\n\n\n\n\n\n//# sourceURL=webpack:///./assets/public/app.js?"); eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"appBaseConfig\", function() { return appBaseConfig; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"setAppConfig\", function() { return setAppConfig; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"getAppConfig\", function() { return getAppConfig; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"loadApp\", function() { return loadApp; });\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n\n\n\nconst appBaseConfig = {\n el: '#app',\n delimiters: ['[[', ']]'],\n}\n\n/**\n * Application config for the main application instance\n */\nvar appConfig = {};\n\nfunction setAppConfig(config) {\n for(var member in appConfig) delete appConfig[member];\n return Object.assign(appConfig, config)\n}\n\nfunction getAppConfig(config) {\n if(config instanceof Function)\n config = config()\n config = config == null ? appConfig : config;\n return {...appBaseConfig, ...config}\n}\n\n\n/**\n * Create Vue application at window 'load' event and return a Promise\n * resolving to the created app.\n *\n * config: defaults to appConfig (checked when window is loaded)\n */\nfunction loadApp(config=null) {\n return new Promise(function(resolve, reject) {\n window.addEventListener('load', function() {\n try {\n config = getAppConfig(config)\n const el = document.querySelector(config.el)\n if(!el) {\n reject(`Error: missing element ${config.el}`);\n return;\n }\n\n resolve(new vue__WEBPACK_IMPORTED_MODULE_0__[\"default\"](config))\n }\n catch(error) { reject(error) }\n })\n })\n}\n\n\n\n\n\n\n//# sourceURL=webpack:///./assets/public/app.js?");
/***/ }),
/***/ "./assets/public/autocomplete.vue":
/*!****************************************!*\
!*** ./assets/public/autocomplete.vue ***!
\****************************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./autocomplete.vue?vue&type=template&id=70936760& */ \"./assets/public/autocomplete.vue?vue&type=template&id=70936760&\");\n/* harmony import */ var _autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./autocomplete.vue?vue&type=script&lang=js& */ \"./assets/public/autocomplete.vue?vue&type=script&lang=js&\");\n/* empty/unused harmony star reexport *//* harmony import */ var _node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../../node_modules/vue-loader/lib/runtime/componentNormalizer.js */ \"./node_modules/vue-loader/lib/runtime/componentNormalizer.js\");\n\n\n\n\n\n/* normalize component */\n\nvar component = Object(_node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"])(\n _autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n _autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"render\"],\n _autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"],\n false,\n null,\n null,\n null\n \n)\n\n/* hot reload */\nif (false) { var api; }\ncomponent.options.__file = \"assets/public/autocomplete.vue\"\n/* harmony default export */ __webpack_exports__[\"default\"] = (component.exports);\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?");
/***/ }),
/***/ "./assets/public/autocomplete.vue?vue&type=script&lang=js&":
/*!*****************************************************************!*\
!*** ./assets/public/autocomplete.vue?vue&type=script&lang=js& ***!
\*****************************************************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../node_modules/vue-loader/lib??vue-loader-options!./autocomplete.vue?vue&type=script&lang=js& */ \"./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=script&lang=js&\");\n/* empty/unused harmony star reexport */ /* harmony default export */ __webpack_exports__[\"default\"] = (_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__[\"default\"]); \n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?");
/***/ }),
/***/ "./assets/public/autocomplete.vue?vue&type=template&id=70936760&":
/*!***********************************************************************!*\
!*** ./assets/public/autocomplete.vue?vue&type=template&id=70936760& ***!
\***********************************************************************/
/*! exports provided: render, staticRenderFns */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../node_modules/vue-loader/lib??vue-loader-options!./autocomplete.vue?vue&type=template&id=70936760& */ \"./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=template&id=70936760&\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"render\"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"]; });\n\n\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?");
/***/ }), /***/ }),
@ -234,7 +270,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _liveInfo__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./liveInfo */ \"./assets/public/liveInfo.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_5__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_6__[\"default\"])\n\n\nwindow.aircox = {\n app: _app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n LiveInfo: _liveInfo__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n}\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?"); eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_4__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/* harmony import */ var _autocomplete_vue__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./autocomplete.vue */ \"./assets/public/autocomplete.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_5__[\"default\"])\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-autocomplete', _autocomplete_vue__WEBPACK_IMPORTED_MODULE_6__[\"default\"])\n\n\nwindow.aircox = {\n // main application\n app: null,\n\n // main application config\n appConfig: {},\n\n // player application\n playerApp: null,\n\n // player component\n get player() {\n return this.playerApp && this.playerApp.$refs.player\n }\n};\n\n\nObject(_app__WEBPACK_IMPORTED_MODULE_3__[\"loadApp\"])({el: '#player'}).then(app => { window.aircox.playerApp = app },\n () => undefined)\nObject(_app__WEBPACK_IMPORTED_MODULE_3__[\"loadApp\"])(() => window.aircox.appConfig ).then(app => { window.aircox.app = app },\n () => undefined)\n\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?");
/***/ }), /***/ }),
@ -309,6 +345,18 @@ eval("__webpack_require__.r(__webpack_exports__);\n//\n//\n//\n//\n//\n//\n\n\nc
/***/ }), /***/ }),
/***/ "./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=script&lang=js&":
/*!*******************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib??vue-loader-options!./assets/public/autocomplete.vue?vue&type=script&lang=js& ***!
\*******************************************************************************************************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var lodash_debounce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! lodash/debounce */ \"./node_modules/lodash/debounce.js\");\n/* harmony import */ var lodash_debounce__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(lodash_debounce__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! buefy/dist/components/autocomplete */ \"./node_modules/buefy/dist/components/autocomplete/index.js\");\n/* harmony import */ var buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n\n\n\n\n\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n props: {\n url: String,\n model: Function,\n placeholder: String,\n field: {type: String, default: 'value'},\n count: {type: Number, count: 10},\n valueAttr: String,\n valueField: String,\n },\n\n data() {\n return {\n data: [],\n selected: null,\n isFetching: false,\n };\n },\n\n methods: {\n onSelect(option) {\n console.log('selected', option)\n vue__WEBPACK_IMPORTED_MODULE_2__[\"default\"].set(this, 'selected', option);\n this.$emit('select', option);\n },\n\n fetch: lodash_debounce__WEBPACK_IMPORTED_MODULE_0___default()(function(query) {\n if(!query)\n return;\n\n this.isFetching = true;\n this.model.fetchAll(this.url.replace('${query}', query))\n .then(data => {\n this.data = data;\n this.isFetching = false;\n }, data => { this.isFetching = false; Promise.reject(data) })\n }),\n },\n\n components: {\n Autocomplete: buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1__[\"Autocomplete\"],\n },\n});\n\n\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?./node_modules/vue-loader/lib??vue-loader-options");
/***/ }),
/***/ "./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=script&lang=js&": /***/ "./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=script&lang=js&":
/*!*************************************************************************************************************!*\ /*!*************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=script&lang=js& ***! !*** ./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=script&lang=js& ***!
@ -333,6 +381,18 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ }), /***/ }),
/***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=template&id=70936760&":
/*!*****************************************************************************************************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./assets/public/autocomplete.vue?vue&type=template&id=70936760& ***!
\*****************************************************************************************************************************************************************************************************/
/*! exports provided: render, staticRenderFns */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\n \"div\",\n { staticClass: \"control\" },\n [\n _c(\"Autocomplete\", {\n ref: \"autocomplete\",\n attrs: {\n data: _vm.data,\n placeholder: _vm.placeholder,\n field: _vm.field,\n loading: _vm.isFetching,\n \"open-on-focus\": \"\"\n },\n on: {\n typing: _vm.fetch,\n select: function(object) {\n return _vm.onSelect(object)\n }\n }\n }),\n _vm._v(\" \"),\n _vm.valueField\n ? _c(\"input\", {\n ref: \"value\",\n attrs: { type: \"hidden\", name: _vm.valueField },\n domProps: {\n value:\n _vm.selected && _vm.selected[_vm.valueAttr || _vm.valueField]\n }\n })\n : _vm._e()\n ],\n 1\n )\n}\nvar staticRenderFns = []\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
/***/ }),
/***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=template&id=42a56ec9&": /***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=template&id=42a56ec9&":
/*!***********************************************************************************************************************************************************************************************!*\ /*!***********************************************************************************************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=template&id=42a56ec9& ***! !*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=template&id=42a56ec9& ***!

View File

@ -189,6 +189,97 @@
fieldset[disabled] .pagination-ellipsis { fieldset[disabled] .pagination-ellipsis {
cursor: not-allowed; } cursor: not-allowed; }
.dropdown {
display: inline-flex;
position: relative;
vertical-align: top; }
.dropdown.is-active .dropdown-menu, .dropdown.is-hoverable:hover .dropdown-menu {
display: block; }
.dropdown.is-right .dropdown-menu {
left: auto;
right: 0; }
.dropdown.is-up .dropdown-menu {
bottom: 100%;
padding-bottom: 4px;
padding-top: initial;
top: auto; }
.dropdown-menu {
display: none;
left: 0;
min-width: 12rem;
padding-top: 4px;
position: absolute;
top: 100%;
z-index: 20; }
.dropdown-content {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
padding-bottom: 0.5rem;
padding-top: 0.5rem; }
.dropdown-item {
color: #4a4a4a;
display: block;
font-size: 0.875rem;
line-height: 1.5;
padding: 0.375rem 1rem;
position: relative; }
a.dropdown-item,
button.dropdown-item {
padding-right: 3rem;
text-align: left;
white-space: nowrap;
width: 100%; }
a.dropdown-item:hover,
button.dropdown-item:hover {
background-color: whitesmoke;
color: #0a0a0a; }
a.dropdown-item.is-active,
button.dropdown-item.is-active {
background-color: #3273dc;
color: #fff; }
.dropdown-divider {
background-color: #dbdbdb;
border: none;
display: block;
height: 1px;
margin: 0.5rem 0; }
.autocomplete {
position: relative; }
.autocomplete .dropdown-menu {
display: block;
min-width: 100%;
max-width: 100%; }
.autocomplete .dropdown-menu.is-opened-top {
top: auto;
bottom: 100%; }
.autocomplete .dropdown-content {
overflow: auto;
max-height: 200px; }
.autocomplete .dropdown-item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.autocomplete .dropdown-item.is-hovered {
background: whitesmoke;
color: #0a0a0a; }
.autocomplete .dropdown-item.is-disabled {
opacity: 0.5;
cursor: not-allowed; }
.autocomplete.is-small {
border-radius: 2px;
font-size: 0.75rem; }
.autocomplete.is-medium {
font-size: 1.25rem; }
.autocomplete.is-large {
font-size: 1.5rem; }
/*! bulma.io v0.7.5 | MIT License | github.com/jgthms/bulma */ /*! bulma.io v0.7.5 | MIT License | github.com/jgthms/bulma */
@keyframes spinAround { @keyframes spinAround {
from { from {
@ -7159,6 +7250,9 @@ label.panel-block {
.is-borderless { .is-borderless {
border: none; } border: none; }
.has-text-nowrap {
white-space: nowrap; }
.has-background-transparent { .has-background-transparent {
background-color: transparent; } background-color: transparent; }
@ -7190,6 +7284,18 @@ a.navbar-item.is-active {
margin: 0em; margin: 0em;
padding: 0em; } padding: 0em; }
.navbar.toolbar {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em; }
.navbar.toolbar .title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px #b5b5b5 solid;
font-size: 1.25rem;
color: #7a7a7a;
font-weight: 300; }
.card .title { .card .title {
padding: 0.2em; padding: 0.2em;
font-size: 1.25rem; font-size: 1.25rem;
@ -7212,18 +7318,6 @@ a.navbar-item.is-active {
padding: 0.1em; padding: 0.1em;
font-size: 0.8em; } font-size: 0.8em; }
.filters {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em; }
.filters .title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px #b5b5b5 solid;
font-size: 1.25rem;
color: #7a7a7a;
font-weight: 300; }
.page > .cover { .page > .cover {
float: right; float: right;
max-width: 45%; } max-width: 45%; }

View File

@ -159,11 +159,47 @@
/*!******************************!*\ /*!******************************!*\
!*** ./assets/public/app.js ***! !*** ./assets/public/app.js ***!
\******************************/ \******************************/
/*! exports provided: app, default */ /*! exports provided: appBaseConfig, setAppConfig, getAppConfig, loadApp */
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"app\", function() { return app; });\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n\n\n\nvar app = null;\n/* harmony default export */ __webpack_exports__[\"default\"] = (app);\n\nfunction loadApp() {\n app = new vue__WEBPACK_IMPORTED_MODULE_0__[\"default\"]({\n el: '#app',\n delimiters: [ '[[', ']]' ],\n })\n}\n\nwindow.addEventListener('load', loadApp);\n\n\n\n\n\n//# sourceURL=webpack:///./assets/public/app.js?"); eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"appBaseConfig\", function() { return appBaseConfig; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"setAppConfig\", function() { return setAppConfig; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"getAppConfig\", function() { return getAppConfig; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"loadApp\", function() { return loadApp; });\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n\n\n\nconst appBaseConfig = {\n el: '#app',\n delimiters: ['[[', ']]'],\n}\n\n/**\n * Application config for the main application instance\n */\nvar appConfig = {};\n\nfunction setAppConfig(config) {\n for(var member in appConfig) delete appConfig[member];\n return Object.assign(appConfig, config)\n}\n\nfunction getAppConfig(config) {\n if(config instanceof Function)\n config = config()\n config = config == null ? appConfig : config;\n return {...appBaseConfig, ...config}\n}\n\n\n/**\n * Create Vue application at window 'load' event and return a Promise\n * resolving to the created app.\n *\n * config: defaults to appConfig (checked when window is loaded)\n */\nfunction loadApp(config=null) {\n return new Promise(function(resolve, reject) {\n window.addEventListener('load', function() {\n try {\n config = getAppConfig(config)\n const el = document.querySelector(config.el)\n if(!el) {\n reject(`Error: missing element ${config.el}`);\n return;\n }\n\n resolve(new vue__WEBPACK_IMPORTED_MODULE_0__[\"default\"](config))\n }\n catch(error) { reject(error) }\n })\n })\n}\n\n\n\n\n\n\n//# sourceURL=webpack:///./assets/public/app.js?");
/***/ }),
/***/ "./assets/public/autocomplete.vue":
/*!****************************************!*\
!*** ./assets/public/autocomplete.vue ***!
\****************************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./autocomplete.vue?vue&type=template&id=70936760& */ \"./assets/public/autocomplete.vue?vue&type=template&id=70936760&\");\n/* harmony import */ var _autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./autocomplete.vue?vue&type=script&lang=js& */ \"./assets/public/autocomplete.vue?vue&type=script&lang=js&\");\n/* empty/unused harmony star reexport *//* harmony import */ var _node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../../node_modules/vue-loader/lib/runtime/componentNormalizer.js */ \"./node_modules/vue-loader/lib/runtime/componentNormalizer.js\");\n\n\n\n\n\n/* normalize component */\n\nvar component = Object(_node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"])(\n _autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n _autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"render\"],\n _autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"],\n false,\n null,\n null,\n null\n \n)\n\n/* hot reload */\nif (false) { var api; }\ncomponent.options.__file = \"assets/public/autocomplete.vue\"\n/* harmony default export */ __webpack_exports__[\"default\"] = (component.exports);\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?");
/***/ }),
/***/ "./assets/public/autocomplete.vue?vue&type=script&lang=js&":
/*!*****************************************************************!*\
!*** ./assets/public/autocomplete.vue?vue&type=script&lang=js& ***!
\*****************************************************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../node_modules/vue-loader/lib??vue-loader-options!./autocomplete.vue?vue&type=script&lang=js& */ \"./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=script&lang=js&\");\n/* empty/unused harmony star reexport */ /* harmony default export */ __webpack_exports__[\"default\"] = (_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__[\"default\"]); \n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?");
/***/ }),
/***/ "./assets/public/autocomplete.vue?vue&type=template&id=70936760&":
/*!***********************************************************************!*\
!*** ./assets/public/autocomplete.vue?vue&type=template&id=70936760& ***!
\***********************************************************************/
/*! exports provided: render, staticRenderFns */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../node_modules/vue-loader/lib??vue-loader-options!./autocomplete.vue?vue&type=template&id=70936760& */ \"./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=template&id=70936760&\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"render\"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_autocomplete_vue_vue_type_template_id_70936760___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"]; });\n\n\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?");
/***/ }), /***/ }),
@ -175,7 +211,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _liveInfo__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./liveInfo */ \"./assets/public/liveInfo.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_5__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_6__[\"default\"])\n\n\nwindow.aircox = {\n app: _app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n LiveInfo: _liveInfo__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n}\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?"); eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_4__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/* harmony import */ var _autocomplete_vue__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./autocomplete.vue */ \"./assets/public/autocomplete.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_5__[\"default\"])\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-autocomplete', _autocomplete_vue__WEBPACK_IMPORTED_MODULE_6__[\"default\"])\n\n\nwindow.aircox = {\n // main application\n app: null,\n\n // main application config\n appConfig: {},\n\n // player application\n playerApp: null,\n\n // player component\n get player() {\n return this.playerApp && this.playerApp.$refs.player\n }\n};\n\n\nObject(_app__WEBPACK_IMPORTED_MODULE_3__[\"loadApp\"])({el: '#player'}).then(app => { window.aircox.playerApp = app },\n () => undefined)\nObject(_app__WEBPACK_IMPORTED_MODULE_3__[\"loadApp\"])(() => window.aircox.appConfig ).then(app => { window.aircox.app = app },\n () => undefined)\n\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?");
/***/ }), /***/ }),
@ -238,6 +274,18 @@ eval("// extracted by mini-css-extract-plugin\n\n//# sourceURL=webpack:///./asse
/***/ }), /***/ }),
/***/ "./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=script&lang=js&":
/*!*******************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib??vue-loader-options!./assets/public/autocomplete.vue?vue&type=script&lang=js& ***!
\*******************************************************************************************************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var lodash_debounce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! lodash/debounce */ \"./node_modules/lodash/debounce.js\");\n/* harmony import */ var lodash_debounce__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(lodash_debounce__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! buefy/dist/components/autocomplete */ \"./node_modules/buefy/dist/components/autocomplete/index.js\");\n/* harmony import */ var buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n\n\n\n\n\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n props: {\n url: String,\n model: Function,\n placeholder: String,\n field: {type: String, default: 'value'},\n count: {type: Number, count: 10},\n valueAttr: String,\n valueField: String,\n },\n\n data() {\n return {\n data: [],\n selected: null,\n isFetching: false,\n };\n },\n\n methods: {\n onSelect(option) {\n console.log('selected', option)\n vue__WEBPACK_IMPORTED_MODULE_2__[\"default\"].set(this, 'selected', option);\n this.$emit('select', option);\n },\n\n fetch: lodash_debounce__WEBPACK_IMPORTED_MODULE_0___default()(function(query) {\n if(!query)\n return;\n\n this.isFetching = true;\n this.model.fetchAll(this.url.replace('${query}', query))\n .then(data => {\n this.data = data;\n this.isFetching = false;\n }, data => { this.isFetching = false; Promise.reject(data) })\n }),\n },\n\n components: {\n Autocomplete: buefy_dist_components_autocomplete__WEBPACK_IMPORTED_MODULE_1__[\"Autocomplete\"],\n },\n});\n\n\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?./node_modules/vue-loader/lib??vue-loader-options");
/***/ }),
/***/ "./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=script&lang=js&": /***/ "./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=script&lang=js&":
/*!*************************************************************************************************************!*\ /*!*************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=script&lang=js& ***! !*** ./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=script&lang=js& ***!
@ -250,6 +298,18 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ }), /***/ }),
/***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/autocomplete.vue?vue&type=template&id=70936760&":
/*!*****************************************************************************************************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./assets/public/autocomplete.vue?vue&type=template&id=70936760& ***!
\*****************************************************************************************************************************************************************************************************/
/*! exports provided: render, staticRenderFns */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\n \"div\",\n { staticClass: \"control\" },\n [\n _c(\"Autocomplete\", {\n ref: \"autocomplete\",\n attrs: {\n data: _vm.data,\n placeholder: _vm.placeholder,\n field: _vm.field,\n loading: _vm.isFetching,\n \"open-on-focus\": \"\"\n },\n on: {\n typing: _vm.fetch,\n select: function(object) {\n return _vm.onSelect(object)\n }\n }\n }),\n _vm._v(\" \"),\n _vm.valueField\n ? _c(\"input\", {\n ref: \"value\",\n attrs: { type: \"hidden\", name: _vm.valueField },\n domProps: {\n value:\n _vm.selected && _vm.selected[_vm.valueAttr || _vm.valueField]\n }\n })\n : _vm._e()\n ],\n 1\n )\n}\nvar staticRenderFns = []\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./assets/public/autocomplete.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
/***/ }),
/***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=template&id=42a56ec9&": /***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./assets/public/player.vue?vue&type=template&id=42a56ec9&":
/*!***********************************************************************************************************************************************************************************************!*\ /*!***********************************************************************************************************************************************************************************************!*\
!*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=template&id=42a56ec9& ***! !*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./assets/public/player.vue?vue&type=template&id=42a56ec9& ***!

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,10 @@
{% load i18n static %}<!DOCTYPE html> {% load i18n static aircox_admin %}<!DOCTYPE html>
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
<html lang="{{ LANGUAGE_CODE|default:"en-us" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}> <html lang="{{ LANGUAGE_CODE|default:"en-us" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
<head> <head>
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{% static "aircox/vendor.css" %}">
<link rel="stylesheet" type="text/css" href="{% static "admin/css/base.css" %}"> <link rel="stylesheet" type="text/css" href="{% static "admin/css/base.css" %}">
<link rel="stylesheet" type="text/css" href="{% static "aircox/main.css" %}"> <link rel="stylesheet" type="text/css" href="{% static "aircox/main.css" %}">
<link rel="stylesheet" type="text/css" href="{% static "aircox/admin.css" %}"> <link rel="stylesheet" type="text/css" href="{% static "aircox/admin.css" %}">
@ -67,7 +68,10 @@
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a href="#" class="navbar-link">{% trans "Tools" %}</a> <a href="#" class="navbar-link">{% trans "Tools" %}</a>
<div class="navbar-dropdown is-boxed is-right"> <div class="navbar-dropdown is-boxed is-right">
<a href="{% url 'admin:tools-stats' %}" class="navbar-item">{% trans "Statistics" %}</a> {% get_admin_tools as admin_tools %}
{% for label, url in admin_tools %}
<a href="{{ url }}" class="navbar-item">{{ label }}</a>
{% endfor %}
</div> </div>
</div> </div>
@ -121,7 +125,7 @@
<!-- Content --> <!-- Content -->
<div id="content" class="{% block coltype %}colM{% endblock %}"> <div id="content" class="{% block coltype %}colM{% endblock %}">
{% block pretitle %}{% endblock %} {% block pretitle %}{% endblock %}
{% block content_title %}{% if title %}<h1 class="subtitle is-3">{{ title }}</h1>{% endif %}{% endblock %} {% block content_title %}{% if title %}<h1 class="title is-3">{{ title }}</h1>{% endif %}{% endblock %}
{% block content %} {% block content %}
{% block object-tools %}{% endblock %} {% block object-tools %}{% endblock %}
{{ content }} {{ content }}

View File

@ -93,7 +93,7 @@ Blocks:
{% block main %} {% block main %}
{% if has_filters %} {% if has_filters %}
<nav class="navbar filters" <nav class="navbar toolbar"
aria-label="{% trans "list filters" %}"> aria-label="{% trans "list filters" %}">
{% block filters %}{% endblock %} {% block filters %}{% endblock %}
</nav> </nav>
@ -132,8 +132,8 @@ Blocks:
</div> </div>
<hr> <hr>
{% include "aircox/player.html" %}
</div> </div>
<div id="player">{% include "aircox/player.html" %}</div>
</body> </body>
</html> </html>

View File

@ -38,7 +38,9 @@ Context:
<hr> <hr>
<h4 class="title is-4">{% trans "Last publications" %}</h4> <h4 class="title is-4">{% trans "Last publications" %}</h4>
{% with has_headline=True %}
{{ block.super }} {{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}

View File

@ -16,7 +16,7 @@
</noscript> </noscript>
<a-player ref="player" src="{{ audio_streams.0 }}" <a-player ref="player" src="{{ audio_streams.0 }}"
live-info-url="{% url "api-live" %}" :live-info-timeout="15" live-info-url="{% url "api-live" %}" :live-info-timeout="20"
button-title="{% trans "Play or pause audio" %}"> button-title="{% trans "Play or pause audio" %}">
<template v-slot:sources> <template v-slot:sources>
{% for stream in audio_streams %} {% for stream in audio_streams %}

View File

@ -12,12 +12,17 @@ __all__ = ['BaseAdminView', 'StatisticsView']
class BaseAdminView(LoginRequiredMixin, UserPassesTestMixin): class BaseAdminView(LoginRequiredMixin, UserPassesTestMixin):
title = '' title = ''
@property
def station(self):
return self.request.station
def test_func(self): def test_func(self):
return self.request.user.is_staff return self.request.user.is_staff
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update(admin.site.each_context(self.request)) kwargs.update(admin.site.each_context(self.request))
kwargs.setdefault('title', self.title) kwargs.setdefault('title', self.title)
kwargs.setdefault('station', self.station)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -3,3 +3,5 @@ from django.apps import AppConfig
class AircoxStreamerConfig(AppConfig): class AircoxStreamerConfig(AppConfig):
name = 'aircox_streamer' name = 'aircox_streamer'

View File

@ -43,6 +43,8 @@ class BaseMetadata:
""" Request uri """ """ Request uri """
status = None status = None
""" Current playing status """ """ Current playing status """
request_status = None
""" Requests' status """
air_time = None air_time = None
""" Launch datetime """ """ Launch datetime """
@ -58,10 +60,25 @@ class BaseMetadata:
return self.status == 'playing' return self.status == 'playing'
def fetch(self): def fetch(self):
data = self.controller.set('request.metadata ', self.rid, parse=True) data = self.controller.send('request.metadata ', self.rid, parse=True)
if data: if data:
self.validate(data) self.validate(data)
def validate_status(self, status):
on_air = self.controller.source
if on_air and status == 'playing' and (on_air == self or
on_air.rid == self.rid):
return 'playing'
elif status == 'playing':
return 'paused'
else:
return 'stopped'
def validate_air_time(self, air_time):
if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
return local_tz.localize(air_time)
def validate(self, data): def validate(self, data):
""" """
Validate provided data and set as attribute (must already be Validate provided data and set as attribute (must already be
@ -72,12 +89,9 @@ class BaseMetadata:
setattr(self, key, value) setattr(self, key, value)
self.uri = data.get('initial_uri') self.uri = data.get('initial_uri')
air_time = data.get('on_air') self.air_time = self.validate_air_time(data.get('on_air'))
if air_time: self.status = self.validate_status(data.get('status'))
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S') self.request_status = data.get('status')
self.air_time = local_tz.localize(air_time)
else:
self.air_time = None
class Request(BaseMetadata): class Request(BaseMetadata):
@ -142,6 +156,14 @@ class Streamer:
logger.debug('process died with return code %s' % returncode) logger.debug('process died with return code %s' % returncode)
return False return False
@property
def playlists(self):
return (s for s in self.sources if isinstance(s, PlaylistSource))
@property
def queues(self):
return (s for s in self.sources if isinstance(s, QueueSource))
# Sources and config ############################################### # Sources and config ###############################################
def send(self, *args, **kwargs): def send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs) or '' return self.connector.send(*args, **kwargs) or ''
@ -180,12 +202,11 @@ class Streamer:
source.fetch() source.fetch()
# request.on_air is not ordered: we need to do it manually # request.on_air is not ordered: we need to do it manually
if self.dealer.is_playing: self.source = next(iter(sorted(
self.source = self.dealer (source for source in self.sources
return if source.request_status == 'playing' and source.air_time),
key=lambda o: o.air_time, reverse=True
self.source = next((source for source in self.sources )), None)
if source.is_playing), None)
# Process ########################################################## # Process ##########################################################
def get_process_args(self): def get_process_args(self):
@ -241,15 +262,12 @@ class Source(BaseMetadata):
""" source id """ """ source id """
remaining = 0.0 remaining = 0.0
""" remaining time """ """ remaining time """
status = 'stopped'
@property @property
def station(self): def station(self):
return self.controller.station return self.controller.station
# @property
# def is_on_air(self):
# return self.rid is not None and self.rid in self.controller.on_air
def __init__(self, controller=None, id=None, *args, **kwargs): def __init__(self, controller=None, id=None, *args, **kwargs):
super().__init__(controller, *args, **kwargs) super().__init__(controller, *args, **kwargs)
self.id = id self.id = id
@ -258,9 +276,12 @@ class Source(BaseMetadata):
""" Synchronize what should be synchronized """ """ Synchronize what should be synchronized """
def fetch(self): def fetch(self):
try:
data = self.controller.send(self.id, '.remaining') data = self.controller.send(self.id, '.remaining')
if data: if data:
self.remaining = float(data) self.remaining = float(data)
except ValueError:
self.remaining = None
data = self.controller.send(self.id, '.get', parse=True) data = self.controller.send(self.id, '.get', parse=True)
if data: if data:
@ -332,12 +353,9 @@ class PlaylistSource(Source):
class QueueSource(Source): class QueueSource(Source):
queue = None queue = None
""" Source's queue (excluded on_air request) """ """ Source's queue (excluded on_air request) """
as_requests = False
""" If True, queue is a list of Request """
def __init__(self, *args, queue_metadata=False, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.queue_metadata = queue_metadata
def push(self, *paths): def push(self, *paths):
""" Add the provided paths to source's play queue """ """ Add the provided paths to source's play queue """
@ -346,13 +364,19 @@ class QueueSource(Source):
def fetch(self): def fetch(self):
super().fetch() super().fetch()
queue = self.controller.send(self.id, '_queue.queue').split(' ') queue = self.controller.send(self.id, '_queue.queue').strip()
if not self.as_requests: if not queue:
self.queue = queue self.queue = []
return return
self.queue = [Request(self.controller, rid) for rid in queue] self.queue = queue.split(' ')
for request in self.queue:
@property
def requests(self):
""" Queue as requests metadata """
requests = [Request(self.controller, rid) for rid in self.queue]
for request in requests:
request.fetch() request.fetch()
return requests

View File

@ -24,7 +24,7 @@ from django.utils import timezone as tz
from aircox.models import Station, Episode, Diffusion, Track, Sound, Log from aircox.models import Station, Episode, Diffusion, Track, Sound, Log
from aircox.utils import date_range from aircox.utils import date_range
from aircox_streamer.liquidsoap import Streamer, PlaylistSource from aircox_streamer.controllers import Streamer
# force using UTC # force using UTC
@ -246,8 +246,7 @@ class Monitor:
self.sync_next = now + tz.timedelta(minutes=self.sync_timeout) self.sync_next = now + tz.timedelta(minutes=self.sync_timeout)
for source in self.streamer.sources: for source in self.streamer.playlists:
if isinstance(source, PlaylistSource):
source.sync() source.sync()
@ -291,10 +290,8 @@ class Command (BaseCommand):
) )
# TODO: sync-timeout, cancel-timeout # TODO: sync-timeout, cancel-timeout
def handle(self, *args, def handle(self, *args, config=None, run=None, monitor=None, station=[],
config=None, run=None, monitor=None, delay=1000, timeout=600, **options):
station=[], delay=1000, timeout=600,
**options):
stations = Station.objects.filter(name__in=station) if station else \ stations = Station.objects.filter(name__in=station) if station else \
Station.objects.all() Station.objects.all()
streamers = [Streamer(station) for station in stations] streamers = [Streamer(station) for station in stations]

View File

@ -1,24 +1,35 @@
from django.urls import reverse
from rest_framework import serializers from rest_framework import serializers
from .controllers import QueueSource, PlaylistSource
__all__ = ['RequestSerializer', 'StreamerSerializer', 'SourceSerializer', __all__ = ['RequestSerializer', 'StreamerSerializer', 'SourceSerializer',
'PlaylistSerializer', 'QueueSourceSerializer'] 'PlaylistSerializer', 'QueueSourceSerializer']
# TODO: use models' serializers # TODO: use models' serializers
class BaseMetadataSerializer(serializers.Serializer): class BaseSerializer(serializers.Serializer):
url_ = serializers.SerializerMethodField('get_url')
url_name = None
def get_url(self, obj, **kwargs):
if not obj or not self.url_name:
return
kwargs.setdefault('pk', getattr(obj, 'id', None))
return reverse(self.url_name, kwargs=kwargs)
class BaseMetadataSerializer(BaseSerializer):
rid = serializers.IntegerField() rid = serializers.IntegerField()
air_time = serializers.DateTimeField() air_time = serializers.DateTimeField()
uri = serializers.CharField() uri = serializers.CharField()
class RequestSerializer(serializers.Serializer): class RequestSerializer(BaseMetadataSerializer):
title = serializers.CharField() title = serializers.CharField(required=False)
artist = serializers.CharField() artist = serializers.CharField(required=False)
class StreamerSerializer(serializers.Serializer):
station = serializers.CharField(source='station.title')
class SourceSerializer(BaseMetadataSerializer): class SourceSerializer(BaseMetadataSerializer):
@ -27,14 +38,34 @@ class SourceSerializer(BaseMetadataSerializer):
rid = serializers.IntegerField() rid = serializers.IntegerField()
air_time = serializers.DateTimeField() air_time = serializers.DateTimeField()
status = serializers.CharField() status = serializers.CharField()
remaining = serializers.FloatField()
def get_url(self, obj, **kwargs):
kwargs['station_pk'] = obj.station.pk
return super().get_url(obj, **kwargs)
class PlaylistSerializer(SourceSerializer): class PlaylistSerializer(SourceSerializer):
program = serializers.CharField(source='program.title') program = serializers.CharField(source='program.title')
playlist = serializers.ListField(child=serializers.CharField())
url_name = 'admin:api:streamer-playlist-detail'
class QueueSourceSerializer(SourceSerializer): class QueueSourceSerializer(SourceSerializer):
queue = serializers.ListField(child=RequestSerializer()) queue = serializers.ListField(child=RequestSerializer(), source='requests')
url_name = 'admin:api:streamer-queue-detail'
class StreamerSerializer(BaseSerializer):
id = serializers.IntegerField(source='station.pk')
name = serializers.CharField(source='station.name')
source = serializers.CharField(source='source.id', required=False)
playlists = serializers.ListField(child=PlaylistSerializer())
queues = serializers.ListField(child=QueueSourceSerializer())
url_name = 'admin:api:streamer-detail'
def get_url(self, obj, **kwargs):
kwargs['pk'] = obj.station.pk
return super().get_url(obj, **kwargs)

View File

@ -0,0 +1,121 @@
{% load i18n %}
<section class="box"><div class="columns is-desktop">
<div class="column">
<h5 class='title is-5' :class="{'has-text-danger': source.isPlaying, 'has-text-warning': source.isPaused}">
<span>
<span v-if="source.isPlaying" class="fas fa-play"></span>
<span v-else-if="source.isPaused" class="fas fa-pause"></span>
</span>
[[ source.id ]]
<small v-if="source.isPaused || source.isPlaying">(-[[ source.remainingString ]])</small>
</h5>
<div>
<button class="button" @click="source.sync()"
title="{% trans "Synchronize source with Liquidsoap" %}">
<span class="icon is-small">
<span class="fas fa-sync"></span>
</span>
<span>{% trans "Synchronise" %}</span>
</button>
<button class="button" @click="source.restart()"
title="{% trans "Restart current track" %}">
<span class="icon is-small">
<span class="fas fa-step-backward"></span>
</span>
<span>{% trans "Restart" %}</span>
</button>
<button class="button" @click="source.skip()"
title="{% trans "Skip current file" %}">
<span>{% trans "Skip" %}</span>
<span class="icon is-small">
<span class="fas fa-step-forward"></span>
</span>
</button>
</div>
<div v-if="source.isQueue">
<hr>
<h6 class="title is-6 is-marginless">{% trans "Add sound" %}</h6>
<form class="columns" @submit.prevent="source.push($event.target.elements['sound_id'].value)">
<div class="column field is-small">
<a-autocomplete url="{% url "admin:api:streamer-queue-autocomplete-push" station_pk=station.pk %}?q=${query}"
class="is-fullwidth"
:model="Sound" field="name" value-field="sound_id" value-attr="id"
{# FIXME dirty hack awaiting the vue component #}
placeholder="{% trans "Select a sound" %}"></a-autocomplete>
<p class="help">
{% trans "Add a sound to the queue (queue may start playing)" %}
</p>
{# TODO: help text about how it works #}
</div>
<div class="column control is-one-fifth">
<button type="submit" class="button is-primary">
<span class="icon">
<span class="fas fa-plus"></span>
</span>
<span>{% trans "Add" %}</span>
</button>
</div>
</form>
<div v-if="source.queue.length">
<h6 class="title is-6 is-marginless">{% trans "Sounds in queue" %}</h6>
<table class="table is-fullwidth"><tbody>
<tr v-for="[index, request] in source.queue.entries()">
<td :class="{'has-text-weight-semibold': index==0 }">
<span v-if="index==0" class="far fa-play-circle"></span>
<span>[[ request.data.uri ]]</span>
</td>
</tr>
</tbody></table>
</div>
</div>
</div>
<div class="column is-two-fifths">
<h6 class="subtitle is-6 is-marginless">Metadata</h6>
<table class="table has-background-transparent">
<tbody>
<tr><th class="has-text-right has-text-nowrap">
{% trans "Status" %}
</th>
<td :class="{'has-text-danger': source.isPlaying, 'has-text-warning': source.isPaused}">
<span v-if="source.isPlaying" class="fas fa-play"></span>
<span v-else-if="source.data.status" class="fas fa-pause"></span>
[[ source.data.status || "&mdash;" ]]
</td>
</tr>
<tr v-if="source.data.air_time">
<th class="has-text-right has-text-nowrap">
{% trans "Air time" %}
</th><td>
<span class="far fa-clock"></span>
<time :datetime="source.date">
[[ source.data.air_time.toLocaleDateString() ]],
[[ source.data.air_time.toLocaleTimeString() ]]
</time>
</td>
<tr v-if="source.remaining">
<th class="has-text-right has-text-nowrap">
{% trans "Time left" %}
</th><td>
<span class="far fa-hourglass"></span>
[[ source.remainingString ]]
</td>
</tr>
<tr v-if="source.data.uri">
<th class="has-text-right has-text-nowrap">
{% trans "Data source" %}
</th><td>
<span class="far fa-play-circle"></span>
<template v-if="source.data.uri.length > 64">...</template>[[ (source.data.uri && source.data.uri.slice(-64)) || '&mdash;' ]]
</td>
</tr>
</tbody>
</table>
</div>
</div></section>

View File

@ -0,0 +1,39 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}{{ block.super }}
<script src="{% static "aircox/streamer.js" %}"></script>
{% endblock %}
{% block content %}{{ block.super }}
<div id="app" data-api-url="{% url "admin:api:streamer-list" %}">
<div class="navbar toolbar">
<div class="navbar-start">
<span class="navbar-item control">
<button class="button">
<span class="icon is-small">
<span class="fas fa-sync"></span>
</span>
<span>{% trans "Reload" %}</span>
</button>
</span>
</div>
<div class="navbar-end">
<div class="select navbar-item">
<select ref="selectStreamer" @change.native="selectStreamer" class="control"
title="{% trans "Select a station" %}"
aria-label="{% trans "Select a station" %}">
<option v-for="streamer of streamers" :value="streamer.id">[[ streamer.data.name ]]</option>
</select>
</div>
</div>
</div>
<div v-if="streamer">
<template v-for="source in sources">
{% include "aircox_streamer/source_item.html" %}
</template>
</div>
</div>
{% endblock %}

22
aircox_streamer/urls.py Normal file
View File

@ -0,0 +1,22 @@
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from . import viewsets
from .views import StreamerAdminView
admin.site.route_view('tools/streamer', StreamerAdminView.as_view(),
'tools-streamer', label=_('Streamer Monitor'))
streamer_prefix = 'streamer/(?P<station_pk>[0-9]+)/'
router = admin.site.router
router.register(streamer_prefix + 'playlist', viewsets.PlaylistSourceViewSet,
basename='streamer-playlist')
router.register(streamer_prefix + 'queue', viewsets.QueueSourceViewSet,
basename='streamer-queue')
router.register('streamer', viewsets.StreamerViewSet, basename='streamer')
urls = []

View File

@ -1,3 +1,11 @@
from django.shortcuts import render from django.utils.translation import ugettext_lazy as _
from django.views.generic import TemplateView
from aircox.views.admin import BaseAdminView
class StreamerAdminView(BaseAdminView, TemplateView):
template_name = 'aircox_streamer/streamer.html'
title = _('Streamer Monitor')
# Create your views here.

View File

@ -1,12 +1,16 @@
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone as tz from django.utils import timezone as tz
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from aircox import controllers from aircox.models import Sound, Station
from aircox.models import Station from aircox.serializers import SoundSerializer
from . import controllers
from .serializers import * from .serializers import *
@ -52,16 +56,17 @@ class Streamers:
self.date = now + self.timeout self.date = now + self.timeout
def get(self, key, default=None): def get(self, key, default=None):
self.fetch()
return self.streamers.get(key, default) return self.streamers.get(key, default)
def values(self): def values(self):
self.fetch()
return self.streamers.values() return self.streamers.values()
def __getitem__(self, key): def __getitem__(self, key):
return self.streamers[key] return self.streamers[key]
def __contains__(self, key):
return key in self.streamers
streamers = Streamers() streamers = Streamers()
@ -70,22 +75,25 @@ class BaseControllerAPIView(viewsets.ViewSet):
permission_classes = (IsAdminUser,) permission_classes = (IsAdminUser,)
serializer = None serializer = None
streamer = None streamer = None
object = None
def get_streamer(self, pk=None): def get_streamer(self, request, station_pk=None, **kwargs):
streamer = streamers.get(self.request.pk if pk is None else pk) streamers.fetch()
if not streamer: id = int(request.station.pk if station_pk is None else station_pk)
if id not in streamers:
raise Http404('station not found') raise Http404('station not found')
return streamer return streamers[id]
def get_serializer(self, obj, **kwargs): def get_serializer(self, **kwargs):
return self.serializer(obj, **kwargs) return self.serializer(self.object, **kwargs)
def serialize(self, obj, **kwargs): def serialize(self, obj, **kwargs):
serializer = self.get_serializer(obj, **kwargs) self.object = obj
serializer = self.get_serializer(**kwargs)
return serializer.data return serializer.data
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, station_pk=None, **kwargs):
self.streamer = self.get_streamer(request.station.pk) self.streamer = self.get_streamer(request, station_pk, **kwargs)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -97,10 +105,19 @@ class StreamerViewSet(BaseControllerAPIView):
serializer = StreamerSerializer serializer = StreamerSerializer
def retrieve(self, request, pk=None): def retrieve(self, request, pk=None):
return self.serialize(self.streamer) return Response(self.serialize(self.streamer))
def list(self, request): def list(self, request, pk=None):
return self.serialize(streamers.values(), many=True) return Response({
'results': self.serialize(streamers.values(), many=True)
})
def dispatch(self, request, *args, pk=None, **kwargs):
if pk is not None:
kwargs.setdefault('station_pk', pk)
self.streamer = self.get_streamer(request, **kwargs)
self.object = self.streamer
return super().dispatch(request, *args, **kwargs)
class SourceViewSet(BaseControllerAPIView): class SourceViewSet(BaseControllerAPIView):
@ -108,38 +125,46 @@ class SourceViewSet(BaseControllerAPIView):
model = controllers.Source model = controllers.Source
def get_sources(self): def get_sources(self):
return (s for s in self.streamer.souces if isinstance(s, self.model)) return (s for s in self.streamer.sources if isinstance(s, self.model))
def get_source(self, pk): def get_source(self, pk):
source = next((source for source in self.get_sources() source = next((source for source in self.get_sources()
if source.pk == pk), None) if source.id == pk), None)
if source is None: if source is None:
raise Http404('source `%s` not found' % pk) raise Http404('source `%s` not found' % pk)
return source return source
def retrieve(self, request, pk=None): def retrieve(self, request, pk=None):
source = self.get_source(pk) self.object = self.get_source(pk)
return self.serialize(source) return Response(self.serialize())
def list(self, request): def list(self, request):
return self.serialize(self.get_sources(), many=True) return Response({
'results': self.serialize(self.get_sources(), many=True)
})
def _run(self, pk, action):
source = self.object = self.get_source(pk)
action(source)
source.fetch()
return Response(self.serialize(source))
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def sync(self, request, pk): def sync(self, request, pk):
self.get_source(pk).sync() return self._run(pk, lambda s: s.sync())
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def skip(self, request, pk): def skip(self, request, pk):
self.get_source(pk).skip() return self._run(pk, lambda s: s.skip())
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def restart(self, request, pk): def restart(self, request, pk):
self.get_source(pk).restart() return self._run(pk, lambda s: s.restart())
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def seek(self, request, pk): def seek(self, request, pk):
count = request.POST['seek'] count = request.POST['seek']
self.get_source(pk).seek(count) return self._run(pk, lambda s: s.seek(count))
class PlaylistSourceViewSet(SourceViewSet): class PlaylistSourceViewSet(SourceViewSet):
@ -151,8 +176,26 @@ class QueueSourceViewSet(SourceViewSet):
serializer = QueueSourceSerializer serializer = QueueSourceSerializer
model = controllers.QueueSource model = controllers.QueueSource
def get_sound_queryset(self):
return Sound.objects.station(self.request.station).archive()
@action(detail=False, url_path='autocomplete/push',
url_name='autocomplete-push')
def autcomplete_push(self, request):
query = request.GET.get('q')
qs = self.get_sound_queryset().search(query)
serializer = SoundSerializer(qs, many=True, context={
'request': self.request
})
return Response({'results': serializer.data})
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def push(self, request, pk): def push(self, request, pk):
self.get_source(pk).push() if not request.data.get('sound_id'):
raise ValidationError('missing "sound_id" POST data')
sound = get_object_or_404(self.get_sound_queryset(),
pk=request.data['sound_id'])
return self._run(
pk, lambda s: s.push(sound.path) if sound.path else None)

View File

@ -1,17 +1,53 @@
import Vue from 'vue'; import Vue from 'vue';
export var app = null; export const appBaseConfig = {
export default app;
function loadApp() {
app = new Vue({
el: '#app', el: '#app',
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
}
/**
* Application config for the main application instance
*/
var appConfig = {};
export function setAppConfig(config) {
for(var member in appConfig) delete appConfig[member];
return Object.assign(appConfig, config)
}
export function getAppConfig(config) {
if(config instanceof Function)
config = config()
config = config == null ? appConfig : config;
return {...appBaseConfig, ...config}
}
/**
* Create Vue application at window 'load' event and return a Promise
* resolving to the created app.
*
* config: defaults to appConfig (checked when window is loaded)
*/
export function loadApp(config=null) {
return new Promise(function(resolve, reject) {
window.addEventListener('load', function() {
try {
config = getAppConfig(config)
const el = document.querySelector(config.el)
if(!el) {
reject(`Error: missing element ${config.el}`);
return;
}
resolve(new Vue(config))
}
catch(error) { reject(error) }
})
}) })
} }
window.addEventListener('load', loadApp);

View File

@ -0,0 +1,63 @@
<template>
<div class="control">
<Autocomplete ref="autocomplete" :data="data" :placeholder="placeholder" :field="field"
:loading="isFetching" open-on-focus
@typing="fetch" @select="object => onSelect(object)"
>
</Autocomplete>
<input v-if="valueField" ref="value" type="hidden" :name="valueField"
:value="selected && selected[valueAttr || valueField]" />
</div>
</template>
<script>
import debounce from 'lodash/debounce'
import {Autocomplete} from 'buefy/dist/components/autocomplete';
import Vue from 'vue';
export default {
props: {
url: String,
model: Function,
placeholder: String,
field: {type: String, default: 'value'},
count: {type: Number, count: 10},
valueAttr: String,
valueField: String,
},
data() {
return {
data: [],
selected: null,
isFetching: false,
};
},
methods: {
onSelect(option) {
console.log('selected', option)
Vue.set(this, 'selected', option);
this.$emit('select', option);
},
fetch: debounce(function(query) {
if(!query)
return;
this.isFetching = true;
this.model.fetchAll(this.url.replace('${query}', query))
.then(data => {
this.data = data;
this.isFetching = false;
}, data => { this.isFetching = false; Promise.reject(data) })
}),
},
components: {
Autocomplete,
},
}
</script>

View File

@ -10,18 +10,37 @@ import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
//-- aircox //-- aircox
import app from './app'; import {appConfig, loadApp} from './app';
import LiveInfo from './liveInfo';
import './styles.scss'; import './styles.scss';
import Player from './player.vue'; import Player from './player.vue';
import Autocomplete from './autocomplete.vue';
Vue.component('a-player', Player) Vue.component('a-player', Player)
Vue.component('a-autocomplete', Autocomplete)
window.aircox = { window.aircox = {
app: app, // main application
LiveInfo: LiveInfo, app: null,
}
// main application config
appConfig: {},
// player application
playerApp: null,
// player component
get player() {
return this.playerApp && this.playerApp.$refs.player
}
};
loadApp({el: '#player'}).then(app => { window.aircox.playerApp = app },
() => undefined)
loadApp(() => window.aircox.appConfig ).then(app => { window.aircox.app = app },
() => undefined)

118
assets/public/model.js Normal file
View File

@ -0,0 +1,118 @@
import Vue from 'vue';
function getCookie(name) {
if(document.cookie && document.cookie !== '') {
const cookie = document.cookie.split(';')
.find(c => c.trim().startsWith(name + '='))
return cookie ? decodeURIComponent(cookie.split('=')[1]) : null;
}
return null;
}
var csrfToken = null;
export function getCsrf() {
if(csrfToken === null)
csrfToken = getCookie('csrftoken')
return csrfToken;
}
// TODO: move in another module for reuse
export default class Model {
constructor(data, {url=null}={}) {
this.commit(data);
}
static getId(data) {
return data.id;
}
static getOptions(options) {
return {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRFToken': getCsrf(),
},
...options,
}
}
static fetch(url, options=null, initArgs=null) {
options = this.getOptions(options)
return fetch(url, options)
.then(response => response.json())
.then(data => new this(d, {url: url, ...initArgs}));
}
static fetchAll(url, options=null, initArgs=null) {
options = this.getOptions(options)
return fetch(url, options)
.then(response => response.json())
.then(data => {
if(!(data instanceof Array))
data = data.results;
data = data.map(d => new this(d, {baseUrl: url, ...initArgs}));
return data
})
}
/**
* Fetch data from server.
*/
fetch(options) {
options = this.constructor.getOptions(options)
return fetch(this.url, options)
.then(response => response.json())
.then(data => this.commit(data));
}
/**
* Call API action on object.
*/
action(path, options, commit=false) {
options = this.constructor.getOptions(options)
const promise = fetch(this.url + path, options);
return commit ? promise.then(data => data.json())
.then(data => { this.commit(data); this.data })
: promise;
}
/**
* Update instance's data with provided data. Return None
*/
commit(data) {
this.id = this.constructor.getId(data);
this.url = data.url_;
Vue.set(this, 'data', data);
}
/**
* Return data as model with url prepent by `this.url`.
*/
asChild(model, data, prefix='') {
return new model(data, {baseUrl: `${this.url}${prefix}/`})
}
getChildOf(attr, id) {
const index = this.data[attr].findIndex(o => o.id = id)
return index == -1 ? null : this.data[attr][index];
}
static updateList(list=[], old=[]) {
return list.reduce((items, data) => {
const id = this.getId(data);
let [index, obj] = [old.findIndex(o => o.id == id), null];
if(index != -1) {
old[index].commit(data)
items.push(old[index]);
}
else
items.push(new this(data))
return items;
}, [])
}
}

10
assets/public/sound.js Normal file
View File

@ -0,0 +1,10 @@
import Model from './model';
export default class Sound extends Model {
get name() { return this.data.name }
static getId(data) { return data.pk }
}

View File

@ -1,8 +1,10 @@
@charset "utf-8"; @charset "utf-8";
@import "~bulma/sass/utilities/_all.sass"; @import "~bulma/sass/utilities/_all.sass";
@import "~bulma/sass/components/dropdown.sass";
$body-background-color: $light; $body-background-color: $light;
@import "~buefy/src/scss/components/_autocomplete.scss";
@import "~bulma"; @import "~bulma";
//-- helpers/modifiers //-- helpers/modifiers
@ -15,6 +17,10 @@ $body-background-color: $light;
} }
.is-borderless { border: none; } .is-borderless { border: none; }
.has-text-nowrap {
white-space: nowrap;
}
.has-background-transparent { .has-background-transparent {
background-color: transparent; background-color: transparent;
} }
@ -56,6 +62,22 @@ a.navbar-item.is-active {
margin: 0em; margin: 0em;
padding: 0em; padding: 0em;
} }
&.toolbar {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em;
.title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px $grey-light solid;
font-size: $size-5;
color: $text-light;
font-weight: $weight-light;
}
}
} }
//-- cards //-- cards
@ -91,24 +113,6 @@ a.navbar-item.is-active {
} }
//-- filters
.filters {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em;
.title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px $grey-light solid;
font-size: $size-5;
color: $text-light;
font-weight: $weight-light;
}
}
//-- page //-- page
.page { .page {
& > .cover { & > .cover {

View File

@ -0,0 +1,98 @@
import Vue from 'vue';
import Model from 'public/model';
export class Streamer extends Model {
get queues() { return this.data ? this.data.queues : []; }
get playlists() { return this.data ? this.data.playlists : []; }
get sources() { return [...this.queues, ...this.playlists]; }
get source() { return this.sources.find(o => o.id == this.data.source) }
commit(data) {
if(!this.data)
this.data = { id: data.id, playlists: [], queues: [] }
data.playlists = Playlist.updateList(data.playlists, this.playlists)
data.queues = Queue.updateList(data.queues, this.queues)
super.commit(data)
}
}
export class Request extends Model {
static getId(data) { return data.rid; }
}
export class Source extends Model {
constructor(...args) {
super(...args);
setInterval(() => this.tick(), 1000)
}
get isQueue() { return false; }
get isPlaylist() { return false; }
get isPlaying() { return this.data.status == 'playing' }
get isPaused() { return this.data.status == 'paused' }
get remainingString() {
if(!this.remaining)
return '00:00';
const seconds = Math.floor(this.remaining % 60);
const minutes = Math.floor(this.remaining / 60);
return String(minutes).padStart(2, '0') + ':' +
String(seconds).padStart(2, '0');
}
sync() { return this.action('sync/', {method: 'POST'}, true); }
skip() { return this.action('skip/', {method: 'POST'}, true); }
restart() { return this.action('restart/', {method: 'POST'}, true); }
seek(count) {
return this.action('seek/', {
method: 'POST',
body: JSON.stringify({count: count})
}, true)
}
tick() {
if(!this.data.remaining || !this.isPlaying)
return;
const delta = (Date.now() - this.commitDate) / 1000;
Vue.set(this, 'remaining', this.data.remaining - delta)
}
commit(data) {
if(data.air_time)
data.air_time = new Date(data.air_time);
Vue.set(this, 'remaining', data.remaining)
this.commitDate = Date.now()
super.commit(data)
}
}
export class Playlist extends Source {
get isPlaylist() { return true; }
}
export class Queue extends Source {
get isQueue() { return true; }
get queue() { return this.data && this.data.queue; }
commit(data) {
data.queue = Request.updateList(data.queue, this.queue)
super.commit(data)
}
push(soundId) {
return this.action('push/', {
method: 'POST',
body: JSON.stringify({'sound_id': parseInt(soundId)})
}, true);
}
}

56
assets/streamer/index.js Normal file
View File

@ -0,0 +1,56 @@
import Vue from 'vue';
import Button from 'buefy/dist/components/button';
Vue.use(Button)
import {setAppConfig} from 'public/app';
import Model from 'public/model';
import Sound from 'public/sound';
import {Streamer, Queue} from './controllers';
window.aircox.appConfig = {
data() {
return {
// current streamer
streamer: null,
// all streamers
streamers: [],
// fetch interval id
fetchInterval: null,
Sound: Sound,
}
},
computed: {
apiUrl() {
return this.$el && this.$el.dataset.apiUrl;
},
sources() {
var sources = this.streamer ? this.streamer.sources : [];
return sources.filter(s => s.data)
},
},
methods: {
fetchStreamers() {
Streamer.fetchAll(this.apiUrl, null)
.then(streamers => {
Vue.set(this, 'streamers', streamers);
Vue.set(this, 'streamer', streamers ? streamers[0] : null);
})
},
},
mounted() {
this.fetchStreamers();
this.fetchInterval = setInterval(() => this.streamer && this.streamer.fetch(), 5000)
},
destroyed() {
if(this.fetchInterval !== null)
clearInterval(this.fetchInterval)
}
}

View File

@ -121,9 +121,15 @@ except:
# Application definition # Application definition
INSTALLED_APPS = ( INSTALLED_APPS = (
# aircox & dependencies
'aircox', 'aircox',
'aircox.apps.AircoxAdminConfig',
'aircox_streamer',
# aircox applications
'rest_framework', 'rest_framework',
# aircox_web applications
"content_editor",
"ckeditor", "ckeditor",
'easy_thumbnails', 'easy_thumbnails',
'filer', 'filer',

View File

@ -19,6 +19,7 @@ from django.contrib import admin
from django.urls import include, path, re_path from django.urls import include, path, re_path
import aircox.urls import aircox.urls
import aircox_streamer.urls
try: try:

View File

@ -11,6 +11,7 @@
"css-loader": "^2.1.1", "css-loader": "^2.1.1",
"extract-text-webpack-plugin": "^4.0.0-beta.0", "extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^3.0.1", "file-loader": "^3.0.1",
"lodash": "^4.17.15",
"mini-css-extract-plugin": "^0.5.0", "mini-css-extract-plugin": "^0.5.0",
"node-sass": "^4.12.0", "node-sass": "^4.12.0",
"sass-loader": "^7.3.1", "sass-loader": "^7.3.1",

View File

@ -11,6 +11,7 @@ module.exports = (env, argv) => Object({
entry: { entry: {
main: './assets/public/index', main: './assets/public/index',
admin: './assets/admin/index', admin: './assets/admin/index',
streamer: './assets/streamer/index',
}, },
output: { output: {