work on player

This commit is contained in:
bkfox 2020-10-27 20:52:46 +01:00
parent 2a0d0b1758
commit 063d1f194e
14 changed files with 477 additions and 200 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,16 @@
{% extends "aircox/page_detail.html" %}
{% comment %}List of a show's episodes for a specific{% endcomment %}
{% load i18n %}
{% load i18n aircox %}
{% block head_extra %}
<script id="page">
window.addEventListener('load', ev => {
Vue.set(aircox.app, 'page', {
podcasts: new aircox.Set(aircox.Sound, {items: {{ podcasts|json|safe }}})
});
});
</script>
{% endblock head_extra %}
{% include "aircox/program_sidebar.html" %}
@ -38,10 +48,19 @@
{% if podcasts %}
<section class="column">
<h5 class="title is-5">{% trans "Podcasts" %}</h5>
<a-playlist v-if="page" :set="page.podcasts"
name="{{ page.title }}"
:player="player" :actions="['play']"
@select="player.playItems('queue', $event.item)">
<template v-slot:header>
<h5 class="title is-5">{% trans "Podcasts" %}</h5>
</template>
</a-playlist>
{% comment %}
{% for object in podcasts %}
{% include "aircox/widgets/podcast_item.html" %}
{% endfor %}
{% endcomment %}
</section>
{% endif %}
</div>

View File

@ -0,0 +1,8 @@
{% block sidebar_title %}
{% with program.title as program %}
{% blocktrans %}Recently on {{ program }}{% endblocktrans %}
{% endwith %}
{% endblock %}

View File

@ -12,7 +12,7 @@ List item for a podcast.
{% endif %}
{% endcomment %}
<a-sound-item :data="{{ object|json }}" :player="player"
:actions="['play','queue']" @click="player.play(item)">
:actions="['play']" @click="player.play(item)">
</a-sound-item>
</div>

View File

@ -2,6 +2,7 @@ import random
import json
from django import template
from django.utils.safestring import mark_safe
from aircox.models import Page, Diffusion, Log

View File

