upgrade vue and assets

This commit is contained in:
bkfox
2022-03-18 02:53:54 +01:00
parent 5b788ca28f
commit adb10c3d95
76 changed files with 2453 additions and 11975 deletions

View File

@ -0,0 +1,75 @@
<template>
<div class="control">
<datalist :id="listId">
<option v-for="item in items" :key="item.path"
:value="item[field]"></option>
</datalist>
<input type="text" :name="name" :placeholder="placeholder"
:list="listId" @keyup="onKeyUp"/>
</div>
</template>
<script>
// import debounce from 'lodash/debounce'
export default {
props: {
url: String,
model: Function,
placeholder: String,
name: String,
field: String,
valueField: {type: String, default: 'id'},
count: {type: Number, count: 10},
},
data() {
return {
value: '',
items: [],
selected: null,
isFetching: false,
listId: `autocomplete-${ Math.random() }`.replace('.',''),
}
},
methods: {
select(option, value=null) {
if(!option && value !== null)
option = this.items.find(item => item[this.field] == value)
this.selected = option
this.$emit('select', option)
},
onKeyUp: function(event) {
const value = event.target.value
if(value === this.value)
return
if(value !== undefined && value !== null)
this.value = value
if(!value)
return this.select(null)
this.fetch(value)
},
fetch: function(query) {
if(!query || this.isFetching)
return
this.isFetching = true
return this.model.fetch(this.url.replace('${query}', query), {many:true})
.then(items => { this.items = items || []
this.isFetching = false
this.select(null, query)
return items },
data => {this.isFetching = false; Promise.reject(data)})
},
},
}
</script>

View File

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

View File

@ -0,0 +1,66 @@
<template>
<div>
<slot name="header"></slot>
<ul :class="listClass">
<template v-for="(item,index) in items" :key="index">
<li :class="itemClass" @click="select(index)">
<slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
</li>
</template>
</ul>
<slot name="footer"></slot>
</div>
</template>
<script>
export default {
emits: ['select', 'unselect'],
data() {
return {
selectedIndex: this.defaultIndex,
}
},
props: {
listClass: String,
itemClass: String,
defaultIndex: { type: Number, default: -1},
set: Object,
},
computed: {
model() { return this.set.model },
items() { return this.set.items },
length() { return this.set.length },
selected() {
return this.selectedIndex > -1 && this.items.length > this.selectedIndex > -1
? this.items[this.selectedIndex] : null;
},
},
methods: {
get(index) { return this.set.get(index) },
find(pred) { return this.set.find(pred) },
findIndex(pred) { return this.set.findIndex(pred) },
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', { item: this.selected, index: this.selectedIndex });
return this.selectedIndex;
},
unselect() {
this.$emit('unselect', { item: this.selected, index: this.selectedIndex});
this.selectedIndex = -1;
},
},
}
</script>

View File

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

View File

