diff --git a/aircox/admin/sound.py b/aircox/admin/sound.py
index b2082cd..7dff945 100644
--- a/aircox/admin/sound.py
+++ b/aircox/admin/sound.py
@@ -28,7 +28,7 @@ class SoundInline(admin.TabularInline):
max_num = 0
def audio(self, obj):
- return mark_safe(''.format(obj.url()))
+ return mark_safe(''.format(obj.file.url))
audio.short_descripton = _('Audio')
def get_queryset(self, request):
@@ -46,10 +46,10 @@ class SoundAdmin(admin.ModelAdmin):
search_fields = ['name', 'program__title']
fieldsets = [
- (None, {'fields': ['name', 'path', 'type', 'program', 'episode']}),
+ (None, {'fields': ['name', 'file', 'type', 'program', 'episode']}),
(None, {'fields': ['duration', 'is_public', 'is_good_quality', 'mtime']}),
]
- readonly_fields = ('path', 'duration',)
+ readonly_fields = ('file', 'duration',)
inlines = [SoundTrackInline]
def related(self, obj):
@@ -59,7 +59,7 @@ class SoundAdmin(admin.ModelAdmin):
related.short_description = _('Program / Episode')
def audio(self, obj):
- return mark_safe(''.format(obj.url()))
+ return mark_safe(''.format(obj.file.url))
audio.short_descripton = _('Audio')
diff --git a/aircox/admin_site.py b/aircox/admin_site.py
index ad189d8..2ee378d 100644
--- a/aircox/admin_site.py
+++ b/aircox/admin_site.py
@@ -40,7 +40,7 @@ class AdminSite(admin.AdminSite):
return context
def get_urls(self):
- urls = super().get_urls() + [
+ urls = [
path('api/', include((self.router.urls, 'api'))),
path('tools/statistics/',
self.admin_view(StatisticsView.as_view()),
@@ -48,7 +48,7 @@ class AdminSite(admin.AdminSite):
path('tools/statistics//',
self.admin_view(StatisticsView.as_view()),
name='tools-stats'),
- ] + self.extra_urls
+ ] + self.extra_urls + super().get_urls()
return urls
def get_tools(self):
diff --git a/aircox/management/commands/import_playlist.py b/aircox/management/commands/import_playlist.py
index 26716a5..656fdea 100755
--- a/aircox/management/commands/import_playlist.py
+++ b/aircox/management/commands/import_playlist.py
@@ -128,7 +128,7 @@ class Command(BaseCommand):
def handle(self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path
if options.get('sound'):
- sound = Sound.objects.filter(path__icontains=options.get('sound'))\
+ sound = Sound.objects.filter(file__icontains=options.get('sound'))\
.first()
else:
path_, ext = os.path.splitext(path)
diff --git a/aircox/management/commands/sounds_monitor.py b/aircox/management/commands/sounds_monitor.py
index 00f8367..896fab3 100755
--- a/aircox/management/commands/sounds_monitor.py
+++ b/aircox/management/commands/sounds_monitor.py
@@ -36,6 +36,7 @@ import mutagen
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent
+from django.conf import settings as conf
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from django.utils.translation import gettext as _
@@ -64,12 +65,16 @@ class SoundFile:
def __init__(self, path):
self.path = path
+ @property
+ def sound_path(self):
+ return self.path.replace(conf.MEDIA_ROOT + '/', '')
+
def sync(self, sound=None, program=None, deleted=False, **kwargs):
"""
Update related sound model and save it.
"""
if deleted:
- sound = Sound.objects.filter(path=self.path).first()
+ sound = Sound.objects.filter(file=self.path).first()
if sound:
sound.type = sound.TYPE_REMOVED
sound.check_on_file()
@@ -78,13 +83,13 @@ class SoundFile:
# FIXME: sound.program as not null
program = kwargs['program'] = Program.get_from_path(self.path)
- sound, created = Sound.objects.get_or_create(path=self.path, defaults=kwargs) \
+ sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs) \
if not sound else (sound, False)
self.sound = sound
sound.program = program
if created or sound.check_on_file():
- logger.info('sound is new or have been modified -> %s', self.path)
+ logger.info('sound is new or have been modified -> %s', self.sound_path)
self.read_path()
sound.name = self.path_info.get('name')
@@ -153,7 +158,7 @@ class SoundFile:
if not diffusion:
return None
- logger.info('%s <--> %s', self.sound.path, str(diffusion.episode))
+ logger.info('%s <--> %s', self.sound.file.name, str(diffusion.episode))
self.sound.episode = diffusion.episode
return diffusion
@@ -172,7 +177,7 @@ class SoundFile:
return
# import playlist
- path = os.path.splitext(self.sound.path)[0] + '.csv'
+ path = os.path.splitext(self.sound.file.path)[0] + '.csv'
if os.path.exists(path):
PlaylistImport(path, sound=sound).run()
# use metadata
@@ -227,7 +232,7 @@ class MonitorHandler(PatternMatchingEventHandler):
def on_moved(self, event):
logger.info('sound moved: %s -> %s', event.src_path, event.dest_path)
def moved(event, sound_kwargs):
- sound = Sound.objects.filter(path=event.src_path)
+ sound = Sound.objects.filter(file=event.src_path)
sound_file = SoundFile(event.dest_path) if not sound else sound
sound_file.sync(**sound_kwargs)
self.pool.submit(moved, event, self.sound_kwargs)
@@ -268,7 +273,8 @@ class Command(BaseCommand):
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
type=Sound.TYPE_EXCERPT,
)
- dirs.append(os.path.join(program.path))
+ dirs.append(os.path.join(program.abspath))
+ return dirs
def scan_for_program(self, program, subdir, **sound_kwargs):
"""
@@ -279,7 +285,7 @@ class Command(BaseCommand):
if not program.ensure_dir(subdir):
return
- subdir = os.path.join(program.path, subdir)
+ subdir = os.path.join(program.abspath, subdir)
sounds = []
# sounds in directory
@@ -293,7 +299,7 @@ class Command(BaseCommand):
sounds.append(sound_file.sound.pk)
# sounds in db & unchecked
- sounds = Sound.objects.filter(path__startswith=subdir). \
+ sounds = Sound.objects.filter(file__startswith=subdir). \
exclude(pk__in=sounds)
self.check_sounds(sounds, program=program)
@@ -302,7 +308,7 @@ class Command(BaseCommand):
# check files
for sound in qs:
if sound.check_on_file():
- SoundFile(sound.path).sync(sound=sound, **sync_kwargs)
+ SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
def monitor(self):
""" Run in monitor mode """
diff --git a/aircox/management/commands/sounds_quality_check.py b/aircox/management/commands/sounds_quality_check.py
index 529f35a..88d0df9 100755
--- a/aircox/management/commands/sounds_quality_check.py
+++ b/aircox/management/commands/sounds_quality_check.py
@@ -160,7 +160,7 @@ class Command (BaseCommand):
self.bad = []
self.good = []
for sound in self.sounds:
- logger.info('analyse ' + sound.path)
+ logger.info('analyse ' + sound.file.name)
sound.analyse()
sound.check(attr, minmax[0], minmax[1])
if sound.bad:
@@ -171,6 +171,6 @@ class Command (BaseCommand):
# resume
if options.get('resume'):
for sound in self.good:
- logger.info('\033[92m+ %s\033[0m', sound.path)
+ logger.info('\033[92m+ %s\033[0m', sound.file.name)
for sound in self.bad:
- logger.info('\033[91m+ %s\033[0m', sound.path)
+ logger.info('\033[91m+ %s\033[0m', sound.file.name)
diff --git a/aircox/models/program.py b/aircox/models/program.py
index 0931c3a..892c0ab 100644
--- a/aircox/models/program.py
+++ b/aircox/models/program.py
@@ -7,6 +7,7 @@ import os
import shutil
import pytz
+from django.conf import settings as conf
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
@@ -73,14 +74,17 @@ class Program(Page):
self.slug.replace('-', '_'))
@property
- def archives_path(self):
- return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
+ def abspath(self):
+ """ Return absolute path to program's dir """
+ return os.path.join(conf.MEDIA_ROOT, self.path)
- @property
- def excerpts_path(self):
- return os.path.join(
- self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
- )
+ def archives_path(self, abs=False):
+ return os.path.join(abs and self.abspath or self.path,
+ settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
+
+ def excerpts_path(self, abs=False):
+ return os.path.join(abs and self.abspath or self.path,
+ settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
@@ -94,6 +98,8 @@ class Program(Page):
Return a Program from the given path. We assume the path has been
given in a previous time by this model (Program.path getter).
"""
+ if path.startswith(conf.MEDIA_ROOT):
+ path = path.replace(conf.MEDIA_ROOT + '/', '')
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
while path[0] == '/':
@@ -107,10 +113,9 @@ class Program(Page):
Make sur the program's dir exists (and optionally subdir). Return True
if the dir (or subdir) exists.
"""
- path = os.path.join(self.path, subdir) if subdir else \
- self.path
+ path = os.path.join(self.abspath, subdir) if subdir else \
+ self.abspath
os.makedirs(path, exist_ok=True)
-
return os.path.exists(path)
class Meta:
@@ -127,14 +132,15 @@ class Program(Page):
# TODO: move in signals
path_ = getattr(self, '__initial_path', None)
+ abspath = os.path.join(conf.MEDIA_ROOT, path_)
if path_ is not None and path_ != self.path and \
- os.path.exists(path_) and not os.path.exists(self.path):
+ os.path.exists(abspath) and not os.path.exists(self.abspath):
logger.info('program #%s\'s dir changed to %s - update it.',
self.id, self.title)
- shutil.move(path_, self.path)
+ shutil.move(abspath, self.abspath)
Sound.objects.filter(path__startswith=path_) \
- .update(path=Concat('path', Substr(F('path'), len(path_))))
+ .update(file=Concat('file', Substr(F('file'), len(path_))))
class ProgramChildQuerySet(PageQuerySet):
diff --git a/aircox/models/sound.py b/aircox/models/sound.py
index 9246b93..e805d11 100644
--- a/aircox/models/sound.py
+++ b/aircox/models/sound.py
@@ -4,7 +4,8 @@ import os
from django.conf import settings as main_settings
from django.db import models
-from django.db.models import Q
+from django.db.models import Q, Value as V
+from django.db.models.functions import Concat
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
@@ -54,7 +55,9 @@ class SoundQuerySet(models.QuerySet):
self = self.archive()
if order_by:
self = self.order_by('path')
- return self.filter(path__isnull=False).values_list('path', flat=True)
+ return self.filter(file__isnull=False) \
+ .annotate(file_path=Concat(V(conf.MEDIA_ROOT), 'file')) \
+ .values_list('file_path', flat=True)
def search(self, query):
return self.filter(
@@ -94,21 +97,15 @@ class Sound(models.Model):
position = models.PositiveSmallIntegerField(
_('order'), default=0, help_text=_('position in the playlist'),
)
- # FIXME: url() does not use the same directory than here
- # should we use FileField for more reliability?
- path = models.FilePathField(
- _('file'),
- path=settings.AIRCOX_PROGRAMS_DIR,
- match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT)
- .replace('.', r'\.') + ')$',
- recursive=True, max_length=255,
- blank=True, null=True, unique=True,
+
+ def _upload_to(self, filename):
+ subdir = AIRCOX_SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else \
+ AIRCOX_SOUND_EXCERPTS_SUBDIR
+ return os.path.join(o.program.path, subdir, filename)
+
+ file = models.FileField(
+ _('file'), upload_to=_upload_to
)
- #embed = models.TextField(
- # _('embed'),
- # blank=True, null=True,
- # help_text=_('HTML code to embed a sound from an external plateform'),
- #)
duration = models.TimeField(
_('duration'),
blank=True, null=True,
@@ -134,8 +131,12 @@ class Sound(models.Model):
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
+ @property
+ def url(self):
+ return self.file and self.file.url
+
def __str__(self):
- return '/'.join(self.path.split('/')[-3:])
+ return '/'.join(self.file.path.split('/')[-3:])
def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None:
@@ -145,17 +146,12 @@ class Sound(models.Model):
self.__check_name()
super().save(*args, **kwargs)
- def url(self):
- """ Return an url to the file. """
- path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
- return (main_settings.MEDIA_URL + path).replace('//','/')
-
# TODO: rename get_file_mtime(self)
def get_mtime(self):
"""
Get the last modification date from file
"""
- mtime = os.stat(self.path).st_mtime
+ mtime = os.stat(self.file.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
@@ -163,7 +159,7 @@ class Sound(models.Model):
def file_exists(self):
""" Return true if the file still exists. """
- return os.path.exists(self.path)
+ return os.path.exists(self.file.path)
def check_on_file(self):
"""
@@ -173,7 +169,7 @@ class Sound(models.Model):
if not self.file_exists():
if self.type == self.TYPE_REMOVED:
return
- logger.info('sound %s: has been removed', self.path)
+ logger.info('sound %s: has been removed', self.file.name)
self.type = self.TYPE_REMOVED
return True
@@ -183,7 +179,7 @@ class Sound(models.Model):
if self.type == self.TYPE_REMOVED and self.program:
changed = True
self.type = self.TYPE_ARCHIVE \
- if self.path.startswith(self.program.archives_path) else \
+ if self.file.path.startswith(self.program.archives_path) else \
self.TYPE_EXCERPT
# check mtime -> reset quality if changed (assume file changed)
@@ -193,15 +189,15 @@ class Sound(models.Model):
self.mtime = mtime
self.is_good_quality = None
logger.info('sound %s: m_time has changed. Reset quality info',
- self.path)
+ self.file.name)
return True
return changed
def __check_name(self):
- if not self.name and self.path:
+ if not self.name and self.file and self.file.path:
# FIXME: later, remove date?
- self.name = os.path.basename(self.path)
+ self.name = os.path.basename(self.file.name)
self.name = os.path.splitext(self.name)[0]
self.name = self.name.replace('_', ' ')
diff --git a/aircox/serializers.py b/aircox/serializers.py
index 344efe4..746ca55 100644
--- a/aircox/serializers.py
+++ b/aircox/serializers.py
@@ -55,7 +55,6 @@ class LogInfoSerializer(serializers.Serializer):
class SoundSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
-
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type',
@@ -64,7 +63,7 @@ class SoundSerializer(serializers.ModelSerializer):
def get_field_names(self, *args):
names = super().get_field_names(*args)
if 'request' in self.context and self.context['request'].user.is_staff:
- names.append('path')
+ names.append('file')
return names
class PodcastSerializer(serializers.ModelSerializer):
diff --git a/aircox/settings.py b/aircox/settings.py
index c30d549..fb54742 100755
--- a/aircox/settings.py
+++ b/aircox/settings.py
@@ -87,8 +87,7 @@ ensure('AIRCOX_DEFAULT_USER_GROUPS', {
# Directory for the programs data
# TODO: rename to PROGRAMS_ROOT
-ensure('AIRCOX_PROGRAMS_DIR',
- os.path.join(settings.MEDIA_ROOT, 'programs'))
+ensure('AIRCOX_PROGRAMS_DIR', 'programs')
########################################################################
@@ -152,8 +151,3 @@ ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
-if settings.MEDIA_ROOT not in AIRCOX_PROGRAMS_DIR:
- # PROGRAMS_DIR must be in MEDIA_ROOT for easy files url resolution
- # later should this restriction disappear.
- raise ValueError("settings: AIRCOX_PROGRAMS_DIR must be in MEDIA_ROOT")
-
diff --git a/aircox/static/aircox/js/chunk-common.js b/aircox/static/aircox/js/chunk-common.js
index e05a0bd..fe48887 100644
--- a/aircox/static/aircox/js/chunk-common.js
+++ b/aircox/static/aircox/js/chunk-common.js
@@ -55,7 +55,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */
\*****************************************************************************************************************************************************************************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
-eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"State\": function() { return /* binding */ State; }\n/* harmony export */ });\n/* harmony import */ var _live__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../live */ \"./src/live.js\");\n/* harmony import */ var _sound__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../sound */ \"./src/sound.js\");\n/* harmony import */ var _model__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../model */ \"./src/model.js\");\n/* harmony import */ var _APlaylist__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./APlaylist */ \"./src/components/APlaylist.vue\");\n/* harmony import */ var _AProgress__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./AProgress */ \"./src/components/AProgress.vue\");\n\n\n\n\n\nconst State = {\n paused: 0,\n playing: 1,\n loading: 2\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n components: {\n APlaylist: _APlaylist__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n AProgress: _AProgress__WEBPACK_IMPORTED_MODULE_4__[\"default\"]\n },\n\n data() {\n let audio = new Audio();\n audio.addEventListener('ended', e => this.onState(e));\n audio.addEventListener('pause', e => this.onState(e));\n audio.addEventListener('playing', e => this.onState(e));\n audio.addEventListener('timeupdate', () => {\n this.currentTime = this.audio.currentTime;\n });\n audio.addEventListener('durationchange', () => {\n this.duration = Number.isFinite(this.audio.duration) ? this.audio.duration : null;\n });\n let live = this.liveArgs ? new _live__WEBPACK_IMPORTED_MODULE_0__[\"default\"](this.liveArgs) : null;\n live && live.refresh();\n return {\n audio,\n duration: 0,\n currentTime: 0,\n state: State.paused,\n live,\n /// Loaded item\n loaded: null,\n //! Active panel name\n panel: null,\n //! current playing playlist name\n playlistName: null,\n //! players' playlists' sets\n sets: {\n queue: _model__WEBPACK_IMPORTED_MODULE_2__.Set.storeLoad(_sound__WEBPACK_IMPORTED_MODULE_1__[\"default\"], \"playlist.queue\", {\n max: 30,\n unique: true\n }),\n pin: _model__WEBPACK_IMPORTED_MODULE_2__.Set.storeLoad(_sound__WEBPACK_IMPORTED_MODULE_1__[\"default\"], \"player.pin\", {\n max: 30,\n unique: true\n })\n }\n };\n },\n\n props: {\n buttonTitle: String,\n liveArgs: Object\n },\n computed: {\n self() {\n return this;\n },\n\n paused() {\n return this.state == State.paused;\n },\n\n playing() {\n return this.state == State.playing;\n },\n\n loading() {\n return this.state == State.loading;\n },\n\n playlist() {\n return this.playlistName ? this.$refs[this.playlistName] : null;\n },\n\n current() {\n return this.loaded ? this.loaded : this.live && this.live.current;\n }\n\n },\n methods: {\n displayTime(seconds) {\n seconds = parseInt(seconds);\n let s = seconds % 60;\n seconds = (seconds - s) / 60;\n let m = seconds % 60;\n let h = (seconds - m) / 60;\n let [ss, mm, hh] = [s.toString().padStart(2, '0'), m.toString().padStart(2, '0'), h.toString().padStart(2, '0')];\n return h ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`;\n },\n\n playlistButtonClass(name) {\n let set = this.sets[name];\n return (set ? (set.length ? \"\" : \"has-text-grey-light \") + (this.panel == name ? \"is-info \" : this.playlistName == name ? 'is-primary ' : '') : '') + \"button has-text-weight-bold\";\n },\n\n /// Show/hide panel\n togglePanel(panel) {\n this.panel = this.panel == panel ? null : panel;\n },\n\n /// Return True if item is loaded\n isLoaded(item) {\n return this.loaded && this.loaded.id == item.id;\n },\n\n /// Return True if item is loaded\n isPlaying(item) {\n return this.isLoaded(item) && !this.paused;\n },\n\n _setPlaylist(playlist) {\n this.playlistName = playlist;\n\n for (var p in this.sets) if (p != playlist) this.$refs[p].unselect();\n },\n\n /// Load a sound from playlist or live\n load(playlist = null, index = 0) {\n let src = null; // from playlist\n\n if (playlist !== null) {\n let item = this.$refs[playlist].get(index);\n if (!item) throw `No sound at index ${index} for playlist ${playlist}`;\n this.loaded = item;\n src = item.src;\n } // from live\n else {\n this.loaded = null;\n src = this.live.src;\n }\n\n this._setPlaylist(playlist); // load sources\n\n\n const audio = this.audio;\n\n if (src instanceof Array) {\n audio.innerHTML = '';\n audio.removeAttribute('src');\n\n for (var s of src) {\n let source = document.createElement('source');\n source.setAttribute('src', s);\n audio.appendChild(source);\n }\n } else {\n audio.src = src;\n }\n\n audio.load();\n },\n\n play(playlist = null, index = 0) {\n this.load(playlist, index);\n this.audio.play().catch(e => console.error(e));\n },\n\n /// Push items to playlist (by name)\n push(playlist, ...items) {\n return this.$refs[playlist].push(...items);\n },\n\n /// Push and play items\n playItems(playlist, ...items) {\n let index = this.push(playlist, ...items);\n this.$refs[playlist].selectedIndex = index;\n this.play(playlist, index);\n },\n\n /// Handle click event that plays multiple items (from `data-sounds` attribute)\n playButtonClick(event) {\n var items = JSON.parse(event.currentTarget.dataset.sounds);\n this.playItems('queue', ...items);\n },\n\n /// Pause\n pause() {\n this.audio.pause();\n },\n\n //! Play/pause\n togglePlay(playlist = null, index = 0) {\n if (playlist !== null) {\n let item = this.sets[playlist].get(index);\n\n if (!this.playlist || this.playlistName !== playlist || this.loaded != item) {\n this.play(playlist, index);\n return;\n }\n }\n\n if (this.paused) this.audio.play().catch(e => console.error(e));else this.audio.pause();\n },\n\n //! Pin/Unpin an item\n togglePin(item) {\n let index = this.sets.pin.findIndex(item);\n if (index > -1) this.sets.pin.remove(index);else {\n this.sets.pin.push(item);\n this.$refs.pinPlaylistButton.focus();\n }\n },\n\n /// Audio player state change event\n onState(event) {\n const audio = this.audio;\n this.state = audio.paused ? State.paused : State.playing;\n if (event.type == 'ended' && (!this.playlist || this.playlist.selectNext() == -1)) this.play();\n }\n\n },\n\n mounted() {\n this.load();\n }\n\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/APlayer.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D");
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"State\": function() { return /* binding */ State; }\n/* harmony export */ });\n/* harmony import */ var _live__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../live */ \"./src/live.js\");\n/* harmony import */ var _sound__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../sound */ \"./src/sound.js\");\n/* harmony import */ var _model__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../model */ \"./src/model.js\");\n/* harmony import */ var _APlaylist__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./APlaylist */ \"./src/components/APlaylist.vue\");\n/* harmony import */ var _AProgress__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./AProgress */ \"./src/components/AProgress.vue\");\n\n\n\n\n\nconst State = {\n paused: 0,\n playing: 1,\n loading: 2\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n components: {\n APlaylist: _APlaylist__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n AProgress: _AProgress__WEBPACK_IMPORTED_MODULE_4__[\"default\"]\n },\n\n data() {\n let audio = new Audio();\n audio.addEventListener('ended', e => this.onState(e));\n audio.addEventListener('pause', e => this.onState(e));\n audio.addEventListener('playing', e => this.onState(e));\n audio.addEventListener('timeupdate', () => {\n this.currentTime = this.audio.currentTime;\n });\n audio.addEventListener('durationchange', () => {\n this.duration = Number.isFinite(this.audio.duration) ? this.audio.duration : null;\n });\n let live = this.liveArgs ? new _live__WEBPACK_IMPORTED_MODULE_0__[\"default\"](this.liveArgs) : null;\n live && live.refresh();\n return {\n audio,\n duration: 0,\n currentTime: 0,\n state: State.paused,\n live,\n /// Loaded item\n loaded: null,\n //! Active panel name\n panel: null,\n //! current playing playlist name\n playlistName: null,\n //! players' playlists' sets\n sets: {\n queue: _model__WEBPACK_IMPORTED_MODULE_2__.Set.storeLoad(_sound__WEBPACK_IMPORTED_MODULE_1__[\"default\"], \"playlist.queue\", {\n max: 30,\n unique: true\n }),\n pin: _model__WEBPACK_IMPORTED_MODULE_2__.Set.storeLoad(_sound__WEBPACK_IMPORTED_MODULE_1__[\"default\"], \"player.pin\", {\n max: 30,\n unique: true\n })\n }\n };\n },\n\n props: {\n buttonTitle: String,\n liveArgs: Object\n },\n computed: {\n self() {\n return this;\n },\n\n paused() {\n return this.state == State.paused;\n },\n\n playing() {\n return this.state == State.playing;\n },\n\n loading() {\n return this.state == State.loading;\n },\n\n playlist() {\n return this.playlistName ? this.$refs[this.playlistName] : null;\n },\n\n current() {\n return this.loaded ? this.loaded : this.live && this.live.current;\n }\n\n },\n methods: {\n displayTime(seconds) {\n seconds = parseInt(seconds);\n let s = seconds % 60;\n seconds = (seconds - s) / 60;\n let m = seconds % 60;\n let h = (seconds - m) / 60;\n let [ss, mm, hh] = [s.toString().padStart(2, '0'), m.toString().padStart(2, '0'), h.toString().padStart(2, '0')];\n return h ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`;\n },\n\n playlistButtonClass(name) {\n let set = this.sets[name];\n return (set ? (set.length ? \"\" : \"has-text-grey-light \") + (this.panel == name ? \"is-info \" : this.playlistName == name ? 'is-primary ' : '') : '') + \"button has-text-weight-bold\";\n },\n\n /// Show/hide panel\n togglePanel(panel) {\n this.panel = this.panel == panel ? null : panel;\n },\n\n /// Return True if item is loaded\n isLoaded(item) {\n return this.loaded && this.loaded.id == item.id;\n },\n\n /// Return True if item is loaded\n isPlaying(item) {\n return this.isLoaded(item) && !this.paused;\n },\n\n _setPlaylist(playlist) {\n this.playlistName = playlist;\n\n for (var p in this.sets) if (p != playlist) this.$refs[p].unselect();\n },\n\n /// Load a sound from playlist or live\n load(playlist = null, index = 0) {\n let src = null; // from playlist\n\n if (playlist !== null) {\n let item = this.$refs[playlist].get(index);\n if (!item) throw `No sound at index ${index} for playlist ${playlist}`;\n this.loaded = item;\n src = item.src;\n } // from live\n else {\n this.loaded = null;\n src = this.live.src;\n }\n\n this._setPlaylist(playlist); // load sources\n\n\n const audio = this.audio;\n\n if (src instanceof Array) {\n audio.innerHTML = '';\n audio.removeAttribute('src');\n\n for (var s of src) {\n let source = document.createElement('source');\n source.setAttribute('src', s);\n audio.appendChild(source);\n }\n } else {\n audio.src = src;\n }\n\n audio.load();\n },\n\n play(playlist = null, index = 0) {\n this.load(playlist, index);\n this.audio.play().catch(e => console.error(e));\n },\n\n /// Push items to playlist (by name)\n push(playlist, ...items) {\n return this.sets[playlist].push(...items);\n },\n\n /// Push and play items\n playItems(playlist, ...items) {\n let index = this.push(playlist, ...items);\n this.$refs[playlist].selectedIndex = index;\n this.play(playlist, index);\n },\n\n /// Handle click event that plays multiple items (from `data-sounds` attribute)\n playButtonClick(event) {\n var items = JSON.parse(event.currentTarget.dataset.sounds);\n this.playItems('queue', ...items);\n },\n\n /// Pause\n pause() {\n this.audio.pause();\n },\n\n //! Play/pause\n togglePlay(playlist = null, index = 0) {\n if (playlist !== null) {\n let item = this.sets[playlist].get(index);\n\n if (!this.playlist || this.playlistName !== playlist || this.loaded != item) {\n this.play(playlist, index);\n return;\n }\n }\n\n if (this.paused) this.audio.play().catch(e => console.error(e));else this.audio.pause();\n },\n\n //! Pin/Unpin an item\n togglePin(item) {\n let index = this.sets.pin.findIndex(item);\n if (index > -1) this.sets.pin.remove(index);else {\n this.sets.pin.push(item);\n this.$refs.pinPlaylistButton.focus();\n }\n },\n\n /// Audio player state change event\n onState(event) {\n const audio = this.audio;\n this.state = audio.paused ? State.paused : State.playing;\n if (event.type == 'ended' && (!this.playlist || this.playlist.selectNext() == -1)) this.play();\n }\n\n },\n\n mounted() {\n this.load();\n }\n\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/APlayer.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D");
/***/ }),
@@ -65,7 +65,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac
\*******************************************************************************************************************************************************************************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
-eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _AList__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./AList */ \"./src/components/AList.vue\");\n/* harmony import */ var _ASoundItem__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./ASoundItem */ \"./src/components/ASoundItem.vue\");\n\n\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n extends: _AList__WEBPACK_IMPORTED_MODULE_0__[\"default\"],\n emits: [..._AList__WEBPACK_IMPORTED_MODULE_0__[\"default\"].emits, 'remove'],\n components: {\n ASoundItem: _ASoundItem__WEBPACK_IMPORTED_MODULE_1__[\"default\"]\n },\n props: {\n actions: Array,\n name: String,\n player: Object,\n editable: Boolean\n },\n computed: {\n self() {\n return this;\n }\n\n },\n methods: {\n hasAction(action) {\n return this.actions && this.actions.indexOf(action) != -1;\n },\n\n selectNext() {\n let index = this.selectedIndex + 1;\n return this.select(index >= this.items.length ? -1 : index);\n },\n\n togglePlay(index) {\n if (this.player.isPlaying(this.set.get(index))) this.player.pause();else this.select(index);\n }\n\n }\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/APlaylist.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D");
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _AList__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./AList */ \"./src/components/AList.vue\");\n/* harmony import */ var _ASoundItem__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./ASoundItem */ \"./src/components/ASoundItem.vue\");\n\n\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n extends: _AList__WEBPACK_IMPORTED_MODULE_0__[\"default\"],\n emits: [..._AList__WEBPACK_IMPORTED_MODULE_0__[\"default\"].emits, 'remove'],\n components: {\n ASoundItem: _ASoundItem__WEBPACK_IMPORTED_MODULE_1__[\"default\"]\n },\n props: {\n actions: Array,\n name: String,\n player: Object,\n editable: Boolean\n },\n computed: {\n self() {\n return this;\n },\n\n player_() {\n return this.player || window.aircox.player;\n }\n\n },\n methods: {\n hasAction(action) {\n return this.actions && this.actions.indexOf(action) != -1;\n },\n\n selectNext() {\n let index = this.selectedIndex + 1;\n return this.select(index >= this.items.length ? -1 : index);\n },\n\n togglePlay(index) {\n if (this.player_.isPlaying(this.set.get(index))) this.player_.pause();else this.select(index);\n }\n\n }\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/APlaylist.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D");
/***/ }),
@@ -165,7 +165,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac
\***********************************************************************************************************************************************************************************************************************************************************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
-eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"render\": function() { return /* binding */ render; }\n/* harmony export */ });\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm-bundler.js\");\n\nconst _hoisted_1 = [\"onClick\"];\nconst _hoisted_2 = [\"onClick\"];\n\nconst _hoisted_3 = /*#__PURE__*/(0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"span\", {\n class: \"icon is-small\"\n}, [/*#__PURE__*/(0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"span\", {\n class: \"fa fa-minus\"\n})], -1\n/* HOISTED */\n);\n\nconst _hoisted_4 = [_hoisted_3];\nfunction render(_ctx, _cache, $props, $setup, $data, $options) {\n const _component_ASoundItem = (0,vue__WEBPACK_IMPORTED_MODULE_0__.resolveComponent)(\"ASoundItem\");\n\n return (0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(\"div\", null, [(0,vue__WEBPACK_IMPORTED_MODULE_0__.renderSlot)(_ctx.$slots, \"header\"), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"ul\", {\n class: (0,vue__WEBPACK_IMPORTED_MODULE_0__.normalizeClass)(_ctx.listClass)\n }, [((0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(true), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(vue__WEBPACK_IMPORTED_MODULE_0__.Fragment, null, (0,vue__WEBPACK_IMPORTED_MODULE_0__.renderList)(_ctx.items, (item, index) => {\n return (0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(\"li\", {\n class: (0,vue__WEBPACK_IMPORTED_MODULE_0__.normalizeClass)(_ctx.itemClass),\n onClick: $event => !$options.hasAction('play') && _ctx.select(index),\n key: index\n }, [(0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"a\", {\n class: (0,vue__WEBPACK_IMPORTED_MODULE_0__.normalizeClass)(index == _ctx.selectedIndex ? 'is-active' : '')\n }, [(0,vue__WEBPACK_IMPORTED_MODULE_0__.createVNode)(_component_ASoundItem, {\n data: item,\n index: index,\n player: $props.player,\n set: _ctx.set,\n onTogglePlay: $event => $options.togglePlay(index),\n actions: $props.actions\n }, {\n actions: (0,vue__WEBPACK_IMPORTED_MODULE_0__.withCtx)(({}) => [$props.editable ? ((0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(\"button\", {\n key: 0,\n class: \"button\",\n onClick: (0,vue__WEBPACK_IMPORTED_MODULE_0__.withModifiers)($event => _ctx.remove(index, true), [\"stop\"])\n }, _hoisted_4, 8\n /* PROPS */\n , _hoisted_2)) : (0,vue__WEBPACK_IMPORTED_MODULE_0__.createCommentVNode)(\"v-if\", true)]),\n _: 2\n /* DYNAMIC */\n\n }, 1032\n /* PROPS, DYNAMIC_SLOTS */\n , [\"data\", \"index\", \"player\", \"set\", \"onTogglePlay\", \"actions\"])], 2\n /* CLASS */\n )], 10\n /* CLASS, PROPS */\n , _hoisted_1);\n }), 128\n /* KEYED_FRAGMENT */\n ))], 2\n /* CLASS */\n ), (0,vue__WEBPACK_IMPORTED_MODULE_0__.renderSlot)(_ctx.$slots, \"footer\")]);\n}\n\n//# sourceURL=webpack://aircox-assets/./src/components/APlaylist.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/templateLoader.js??ruleSet%5B1%5D.rules%5B3%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D");
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"render\": function() { return /* binding */ render; }\n/* harmony export */ });\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm-bundler.js\");\n\nconst _hoisted_1 = [\"onClick\"];\nconst _hoisted_2 = [\"onClick\"];\n\nconst _hoisted_3 = /*#__PURE__*/(0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"span\", {\n class: \"icon is-small\"\n}, [/*#__PURE__*/(0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"span\", {\n class: \"fa fa-minus\"\n})], -1\n/* HOISTED */\n);\n\nconst _hoisted_4 = [_hoisted_3];\nfunction render(_ctx, _cache, $props, $setup, $data, $options) {\n const _component_ASoundItem = (0,vue__WEBPACK_IMPORTED_MODULE_0__.resolveComponent)(\"ASoundItem\");\n\n return (0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(\"div\", null, [(0,vue__WEBPACK_IMPORTED_MODULE_0__.renderSlot)(_ctx.$slots, \"header\"), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"ul\", {\n class: (0,vue__WEBPACK_IMPORTED_MODULE_0__.normalizeClass)(_ctx.listClass)\n }, [((0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(true), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(vue__WEBPACK_IMPORTED_MODULE_0__.Fragment, null, (0,vue__WEBPACK_IMPORTED_MODULE_0__.renderList)(_ctx.items, (item, index) => {\n return (0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(\"li\", {\n class: (0,vue__WEBPACK_IMPORTED_MODULE_0__.normalizeClass)(_ctx.itemClass),\n onClick: $event => !$options.hasAction('play') && _ctx.select(index),\n key: index\n }, [(0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementVNode)(\"a\", {\n class: (0,vue__WEBPACK_IMPORTED_MODULE_0__.normalizeClass)(index == _ctx.selectedIndex ? 'is-active' : '')\n }, [(0,vue__WEBPACK_IMPORTED_MODULE_0__.createVNode)(_component_ASoundItem, {\n data: item,\n index: index,\n set: _ctx.set,\n player: $options.player_,\n onTogglePlay: $event => $options.togglePlay(index),\n actions: $props.actions\n }, {\n actions: (0,vue__WEBPACK_IMPORTED_MODULE_0__.withCtx)(({}) => [$props.editable ? ((0,vue__WEBPACK_IMPORTED_MODULE_0__.openBlock)(), (0,vue__WEBPACK_IMPORTED_MODULE_0__.createElementBlock)(\"button\", {\n key: 0,\n class: \"button\",\n onClick: (0,vue__WEBPACK_IMPORTED_MODULE_0__.withModifiers)($event => _ctx.remove(index, true), [\"stop\"])\n }, _hoisted_4, 8\n /* PROPS */\n , _hoisted_2)) : (0,vue__WEBPACK_IMPORTED_MODULE_0__.createCommentVNode)(\"v-if\", true)]),\n _: 2\n /* DYNAMIC */\n\n }, 1032\n /* PROPS, DYNAMIC_SLOTS */\n , [\"data\", \"index\", \"set\", \"player\", \"onTogglePlay\", \"actions\"])], 2\n /* CLASS */\n )], 10\n /* CLASS, PROPS */\n , _hoisted_1);\n }), 128\n /* KEYED_FRAGMENT */\n ))], 2\n /* CLASS */\n ), (0,vue__WEBPACK_IMPORTED_MODULE_0__.renderSlot)(_ctx.$slots, \"footer\")]);\n}\n\n//# sourceURL=webpack://aircox-assets/./src/components/APlaylist.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/templateLoader.js??ruleSet%5B1%5D.rules%5B3%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D");
/***/ }),
@@ -215,7 +215,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac
\********************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
-eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"PlayerApp\": function() { return /* binding */ PlayerApp; }\n/* harmony export */ });\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n\nconst App = {\n el: '#app',\n delimiters: ['[[', ']]'],\n components: { ..._components__WEBPACK_IMPORTED_MODULE_0__[\"default\"]\n },\n computed: {\n player() {\n return window.aircox.player;\n }\n\n }\n};\nconst PlayerApp = {\n el: '#player',\n components: { ..._components__WEBPACK_IMPORTED_MODULE_0__[\"default\"]\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (App);\n\n//# sourceURL=webpack://aircox-assets/./src/app.js?");
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"PlayerApp\": function() { return /* binding */ PlayerApp; }\n/* harmony export */ });\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n\nconst App = {\n el: '#app',\n delimiters: ['[[', ']]'],\n components: { ..._components__WEBPACK_IMPORTED_MODULE_0__[\"default\"]\n },\n computed: {\n player() {\n return window.aircox.player;\n }\n\n }\n};\nconst PlayerApp = {\n el: '#player',\n delimiters: ['[[', ']]'],\n components: { ..._components__WEBPACK_IMPORTED_MODULE_0__[\"default\"]\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (App);\n\n//# sourceURL=webpack://aircox-assets/./src/app.js?");
/***/ }),
@@ -245,7 +245,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac
\**********************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
-eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_0__ = __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_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _appBuilder__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./appBuilder */ \"./src/appBuilder.js\");\n/* harmony import */ var _sound__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./sound */ \"./src/sound.js\");\n/* harmony import */ var _model__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./model */ \"./src/model.js\");\n/* harmony import */ var _assets_styles_scss__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./assets/styles.scss */ \"./src/assets/styles.scss\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n //-- aircox\n\n\n\n\n\n\nwindow.aircox = {\n // main application\n builder: new _appBuilder__WEBPACK_IMPORTED_MODULE_3__[\"default\"](_app__WEBPACK_IMPORTED_MODULE_2__[\"default\"]),\n\n get app() {\n return this.builder.app;\n },\n\n // player application\n playerBuilder: new _appBuilder__WEBPACK_IMPORTED_MODULE_3__[\"default\"](_app__WEBPACK_IMPORTED_MODULE_2__.PlayerApp),\n\n get playerApp() {\n return this.playerBuilder && this.playerBuilder.app;\n },\n\n get player() {\n return this.playerBuilder.vm && this.playerBuilder.vm.$refs.player;\n },\n\n Set: _model__WEBPACK_IMPORTED_MODULE_5__.Set,\n Sound: _sound__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n\n /**\n * Initialize main application and player.\n */\n init(props = null, {\n config = null,\n builder = null,\n initBuilder = true,\n initPlayer = true,\n hotReload = false\n } = {}) {\n if (initBuilder) {\n builder = builder || this.builder;\n this.builder = builder;\n if (config || window.App) builder.config = config || window.App;\n builder.title = document.title;\n builder.mount({\n props\n });\n if (hotReload) builder.enableHotReload(hotReload);\n }\n\n if (initPlayer) {\n let playerBuilder = this.playerBuilder;\n playerBuilder.mount();\n }\n }\n\n};\n\n//# sourceURL=webpack://aircox-assets/./src/index.js?");
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_0__ = __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_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _appBuilder__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./appBuilder */ \"./src/appBuilder.js\");\n/* harmony import */ var _sound__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./sound */ \"./src/sound.js\");\n/* harmony import */ var _model__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./model */ \"./src/model.js\");\n/* harmony import */ var _assets_styles_scss__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./assets/styles.scss */ \"./src/assets/styles.scss\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n //-- aircox\n\n\n\n\n\n\nwindow.aircox = {\n // main application\n builder: new _appBuilder__WEBPACK_IMPORTED_MODULE_3__[\"default\"](_app__WEBPACK_IMPORTED_MODULE_2__[\"default\"]),\n\n get app() {\n return this.builder.app;\n },\n\n // player application\n playerBuilder: new _appBuilder__WEBPACK_IMPORTED_MODULE_3__[\"default\"](_app__WEBPACK_IMPORTED_MODULE_2__.PlayerApp),\n\n get playerApp() {\n return this.playerBuilder && this.playerBuilder.app;\n },\n\n get player() {\n return this.playerBuilder.vm && this.playerBuilder.vm.$refs.player;\n },\n\n Set: _model__WEBPACK_IMPORTED_MODULE_5__.Set,\n Sound: _sound__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n\n /**\n * Initialize main application and player.\n */\n init(props = null, {\n config = null,\n builder = null,\n initBuilder = true,\n initPlayer = true,\n hotReload = false\n } = {}) {\n if (initPlayer) {\n let playerBuilder = this.playerBuilder;\n playerBuilder.mount();\n }\n\n if (initBuilder) {\n builder = builder || this.builder;\n this.builder = builder;\n if (config || window.App) builder.config = config || window.App;\n builder.title = document.title;\n builder.mount({\n props\n });\n if (hotReload) builder.enableHotReload(hotReload);\n }\n }\n\n};\n\n//# sourceURL=webpack://aircox-assets/./src/index.js?");
/***/ }),
diff --git a/aircox/templates/aircox/widgets/podcast_item.html b/aircox/templates/aircox/widgets/podcast_item.html
index 9f7cc6b..74b8eae 100644
--- a/aircox/templates/aircox/widgets/podcast_item.html
+++ b/aircox/templates/aircox/widgets/podcast_item.html
@@ -8,7 +8,7 @@ List item for a podcast.
{% if object.embed %}
{{ object.embed|safe }}
{% else %}
-