@ -5,14 +5,18 @@ export const defaultConfig = {
el: '#app',
delimiters: ['[[', ']]'],
computed: {
player() {
return window.aircox.player;
data() {
return {
page: null,
}
}
},
computed: {
player() { return window.aircox.player; }
},
}
export function App(config) {
export default function App(config) {
return (new AppConfig(config)).load()
}

View File

@ -10,16 +10,20 @@ import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
//-- aircox
import {App} from './app';
import App from './app';
import Sound from './sound';
import {Set} from './model';
import './styles.scss';
import Autocomplete from './autocomplete.vue';
import Player from './player.vue';
import Playlist from './playlist.vue';
import SoundItem from './soundItem';
Vue.component('a-autocomplete', Autocomplete)
Vue.component('a-player', Player)
Vue.component('a-playlist', Playlist)
Vue.component('a-sound-item', SoundItem)
@ -36,8 +40,11 @@ window.aircox = {
// player component
get player() {
return this.playerApp && this.playerApp.$refs.player
}
},
Set: Set, Sound: Sound,
};
window.Vue = Vue;
App({el: '#player'}).then(app => window.aircox.playerApp = app,

View File

@ -2,13 +2,11 @@
<div>
<slot name="header"></slot>
<ul :class="listClass">
<slot name="start"></slot>
<template v-for="(item,index) in items">
<li :class="itemClass" @click="select(index)">
<slot name="item" :set="set" :index="index" :item="item"></slot>
<slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
</li>
</template>
<slot name="end"></slot>
</ul>
<slot name="footer"></slot>
</div>
@ -17,14 +15,14 @@
export default {
data() {
return {
selectedIndex: this.default,
selectedIndex: this.defaultIndex,
}
},
props: {
listClass: String,
itemClass: String,
default: { type: Number, default: -1},
defaultIndex: { type: Number, default: -1},
set: Object,
},
@ -34,31 +32,37 @@ export default {
length() { return this.set.length },
selected() {
return this.items && this.items.length > this.selectedIndex > -1
return this.selectedIndex > -1 && this.items.length > this.selectedIndex > -1
? this.items[this.selectedIndex] : null;
},
},
methods: {
select(index=null) {
if(index === null)
index = this.selectedIndex;
else if(this.selectedIndex == index)
return;
get(index) { return this.set.get(index) },
find(item) { return this.set.find(item) },
findIndex(item) { return this.set.findIndex(item) },
this.selectedIndex = Math.min(index, this.items.length-1);
this.$emit('select', { item: this.selected, index: this.selectedIndex });
push(...items) {
let index = this.set.length;
for(var item of items)
this.set.push(item);
},
remove(index, select=False) {
this.set.remove(index);
if(index < this.selectedIndex)
this.selectedIndex--;
if(select && this.selectedIndex == index)
this.select(index)
},
select(index) {
this.selectedIndex = index > -1 && this.items.length ? index % this.items.length : -1;
this.$emit('select', { target: this, item: this.selected, index: this.selectedIndex });
return this.selectedIndex;
},
selectNext() {
let index = this.selectedIndex + 1;
return this.select(index >= this.items.length ? -1 : index);
},
// add()
// insert() + drag & drop
// remove()
},
}
</script>

View File

@ -107,16 +107,19 @@ export default class Model {
*/
export class Set {
constructor(model, {items=[],url=null,args={},unique=null,max=null,storeKey=null}={}) {
this.items = items.map(x => x instanceof model ? x : new model(x, args));
this.items = [];
this.model = model;
this.url = url;
this.unique = unique;
this.max = max;
this.storeKey = storeKey;
for(var item of items)
this.push(item, {args: args, save: false});
}
get length() { return this.items.length }
indexOf(...args) { return this.items.indexOf(...args); }
get(index) { return this.items[index] }
/**
* Fetch multiple items from server
@ -141,35 +144,54 @@ export class Set {
* Store list into localStorage
*/
store() {
if(this.storeKey)
window.localStorage.setItem(this.storeKey, JSON.stringify(
this.items.map(i => i.data)));
this.storeKey && window.localStorage.setItem(this.storeKey, JSON.stringify(
this.items.map(i => i.data)));
}
push(item, {args={}}={}) {
/**
* Save item
*/
save() {
this.storeKey && this.store();
}
/**
* Find item by id
*/
find(item) {
return this.items.find(x => x.id == item.id);
}
/**
* Find item index by id
*/
findIndex(item) {
return this.items.findIndex(x => x.id == item.id);
}
/**
* Add item to set
*/
push(item, {args={},save=true}={}) {
item = item instanceof this.model ? item : new this.model(item, args);
if(this.unique && this.items.find(x => x.id == item.id))
return;
if(this.unique) {
let index = this.findIndex(item);
if(index > -1)
this.items.splice(index,1);
}
if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max)
this.items.push(item);
this._updated()
save && this.save();
}
remove(item, {args={}}={}) {
item = item instanceof this.model ? item : new this.model(item, args);
let index = this.items.findIndex(x => x.id == item.id);
if(index == -1)
return;
/**
* Remove item from set by index
*/
remove(index, {save=true}={}) {
this.items.splice(index,1);
this._updated()
}
_updated() {
Vue.set(this, 'items', this.items);
this.store();
save && this.save();
}
}

View File

@ -1,39 +1,38 @@
<template>
<div class="player">
<List ref="queue" class="player-panel menu" v-show="panel == 'queue' && queue.length"
:set="queue" @select="onSelect"
<Playlist ref="history" class="panel-menu menu" v-show="panel == 'history'"
name="History"
:editable="true" :player="self" :set="sets.history" @select="play('pin', $event.index)"
listClass="menu-list" itemClass="menu-item">
<template v-slot:header>
<p class="menu-label">
<span class="icon"><span class="fa fa-list"></span></span>
Playlist
</p>
</template>
<template v-slot:item="{item,index,set}">
<SoundItem activeClass="is-active" :data="item" :player="self" :set="set"
:actions="['remove']">
<template v-slot:actions="{active,set}">
<!-- TODO: stop player if active -->
</template>
</SoundItem>
</template>
</List>
<List ref="history" class="player-panel menu" v-show="panel == 'history' && history.length"
:set="history" @select="onSelect"
listClass="menu-list" itemClass="menu-item">
<template v-slot:header>
<template v-slot:header="">
<p class="menu-label">
<span class="icon"><span class="fa fa-clock"></span></span>
History
</p>
</template>
<template v-slot:item="{item,index,set}">
<SoundItem activeClass="is-active" :data="item" :player="self" :set="set"
:actions="['queue','remove']">
</SoundItem>
</Playlist>
<Playlist ref="pin" class="player-panel menu" v-show="panel == 'pin'"
name="Pinned"
:editable="true" :player="self" :set="sets.pin" @select="play('pin', $event.index)"
listClass="menu-list" itemClass="menu-item">
<template v-slot:header="">
<p class="menu-label">
<span class="icon"><span class="fa fa-thumbtack"></span></span>
Pinned
</p>
</template>
</List>
</Playlist>
<Playlist ref="queue" class="player-panel menu" v-show="panel == 'queue'"
:editable="true" :player="self" :set="sets.queue" @select="play('queue', $event.index)"
listClass="menu-list" itemClass="menu-item">
<template v-slot:header="">
<p class="menu-label">
<span class="icon"><span class="fa fa-list"></span></span>
Playlist
</p>
</template>
</Playlist>
<div class="player-bar media">
<div class="media-left">
<div class="button" @click="togglePlay()"
@ -53,26 +52,31 @@
<slot name="content" :current="current"></slot>
</div>
<div class="media-right">
<button class="button" v-if="active" @click="load() && play()">
<button class="button has-text-weight-bold" v-if="loaded" @click="playLive()">
<span class="icon has-text-danger">
<span class="fa fa-broadcast-tower"></span>
</span>
<span>Live</span>
</button>
<button class="button" v-if="history.length"
@click="togglePanel('history')"
:class="[panel == 'history' ? 'is-info' : '']" >
<span class="icon">
<span class="fa fa-clock"></span>
</span>
<button :class="playlistButtonClass('history')"
@click="togglePanel('history')">
<span class="mr-2 is-size-6" v-if="sets.history.length">
{{ sets.history.length }}</span>
<span class="icon"><span class="fa fa-clock"></span></span>
</button>
<button class="button" v-if="queue.length"
@click="togglePanel('queue')"
:class="[panel == 'queue' ? 'is-info' : '']" >
<span class="mr-2 is-size-6">{{ queue.length }}</span>
<button :class="playlistButtonClass('pin')"
@click="togglePanel('pin')">
<span class="mr-2 is-size-6" v-if="sets.pin.length">
{{ sets.pin.length }}</span>
<span class="icon"><span class="fa fa-thumbtack"></span></span>
</button>
<button :class="playlistButtonClass('queue')"
@click="togglePanel('queue')">
<span class="mr-2 is-size-6" v-if="sets.queue.length">
{{ sets.queue.length }}</span>
<span class="icon"><span class="fa fa-list"></span></span>
</button>
</div>
</div>
<div v-if="progress">
@ -84,8 +88,7 @@
<script>
import Vue, { ref } from 'vue';
import Live from './live';
import List from './list';
import SoundItem from './soundItem';
import Playlist from './playlist';
import Sound from './sound';
import {Set} from './model';
@ -100,11 +103,20 @@ export default {
data() {
return {
state: State.paused,
active: null,
/// Loaded item
loaded: null,
/// Live instance
live: this.liveArgs ? new Live(this.liveArgs) : null,
//! Active panel name
panel: null,
queue: Set.storeLoad(Sound, "player.queue", { max: 30, unique: true }),
history: Set.storeLoad(Sound, "player.history", { max: 30, unique: true }),
//! current playing playlist component
playlist: null,
//! players' playlists' sets
sets: {
queue: Set.storeLoad(Sound, "playlist.queue", { max: 30, unique: true }),
pin: Set.storeLoad(Sound, "player.pin", { max: 30, unique: true }),
history: Set.storeLoad(Sound, "player.history", { max: 30, unique: true }),
}
}
},
@ -120,7 +132,7 @@ export default {
self() { return this; },
current() {
return this.active || this.live && this.live.current;
return this.loaded || this.live && this.live.current;
},
progress() {
@ -137,15 +149,35 @@ export default {
},
methods: {
playlistButtonClass(name) {
let set = this.sets[name];
return (set ? (set.length ? "" : "has-text-grey-light ")
+ (this.panel == name ? "is-info "
: this.playlist && this.playlist == this.$refs[name] ? 'is-primary '
: '') : '')
+ "button has-text-weight-bold";
},
/// Show/hide panel
togglePanel(panel) {
this.panel = this.panel == panel ? null : panel;
},
isActive(item) {
return item && this.active && this.active.src == item.src;
/// Return True if item is loaded
isLoaded(item) {
return this.loaded && this.loaded.src == item.src;
},
_load(src) {
/// Return True if item is loaded
isPlaying(item) {
return this.isLoaded(item) && !this.player.paused;
},
load(playlist, {src=null, item=null}={}) {
src = src || item.src;
this.loaded = item;
this.playlist = playlist ? this.$refs[playlist] : null;
const audio = this.$refs.audio;
if(src instanceof Array) {
audio.innerHTML = '';
@ -161,22 +193,43 @@ export default {
audio.load();
},
load(item) {
/// Play a playlist's sound (by playlist name, and sound index)
play(playlist=null, index=0) {
if(!playlist)
playlist = 'queue';
let item = this.$refs[playlist].get(index);
if(item) {
this._load(item.src)
this.history.push(item);
this.load(playlist, {item: item});
this.sets.history.push(item);
this.$refs.audio.play().catch(e => console.error(e))
}
else
this._load(this.live.src);
this.$set(this, 'active', item);
throw `No sound at index ${index} for playlist ${playlist}`;
},
play(item) {
if(item)
this.load(item);
/// Push items to playlist (by name)
push(playlist, ...items) {
this.$refs[playlist].push(...items);
},
/// Push and play items
playItems(playlist, ...items) {
this.push(playlist, ...items);
let index = this.$refs[playlist].findIndex(items[0]);
this.$refs[playlist].selectedIndex = index;
this.play(playlist, index);
},
/// Play live stream
playLive() {
this.load(null, {src: this.live.src});
this.$refs.audio.play().catch(e => console.error(e))
this.panel = '';
},
/// Pause
pause() {
this.$refs.audio.pause()
},
@ -184,33 +237,30 @@ export default {
//! Play/pause
togglePlay() {
if(this.paused)
this.play()
this.$refs.audio.play().catch(e => console.error(e))
else
this.pause()
},
//! Push item to queue
push(item) {
this.queue.push(item);
this.panel = 'queue';
//! Pin/Unpin an item
togglePin(item) {
let index = this.sets.pin.findIndex(item);
if(index > -1)
this.sets.pin.remove(index);
else {
this.sets.pin.push(item);
if(!this.panel)
this.panel = 'pin';
}
},
/// Audio player state change event
onState(event) {
const audio = this.$refs.audio;
this.state = audio.paused ? State.paused : State.playing;
if(event.type == 'ended' && this.active) {
this.queue.remove(this.active);
if(this.queue.length)
this.$refs.queue.select(0);
else
this.load();
}
},
onSelect({item,index}) {
if(!this.isActive(item))
this.play(item);
if(event.type == 'ended' && (!this.playlist || this.playlist.selectNext() == -1))
this.playLive();
},
},
@ -218,9 +268,7 @@ export default {
this.sources = this.$slots.sources;
},
components: {
List, SoundItem,
},
components: { Playlist },
}
</script>

View File

@ -0,0 +1,58 @@
<template>
<div>
<slot name="header"></slot>
<ul :class="listClass">
<li v-for="(item,index) in items" :class="itemClass" @click="!hasAction('play') && select(index)">
<a :class="index == selectedIndex ? 'is-active' : ''">
<SoundItem
:data="item" :index="index" :player="player" :set="set"
@togglePlay="togglePlay(index)"
:actions="actions">
<template v-slot:actions="{loaded,set}">
<button class="button" v-if="editable" @click.stop="remove(index,true)">
<span class="icon is-small"><span class="fa fa-minus"></span></span>
</button>
</template>
</SoundItem>
</a>
</li>
</ul>
<slot name="footer"></slot>
</div>
</template>
<script>
import List from './list';
import SoundItem from './soundItem';
export default {
extends: List,
props: {
actions: Array,
name: String,
player: Object,
editable: Boolean,
},
computed: {
self() { return this; }
},
methods: {
hasAction(action) { return this.actions && this.actions.indexOf(action) != -1; },
selectNext() {
let index = this.selectedIndex + 1;
return this.select(index >= this.items.length ? -1 : index);
},
togglePlay(index) {
if(this.player.isPlaying(this.set.get(index)))
this.player.pause();
else
this.select(index)
},
},
components: { List, SoundItem },
}
</script>

View File

@ -1,32 +1,29 @@
<template>
<a :class="[active ? activeClass : '']" @click="actions.indexOf('play') == -1 && play()">
<div class="media">
<div class="media-left" v-if="hasAction('play')">
<button class="button" @click="play()">
<div class="icon">
<span class="fa fa-pause" v-if="playing || loading"></span>
<span class="fa fa-play" v-else></span>
</div>
</button>
</div>
<div class="media-content">
<slot name="content" :player="player" :item="item" :active="active">
<h4 class="title is-4 is-inline-block">
{{ name || item.name }}
</h4>
</slot>
</div>
<div class="media-right">
<button class="button" v-if="hasAction('queue')" @click.stop="player.push(item)">
<span class="icon is-small"><span class="fa fa-list"></span></span>
</button>
<button class="button" v-if="hasAction('remove')" @click.stop="set.remove(item)">
<span class="icon is-small"><span class="fa fa-minus"></span></span>
</button>
<slot name="actions" :player="player" :item="item" :active="active"></slot>
</div>
<div class="media">
<div class="media-left" v-if="hasAction('play')">
<button class="button" @click="$emit('togglePlay')">
<div class="icon">
<span class="fa fa-pause" v-if="playing || loading"></span>
<span class="fa fa-play" v-else></span>
</div>
</button>
</div>
</a>
<div class="media-content">
<slot name="content" :player="player" :item="item" :loaded="loaded">
<h4 class="title is-4 is-inline-block">
{{ name || item.name }}
</h4>
</slot>
</div>
<div class="media-right">
<button class="button" v-if="player.$refs.pin != $parent" @click.stop="player.togglePin(item)">
<span class="icon is-small">
<span :class="(pinned ? '' : 'has-text-grey-light ') + 'fa fa-thumbtack'"></span>
</span>
</button>
<slot name="actions" :player="player" :item="item" :loaded="loaded"></slot>
</div>
</div>
</template>
<script>
import Model from './model';
@ -37,36 +34,25 @@ export default {
data: {type: Object, default: x => {}},
name: String,
cover: String,
set: Object,
player: Object,
page_url: String,
activeClass: String,
actions: {type:Array, default: x => []},
index: {type:Number, default: null},
},
computed: {
item() { return this.data instanceof Model ? this.data : new Sound(this.data || {}); },
active() { return this.player && this.player.isActive(this.item) },
playing() { return this.player && this.player.playing && this.active },
paused() { return this.player && this.player.paused && this.active },
loading() { return this.player && this.player.loading && this.active },
loaded() { return this.player && this.player.isLoaded(this.item) },
playing() { return this.player && this.player.playing && this.loaded },
paused() { return this.player && this.player.paused && this.loaded },
loading() { return this.player && this.player.loading && this.loaded },
pinned() { return this.player && this.player.sets.pin.find(this.item) },
},
methods: {
hasAction(action) {
return this.actions && this.actions.indexOf(action) != -1;
},
play() {
if(this.player && this.active)
this.player.togglePlay()
else
this.player.play(this.item);
},
push_to(playlist) {
this.player.playlists[playlist].push(this.item, {unique_key:'id'});
},
}
}
</script>