@ -0,0 +1,286 @@
<template>
<div class="player">
<div :class="['player-panels', panel ? 'is-open' : '']">
<APlaylist ref="pin" class="player-panel menu" v-show="panel == 'pin'"
name="Pinned"
:actions="['page']"
:editable="true" :player="self" :set="sets.pin" @select="togglePlay('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>
</APlaylist>
<APlaylist ref="queue" class="player-panel menu" v-show="panel == 'queue'"
:actions="['page']"
:editable="true" :player="self" :set="sets.queue" @select="togglePlay('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>
</APlaylist>
</div>
<div class="player-bar media">
<div class="media-left">
<button class="button" @click="togglePlay()"
:title="buttonTitle" :aria-label="buttonTitle">
<span class="fas fa-pause" v-if="playing"></span>
<span class="fas fa-play" v-else></span>
</button>
</div>
<div class="media-left media-cover" v-if="current && current.data.cover">
<img :src="current.data.cover" class="cover" />
</div>
<div class="media-content">
<slot name="content" :loaded='loaded' :live='live'></slot>
<AProgress v-if="loaded && duration" :value="currentTime" :max="this.duration"
:format="displayTime"
@select="audio.currentTime = $event"></AProgress>
</div>
<div class="media-right">
<button class="button has-text-weight-bold" v-if="loaded" @click="play()">
<span class="icon is-size-6 has-text-danger">
<span class="fa fa-circle"></span>
</span>
<span>Live</span>
</button>
<button ref="pinPlaylistButton" :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>
</template>
<script>
import Live from '../live';
import Sound from '../sound';
import {Set} from '../model';
import APlaylist from './APlaylist';
import AProgress from './AProgress';
export const State = {
paused: 0,
playing: 1,
loading: 2,
}
export default {
components: { APlaylist, AProgress },
data() {
let audio = new Audio();
audio.addEventListener('ended', e => this.onState(e));
audio.addEventListener('pause', e => this.onState(e));
audio.addEventListener('playing', e => this.onState(e));
audio.addEventListener('timeupdate', () => {
this.currentTime = this.audio.currentTime;
});
audio.addEventListener('durationchange', () => {
this.duration = Number.isFinite(this.audio.duration) ? this.audio.duration : null;
});
let live = this.liveArgs ? new Live(this.liveArgs) : null;
live && live.refresh();
return {
audio, duration: 0, currentTime: 0, state: State.paused,
live,
/// Loaded item
loaded: null,
//! Active panel name
panel: null,
//! current playing playlist name
playlistName: 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 }),
}
}
},
props: {
buttonTitle: String,
liveArgs: Object,
},
computed: {
self() { return this; },
paused() { return this.state == State.paused; },
playing() { return this.state == State.playing; },
loading() { return this.state == State.loading; },
playlist() {
return this.playlistName ? this.$refs[this.playlistName] : null;
},
current() {
return this.loaded ? this.loaded : this.live && this.live.current;
},
},
methods: {
displayTime(seconds) {
seconds = parseInt(seconds);
let s = seconds % 60;
seconds = (seconds - s) / 60;
let m = seconds % 60;
let h = (seconds - m) / 60;
let [ss,mm,hh] = [s.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
h.toString().padStart(2, '0')];
return h ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`;
},
playlistButtonClass(name) {
let set = this.sets[name];
return (set ? (set.length ? "" : "has-text-grey-light ")
+ (this.panel == name ? "is-info "
: this.playlistName == name ? 'is-primary '
: '') : '')
+ "button has-text-weight-bold";
},
/// Show/hide panel
togglePanel(panel) { this.panel = this.panel == panel ? null : panel },
/// Return True if item is loaded
isLoaded(item) { return this.loaded && this.loaded.id == item.id },
/// Return True if item is loaded
isPlaying(item) { return this.isLoaded(item) && !this.paused },
_setPlaylist(playlist) {
this.playlistName = playlist;
for(var p in this.sets)
if(p != playlist)
this.$refs[p].unselect();
},
/// Load a sound from playlist or live
load(playlist=null, index=0) {
let src = null;
// from playlist
if(playlist !== null) {
let item = this.$refs[playlist].get(index);
if(!item)
throw `No sound at index ${index} for playlist ${playlist}`;
this.loaded = item;
src = item.src;
}
// from live
else {
this.loaded = null;
src = this.live.src;
}
this._setPlaylist(playlist);
// load sources
const audio = this.audio;
if(src instanceof Array) {
audio.innerHTML = '';
audio.removeAttribute('src');
for(var s of src) {
let source = document.createElement('source');
source.setAttribute('src', s);
audio.appendChild(source)
}
}
else {
audio.src = src;
}
audio.load();
},
play(playlist=null, index=0) {
this.load(playlist, index);
this.audio.play().catch(e => console.error(e))
},
/// Push items to playlist (by name)
push(playlist, ...items) {
return this.$refs[playlist].push(...items);
},
/// Push and play items
playItems(playlist, ...items) {
let index = this.push(playlist, ...items);
this.$refs[playlist].selectedIndex = index;
this.play(playlist, index);
},
/// Handle click event that plays multiple items (from `data-sounds` attribute)
playButtonClick(event) {
var items = JSON.parse(event.currentTarget.dataset.sounds);
this.playItems('queue', ...items);
},
/// Pause
pause() {
this.audio.pause()
},
//! Play/pause
togglePlay(playlist=null, index=0) {
if(playlist !== null) {
let item = this.sets[playlist].get(index);
if(!this.playlist || this.playlistName !== playlist || this.loaded != item) {
this.play(playlist, index);
return;
}
}
if(this.paused)
this.audio.play().catch(e => console.error(e))
else
this.audio.pause();
},
//! 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);
this.$refs.pinPlaylistButton.focus();
}
},
/// Audio player state change event
onState(event) {
const audio = this.audio;
this.state = audio.paused ? State.paused : State.playing;
if(event.type == 'ended' && (!this.playlist || this.playlist.selectNext() == -1))
this.play();
},
},
mounted() {
this.load();
},
}
</script>

View File

@ -0,0 +1,60 @@
<template>
<div>
<slot name="header"></slot>
<ul :class="listClass">
<li v-for="(item,index) in items" :class="itemClass" @click="!hasAction('play') && select(index)"
:key="index">
<a :class="index == selectedIndex ? 'is-active' : ''">
<ASoundItem
:data="item" :index="index" :player="player" :set="set"
@togglePlay="togglePlay(index)"
:actions="actions">
<template v-slot:actions="{}">
<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>
</ASoundItem>
</a>
</li>
</ul>
<slot name="footer"></slot>
</div>
</template>
<script>
import AList from './AList';
import ASoundItem from './ASoundItem';
export default {
extends: AList,
emits: [...AList.emits, 'remove'],
components: { ASoundItem },
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)
},
},
}
</script>

View File

@ -0,0 +1,67 @@
<template>
<div class="media">
<div class="media-left">
<slot name="value" :value="valueDisplay" :max="max">{{ format(valueDisplay) }}</slot>
</div>
<div ref="bar" class="media-content" @click.stop="onClick" @mouseleave.stop="onMouseMove"
@mousemove.stop="onMouseMove">
<div :class="progressClass" :style="progressStyle">&nbsp;</div>
</div>
<div class="media-right">
<slot name="value" :value="valueDisplay" :max="max">{{ format(max) }}</slot>
</div>
</div>
</template>
<script>
export default {
data() {
return {
hoverValue: null,
}
},
props: {
value: Number,
max: Number,
format: { type: Function, default: x => x },
progressClass: { default: 'has-background-primary' },
vertical: { type: Boolean, default: false },
},
computed: {
valueDisplay() { return this.hoverValue === null ? this.value : this.hoverValue; },
progressStyle() {
if(!this.max)
return null;
let value = this.max ? this.valueDisplay * 100 / this.max : 0;
return this.vertical ? { height: `${value}%` } : { width: `${value}%` };
},
},
methods: {
xToValue(x) { return x * this.max / this.$refs.bar.getBoundingClientRect().width },
yToValue(y) { return y * this.max / this.$refs.bar.getBoundingClientRect().height },
valueFromEvent(event) {
let rect = event.currentTarget.getBoundingClientRect()
return this.vertical ? this.yToValue(event.clientY - rect.y)
: this.xToValue(event.clientX - rect.x);
},
onClick(event) {
this.$emit('select', this.valueFromEvent(event));
},
onMouseMove(event) {
if(event.type == 'mouseleave')
this.hoverValue = null;
else {
this.hoverValue = this.valueFromEvent(event);
}
},
},
}
</script>

View File

@ -0,0 +1,61 @@
<template>
<div class="media sound-item">
<div class="media-left">
<img class="cover is-tiny" :src="item.data.cover" v-if="item.data.cover">
</div>
<div class="media-left">
<button class="button" @click.stop="$emit('togglePlay')">
<div class="icon">
<span class="fa fa-pause" v-if="playing"></span>
<span class="fa fa-play" v-else></span>
</div>
</button>
</div>
<div class="media-content">
<slot name="content" :player="player" :item="item" :loaded="loaded">
<h4 class="title is-4">{{ name || item.name }}</h4>
<a class="subtitle is-6 is-inline-block" v-if="hasAction('page') && item.data.page_url"
:href="item.data.page_url">
{{ item.data.page_title }}
</a>
</slot>
</div>
<div class="media-right">
<button class="button" v-if="player && player.sets.pin != $parent.set" @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';
import Sound from '../sound';
export default {
props: {
data: {type: Object, default: () => {}},
name: String,
player: Object,
page_url: String,
actions: {type:Array, default: () => []},
index: {type:Number, default: null},
},
computed: {
item() { return this.data instanceof Model ? this.data : new Sound(this.data || {}); },
loaded() { return this.player && this.player.isLoaded(this.item) },
playing() { return this.player && this.player.isPlaying(this.item) },
paused() { return this.player && this.player.paused && this.loaded },
pinned() { return this.player && this.player.sets.pin.find(this.item) },
},
methods: {
hasAction(action) {
return this.actions && this.actions.indexOf(action) != -1;
},
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<form ref="form">
<slot :counts="counts"></slot>
</form>
</template>
<script>
const splitReg = new RegExp(',\\s*', 'g');
export default {
data() {
return {
counts: {},
}
},
methods: {
update() {
const items = this.$el.querySelectorAll('input[name="data"]:checked')
const counts = {};
for(var item of items)
if(item.value)
for(var tag of item.value.split(splitReg))
counts[tag.trim()] = (counts[tag.trim()] || 0) + 1;
this.counts = counts;
},
onclick() {
// TODO: row click => check checkbox
}
},
mounted() {
console.log(this.counts)
this.$refs.form.addEventListener('change', () => this.update())
this.update()
}
}
</script>

View File

@ -0,0 +1,57 @@
<template>
<div>
<slot :streamer="streamer" :streamers="streamers" :Sound="Sound"
:sources="sources" :fetchStreamers="fetchStreamers"></slot>
</div>
</template>
<script>
import Sound from '../sound';
import {setEcoInterval} from '../utils';
import Streamer from '../streamer';
export default {
props: {
apiUrl: String,
},
data() {
return {
// current streamer
streamer: null,
// all streamers
streamers: [],
// fetch interval id
fetchInterval: null,
Sound: Sound,
}
},
computed: {
sources() {
var sources = this.streamer ? this.streamer.sources : [];
return sources.filter(s => s.data)
},
},
methods: {
fetchStreamers() {
Streamer.fetch(this.apiUrl, {many:true}).then(streamers => {
this.streamers = streamers
this.streamer = streamers ? streamers[0] : null
})
},
},
mounted() {
this.fetchStreamers();
this.fetchInterval = setEcoInterval(() => this.streamer && this.streamer.fetch(), 5000)
},
unmounted() {
if(this.fetchInterval !== null)
clearInterval(this.fetchInterval)
}
}
</script>

View File

@ -0,0 +1,23 @@
import AAutocomplete from './AAutocomplete.vue'
import AEpisode from './AEpisode.vue'
import AList from './AList.vue'
import APage from './APage.vue'
import APlayer from './APlayer.vue'
import APlaylist from './APlaylist.vue'
import AProgress from './AProgress.vue'
import ASoundItem from './ASoundItem.vue'
import AStatistics from './AStatistics.vue'
import AStreamer from './AStreamer.vue'
/**
* Core components
*/
export default {
AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem,
}
export const admin = {
AStatistics, AStreamer,
}