Add On-Demand Streaming/Download Support (#2836)

This branch represents the initial earliest commit of a new option for radio stations that also want to make a portion of their media library available to the public (such as podcast episodes, royalty-free music, etc.) in an on-demand fashion.

Note: because of royalty restrictions with most radio stations, this feature is turned OFF by default on all stations, and even when enabled, station administrators must select each individual playlist that will contain on-demand available media.
This commit is contained in:
Buster "Silver Eagle" Neece 2020-05-15 05:13:47 -05:00 committed by GitHub
parent 109b65efcc
commit bd6d3203b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1539 additions and 778 deletions

View File

@ -502,4 +502,16 @@ return [
],
],
],
'station_on_demand' => [
'order' => 10,
'require' => ['vue', 'vue-translations', 'bootstrap-vue'],
'files' => [
'js' => [
[
'src' => 'dist/station_on_demand.js',
],
],
],
],
];

View File

@ -93,6 +93,18 @@ return [
],
],
'enable_on_demand' => [
'toggle',
[
'label' => __('Enable On-Demand Streaming and Downloads'),
'description' => __('If enabled, music from playlists with on-demand streaming enabled will be available to stream and download via a specialized public page.'),
'selected_text' => __('Yes'),
'deselected_text' => __('No'),
'default' => false,
'form_group_class' => 'col-sm-6',
],
],
'default_album_art_url' => [
'text',
[

View File

@ -44,6 +44,13 @@ return function (\App\Event\BuildStationMenu $e) {
'external' => true,
'visible' => $station->getEnablePublicPage(),
],
'ondemand' => [
'label' => __('On-Demand Media'),
'icon' => 'cloud_download',
'url' => $router->named('public:ondemand', ['station_id' => $station->getShortName()]),
'external' => true,
'visible' => $station->getEnableOnDemand(),
],
'files' => [
'label' => __('Music Files'),
'icon' => 'library_music',

View File

@ -156,6 +156,13 @@ return function (App $app) {
->setName('api:requests:submit')
->add(new Middleware\RateLimit('api', 5, 2));
$group->get('/ondemand', Controller\Api\Stations\OnDemand\ListAction::class)
->setName('api:stations:ondemand:list');
$group->get('/ondemand/download/{media_id}', Controller\Api\Stations\OnDemand\DownloadAction::class)
->setName('api:stations:ondemand:download')
->add(new Middleware\RateLimit('ondemand', 1, 2));
$group->get('/listeners', Controller\Api\Stations\ListenersController::class . ':indexAction')
->setName('api:listeners:index')
->add(new Middleware\Permissions(Acl::STATION_REPORTS, true));

View File

@ -9,23 +9,22 @@ return function (App $app) {
$app->group('/public/{station_id}', function (RouteCollectorProxy $group) {
$group->get('', Controller\Frontend\PublicController::class . ':indexAction')
$group->get('[/{embed:embed}]', Controller\Frontend\PublicPages\PlayerAction::class)
->setName('public:index');
$group->get('/embed', Controller\Frontend\PublicController::class . ':embedAction')
->setName('public:embed');
$group->get('/embed-requests', Controller\Frontend\PublicController::class . ':embedrequestsAction')
$group->get('/embed-requests', Controller\Frontend\PublicPages\RequestsAction::class)
->setName('public:embedrequests');
$group->get('/playlist[/{format}]', Controller\Frontend\PublicController::class . ':playlistAction')
$group->get('/playlist[/{format}]', Controller\Frontend\PublicPages\PlaylistAction::class)
->setName('public:playlist');
$group->get('/dj', Controller\Frontend\PublicController::class . ':djAction')
$group->get('/dj', Controller\Frontend\PublicPages\WebDjAction::class)
->setName('public:dj');
$group->get('/ondemand[/{embed:embed}]', Controller\Frontend\PublicPages\OnDemandAction::class)
->setName('public:ondemand');
})
->add(Middleware\GetStation::class)
->add(Middleware\EnableView::class);
};

View File

@ -269,6 +269,11 @@ var vueProjects = {
'src_file': 'vue/StationStreamers.vue',
'filename': 'station_streamers.js',
'library': 'StationStreamers'
},
'station_on_demand': {
'src_file': 'vue/StationOnDemand.vue',
'filename': 'station_on_demand.js',
'library': 'StationOnDemand'
}
}

View File

@ -1,40 +1,42 @@
<template>
<div class="ml-3 player-inline" v-if="isPlaying">
<audio ref="player"/>
<div>
<audio-player ref="player"></audio-player>
<div class="inline-seek d-inline-flex align-items-center ml-1" v-if="duration !== 0">
<div class="flex-shrink-0 mx-1 text-muted time-display">
{{ currentTimeText }}
<div class="ml-3 player-inline" v-if="is_playing">
<div class="inline-seek d-inline-flex align-items-center ml-1" v-if="duration !== 0">
<div class="flex-shrink-0 mx-1 text-muted time-display">
{{ currentTimeText }}
</div>
<div class="flex-fill mx-2">
<input type="range" :title="langSeek" class="player-seek-range custom-range" min="0" max="100"
step="1" v-model="progress">
</div>
<div class="flex-shrink-0 mx-1 text-muted time-display">
{{ durationText }}
</div>
</div>
<div class="flex-fill mx-2">
<input type="range" :title="langSeek" class="player-seek-range custom-range" min="0" max="100"
step="1" v-model="progress">
</div>
<div class="flex-shrink-0 mx-1 text-muted time-display">
{{ durationText }}
</div>
</div>
<a class="btn btn-sm btn-outline-light px-2 ml-1" href="#" @click.prevent="stop()">
<i class="material-icons" aria-hidden="true">pause</i>
<span class="sr-only" v-translate>Pause</span>
</a>
<div class="inline-volume-controls d-inline-flex align-items-center ml-1">
<div class="flex-shrink-0">
<a class="btn btn-sm btn-outline-light px-2" href="#" @click.prevent="volume = 0">
<i class="material-icons" aria-hidden="true">volume_mute</i>
<span class="sr-only" v-translate>Mute</span>
</a>
</div>
<div class="flex-fill mx-1">
<input type="range" :title="langVolume" class="player-volume-range custom-range" min="0" max="100"
step="1" v-model="volume">
</div>
<div class="flex-shrink-0">
<a class="btn btn-sm btn-outline-light px-2" href="#" @click.prevent="volume = 100">
<i class="material-icons" aria-hidden="true">volume_up</i>
<span class="sr-only" v-translate>Full Volume</span>
</a>
<a class="btn btn-sm btn-outline-light px-2 ml-1" href="#" @click.prevent="stop()">
<i class="material-icons" aria-hidden="true">pause</i>
<span class="sr-only" v-translate>Pause</span>
</a>
<div class="inline-volume-controls d-inline-flex align-items-center ml-1">
<div class="flex-shrink-0">
<a class="btn btn-sm btn-outline-light px-2" href="#" @click.prevent="volume = 0">
<i class="material-icons" aria-hidden="true">volume_mute</i>
<span class="sr-only" v-translate>Mute</span>
</a>
</div>
<div class="flex-fill mx-1">
<input type="range" :title="langVolume" class="player-volume-range custom-range" min="0" max="100"
step="1" v-model="volume">
</div>
<div class="flex-shrink-0">
<a class="btn btn-sm btn-outline-light px-2" href="#" @click.prevent="volume = 100">
<i class="material-icons" aria-hidden="true">volume_up</i>
<span class="sr-only" v-translate>Full Volume</span>
</a>
</div>
</div>
</div>
</div>
@ -63,42 +65,17 @@
</style>
<script>
import store from 'store';
import getLogarithmicVolume from './inc/logarithmic_volume';
import AudioPlayer from './components/AudioPlayer';
export default {
components: { AudioPlayer },
data () {
return {
'isPlaying': false,
'volume': 55,
'audio': null,
'duration': 0,
'currentTime': 0
is_mounted: false
};
},
created () {
// Allow pausing from the mobile metadata update.
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('pause', () => {
this.stop();
});
}
// Check webstorage for existing volume preference.
if (store.enabled && store.get('player_volume') !== undefined) {
this.volume = store.get('player_volume', this.volume);
}
this.$eventHub.$on('player_toggle', (url) => {
if (this.isPlaying && this.audio.src === url) {
this.stop();
} else {
this.stop();
Vue.nextTick(() => {
this.play(url);
});
}
});
mounted () {
this.is_mounted = true;
},
computed: {
langSeek () {
@ -109,87 +86,71 @@
},
durationText () {
let minutes = Math.floor(this.duration / 60),
seconds_int = this.duration - minutes * 60,
seconds_str = seconds_int.toString(),
seconds = seconds_str.substr(0, 2);
seconds_int = this.duration - minutes * 60,
seconds_str = seconds_int.toString(),
seconds = seconds_str.substr(0, 2);
return minutes + ':' + seconds;
},
currentTimeText () {
let current_minute = parseInt(this.currentTime / 60) % 60,
current_seconds_long = this.currentTime % 60,
current_seconds = current_seconds_long.toFixed();
current_seconds_long = this.currentTime % 60,
current_seconds = current_seconds_long.toFixed();
return (current_minute < 10 ? '0' + current_minute : current_minute) + ':' + (current_seconds < 10 ? '0' + current_seconds : current_seconds);
},
progress: {
get () {
return (this.duration !== 0) ? Math.round((this.currentTime / this.duration) * 100, 2) : 0;
},
set (progress) {
if (this.audio !== null) {
this.audio.currentTime = (progress / 100) * this.duration;
}
}
}
},
watch: {
volume (volume) {
if (this.audio !== null) {
this.audio.volume = getLogarithmicVolume(volume);
duration () {
if (!this.is_mounted) {
return;
}
if (store.enabled) {
store.set('player_volume', volume);
return this.$refs.player.getDuration();
},
currentTime () {
if (!this.is_mounted) {
return;
}
return this.$refs.player.getCurrentTime();
},
is_playing () {
if (!this.is_mounted) {
return;
}
return this.$refs.player.isPlaying();
},
volume: {
get () {
if (!this.is_mounted) {
return;
}
return this.$refs.player.getVolume();
},
set (vol) {
this.$refs.player.setVolume(vol);
}
},
progress: {
get () {
if (!this.is_mounted) {
return;
}
return this.$refs.player.getProgress();
},
set (progress) {
this.$refs.player.setProgress(progress);
}
}
},
methods: {
play (url) {
if (this.isPlaying) {
this.stop();
Vue.nextTick(() => {
this.play(url);
});
}
this.isPlaying = true;
Vue.nextTick(() => {
this.audio = this.$refs.player;
this.audio.onended = () => {
this.stop();
};
this.audio.ontimeupdate = () => {
this.duration = (this.audio.duration !== Infinity && !isNaN(this.audio.duration)) ? this.audio.duration : 0;
this.currentTime = this.audio.currentTime;
};
this.audio.volume = getLogarithmicVolume(this.volume);
this.audio.src = url;
this.audio.load();
this.audio.play();
});
this.$eventHub.$emit('player_playing', url);
this.$refs.player.play(url);
},
stop () {
if (!this.isPlaying) {
return;
}
this.$eventHub.$emit('player_stopped', this.audio.src);
this.audio.pause();
this.audio.src = '';
this.duration = 0;
this.currentTime = 0;
this.isPlaying = false;
this.$refs.player.stop();
}
}
};

View File

@ -1,8 +1,6 @@
<template>
<div class="radio-player-widget">
<template v-if="is_playing">
<audio ref="player" v-bind:title="np.now_playing.song.text"/>
</template>
<audio-player ref="player" v-bind:title="np.now_playing.song.text"></audio-player>
<div class="now-playing-details">
<div class="now-playing-art" v-if="show_album_art && np.now_playing.song.art">
@ -216,10 +214,10 @@
<script>
import axios from 'axios';
import NchanSubscriber from 'nchan';
import store from 'store';
import getLogarithmicVolume from './inc/logarithmic_volume';
import AudioPlayer from './components/AudioPlayer';
export default {
components: { AudioPlayer },
props: {
now_playing_uri: {
type: String,
@ -261,43 +259,22 @@
},
data: function () {
return {
'is_mounted': false,
'np': this.initial_now_playing,
'np_elapsed': 0,
'is_playing': false,
'volume': 55,
'current_stream': {
'name': '',
'url': ''
},
'audio': null,
'np_timeout': null,
'nchan_subscriber': null,
'clock_interval': null
};
},
mounted: function () {
this.is_mounted = true;
this.clock_interval = setInterval(this.iterateTimer, 1000);
// Allow pausing from the mobile metadata update.
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('pause', () => {
this.stop();
});
}
// Check webstorage for existing volume preference.
if (store.enabled && store.get('player_volume') !== undefined) {
this.volume = store.get('player_volume', this.volume);
}
// Check the query string if browser supports easy query string access.
if (typeof URLSearchParams !== 'undefined') {
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('volume')) {
this.volume = parseInt(urlParams.get('volume'));
}
}
// Convert initial NP data from prop to data.
this.setNowPlaying(this.np);
@ -368,80 +345,40 @@
time_display_total () {
let time_total = this.np.now_playing.duration;
return (time_total) ? this.formatTime(time_total) : null;
}
},
watch: {
volume (volume) {
if (this.audio !== null) {
this.audio.volume = getLogarithmicVolume(volume);
},
is_playing () {
if (!this.is_mounted) {
return;
}
if (store.enabled) {
store.set('player_volume', volume);
return this.$refs.player.isPlaying();
},
volume: {
get () {
if (!this.is_mounted) {
return;
}
return this.$refs.player.getVolume();
},
set (vol) {
this.$refs.player.setVolume(vol);
}
}
},
methods: {
play () {
if (this.is_playing) {
return;
}
this.is_playing = true;
// Wait for "next tick" to force Vue to recreate the <audio> element.
Vue.nextTick(() => {
this.audio = this.$refs.player;
// Handle audio errors.
this.audio.onerror = (e) => {
if (e.target.error.code === e.target.error.MEDIA_ERR_NETWORK && this.audio.src !== '') {
console.log('Network interrupted stream. Automatically reconnecting shortly...');
setTimeout(this.play, 5000);
}
};
this.audio.onended = () => {
if (this.is_playing) {
this.stop();
console.log('Network interrupted stream. Automatically reconnecting shortly...');
setTimeout(this.play, 5000);
} else {
this.stop();
}
};
this.audio.volume = getLogarithmicVolume(this.volume);
this.audio.src = this.current_stream.url;
this.audio.load();
this.audio.play();
});
this.$refs.player.play(this.current_stream.url);
},
stop () {
this.audio.pause();
this.audio.src = '';
this.is_playing = false;
this.$refs.player.stop();
},
toggle () {
if (this.is_playing) {
this.stop();
} else {
this.play();
}
this.$refs.player.toggle(this.current_stream.url);
},
switchStream (new_stream) {
this.current_stream = new_stream;
// Stop the existing stream, then wait for the next Vue "tick", at which point the <audio> element will
// no longer exist, then recreate it via play() command.
this.stop();
Vue.nextTick(() => {
this.play();
});
this.play();
},
checkNowPlaying () {
if (this.use_nchan) {

View File

@ -0,0 +1,156 @@
<template>
<div class="card">
<div class="card-header bg-primary-dark">
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<template v-if="stationName">
{{ stationName }}
</template>
<template v-else>
<translate>On-Demand Media</translate>
</template>
</h2>
</div>
<div class="flex-fill text-right">
<inline-player ref="player"></inline-player>
</div>
</div>
</div>
<div class="table-responsive table-responsive-lg">
<data-table ref="datatable" class="on-demand-table" paginated select-fields
:fields="fields" :api-url="listUrl">
<template v-slot:cell(download_url)="row">
<a class="file-icon btn-audio" href="#" :data-url="row.item.download_url"
@click.prevent="playAudio(row.item.download_url)" :title="langPlayPause">
<i class="material-icons" aria-hidden="true" v-if="now_playing_url === row.item.download_url">pause_circle_filled</i>
<i class="material-icons" aria-hidden="true" v-else>play_circle_filled</i>
</a>
&nbsp;
<a class="name" :href="row.item.download_url" target="_blank" :title="langDownload">
<i class="material-icons">cloud_download</i>
</a>
</template>
<template v-slot:cell(media_art)="row">
<a :href="row.item.media_art" class="album-art" target="_blank"
data-fancybox="gallery">
<img class="media_manager_album_art" :alt="langAlbumArt" :src="row.item.media_art">
</a>
</template>
<template v-slot:cell(size)="row">
<template v-if="!row.item.size">&nbsp;</template>
<template v-else>
{{ formatFileSize(row.item.size) }}
</template>
</template>
</data-table>
</div>
</div>
</template>
<style lang="scss">
.on-demand-table {
table.b-table {
thead tr th:nth-child(1),
tbody tr td:nth-child(1) {
padding-right: 0.75rem;
width: 3rem;
white-space: nowrap;
}
thead tr th:nth-child(2),
tbody tr td:nth-child(2) {
padding-left: 0.5rem;
padding-right: 0.5rem;
width: 40px;
}
thead tr th:nth-child(3),
tbody tr td:nth-child(3) {
padding-left: 0.5rem;
}
}
img.media_manager_album_art {
width: 40px;
height: auto;
border-radius: 5px;
}
}
</style>
<script>
import InlinePlayer from './InlinePlayer';
import DataTable from './components/DataTable';
import _ from 'lodash';
export default {
components: { DataTable, InlinePlayer },
props: {
listUrl: String,
stationName: String,
customFields: Array
},
data () {
let fields = [
{ key: 'download_url', label: ' ', sortable: false, selectable: false },
{ key: 'media_art', label: this.$gettext('Art'), sortable: false, selectable: false },
{ key: 'media_title', label: this.$gettext('Title'), sortable: true, selectable: true },
{
key: 'media_artist',
label: this.$gettext('Artist'),
sortable: true,
selectable: true
},
{ key: 'media_album', label: this.$gettext('Album'), sortable: true, selectable: true }
];
_.forEach(this.customFields.slice(), (field) => {
fields.push({
key: field.display_key,
label: field.label,
sortable: true,
selectable: true,
visible: false
});
});
return {
now_playing_url: '',
fields: fields
};
},
mounted () {
this.$eventHub.$on('player_stopped', () => {
this.now_playing_url = '';
});
this.$eventHub.$on('player_playing', (url) => {
this.now_playing_url = url;
});
},
computed: {
langAlbumArt () {
return this.$gettext('Album Art');
},
langPlayPause () {
return this.$gettext('Play/Pause');
},
langDownload () {
return this.$gettext('Download');
}
},
methods: {
playAudio (url) {
if (this.now_playing_url === url) {
this.$refs.player.stop();
} else {
this.$refs.player.play(url);
}
}
}
};
</script>

View File

@ -76,6 +76,9 @@
v-if="row.item.source === 'songs' && row.item.order === 'sequential'">
<translate>Sequential</translate>
</span>
<span class="badge badge-info" v-if="row.item.include_in_on_demand">
<translate>On-Demand</translate>
</span>
<span class="badge badge-success" v-if="row.item.include_in_automation">
<translate>Auto-Assigned</translate>
</span>
@ -170,8 +173,8 @@
methods: {
langToggleButton (record) {
return (record.is_enabled)
? this.$gettext('Disable')
: this.$gettext('Enable');
? this.$gettext('Disable')
: this.$gettext('Enable');
},
formatTime (time) {
return moment(time).tz(this.stationTimeZone).format('LT');

View File

@ -0,0 +1,150 @@
<template>
<audio ref="audio" v-if="is_playing" v-bind:title="title"/>
</template>
<script>
import store from 'store';
import getLogarithmicVolume from '../inc/logarithmic_volume';
export default {
props: {
title: String
},
data () {
return {
'is_playing': false,
'audio': null,
'volume': 55,
'duration': 0,
'currentTime': 0
};
},
mounted () {
// Allow pausing from the mobile metadata update.
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('pause', () => {
this.stop();
});
}
// Check webstorage for existing volume preference.
if (store.enabled && store.get('player_volume') !== undefined) {
this.volume = store.get('player_volume', this.volume);
}
// Check the query string if browser supports easy query string access.
if (typeof URLSearchParams !== 'undefined') {
let urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('volume')) {
this.volume = parseInt(urlParams.get('volume'));
}
}
this.$eventHub.$on('player_toggle', (url) => {
this.toggle(url);
});
},
watch: {
volume (volume) {
if (this.audio !== null) {
this.audio.volume = getLogarithmicVolume(volume);
}
if (store.enabled) {
store.set('player_volume', volume);
}
}
},
methods: {
stop () {
if (this.audio !== null) {
this.$eventHub.$emit('player_stopped', this.audio.src);
this.audio.pause();
this.audio.src = '';
}
this.duration = 0;
this.currentTime = 0;
this.is_playing = false;
},
play (url) {
if (this.is_playing) {
this.stop();
Vue.nextTick(() => {
this.play(url);
});
return;
}
this.is_playing = true;
Vue.nextTick(() => {
this.audio = this.$refs.audio;
// Handle audio errors.
this.audio.onerror = (e) => {
if (e.target.error.code === e.target.error.MEDIA_ERR_NETWORK && this.audio.src !== '') {
console.log('Network interrupted stream. Automatically reconnecting shortly...');
setTimeout(() => {
this.play(url);
}, 5000);
}
};
this.audio.onended = () => {
this.stop();
};
this.audio.ontimeupdate = () => {
this.duration = (this.audio.duration !== Infinity && !isNaN(this.audio.duration)) ? this.audio.duration : 0;
this.currentTime = this.audio.currentTime;
};
this.audio.volume = getLogarithmicVolume(this.volume);
this.audio.src = url;
this.audio.load();
this.audio.play();
});
this.$eventHub.$emit('player_playing', url);
},
toggle (url) {
if (this.is_playing && this.audio.src === url) {
this.stop();
} else {
this.stop();
Vue.nextTick(() => {
this.play(url);
});
}
},
isPlaying () {
return this.is_playing;
},
getVolume () {
return this.volume;
},
setVolume (vol) {
this.volume = vol;
},
getCurrentTime () {
return this.currentTime;
},
getDuration () {
return this.duration;
},
getProgress () {
return (this.duration !== 0) ? Math.round((this.currentTime / this.duration) * 100, 2) : 0;
},
setProgress (progress) {
if (this.audio !== null) {
this.audio.currentTime = (progress / 100) * this.duration;
}
}
}
};
</script>

View File

@ -53,8 +53,8 @@
computed: {
langTitle () {
return this.isEditMode
? this.$gettext('Edit Playlist')
: this.$gettext('Add Playlist');
? this.$gettext('Edit Playlist')
: this.$gettext('Add Playlist');
},
isEditMode () {
return this.editUrl !== null;
@ -64,6 +64,7 @@
form: {
'name': { required },
'is_enabled': { required },
'include_in_on_demand': {},
'weight': { required },
'type': { required },
'source': { required },
@ -94,6 +95,7 @@
this.form = {
'name': '',
'is_enabled': true,
'include_in_on_demand': false,
'weight': 3,
'type': 'default',
'source': 'songs',
@ -130,6 +132,7 @@
this.form = {
'name': d.name,
'is_enabled': d.is_enabled,
'include_in_on_demand': d.include_in_on_demand,
'weight': d.weight,
'type': d.type,
'source': d.source,
@ -161,11 +164,11 @@
axios({
method: (this.isEditMode)
? 'PUT'
: 'POST',
? 'PUT'
: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
? this.editUrl
: this.createUrl,
data: this.form
}).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.');

View File

@ -36,6 +36,15 @@
</b-form-checkbox>
</b-form-group>
<b-form-group class="col-md-12" label-for="form_edit_include_in_on_demand">
<template v-slot:description>
<translate>If this station has on-demand streaming and downloading enabled, only songs that are in playlists with this setting enabled will be visible.</translate>
</template>
<b-form-checkbox id="form_edit_include_in_on_demand" v-model="form.include_in_on_demand.$model">
<translate>Include in On-Demand Player</translate>
</b-form-checkbox>
</b-form-group>
<b-form-group class="col-md-12" label-for="edit_form_type">
<template v-slot:label>
<translate>Playlist Type</translate>

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,10 @@ msgstr ""
"Generated-By: easygettext\n"
"Project-Id-Version: \n"
#: ./vue/StationPlaylists.vue:137
#: ./vue/StationPlaylists.vue:143
msgid "# Songs"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:69
msgid "Start/end date cannot be used on playlists with advanced settings!"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSource.vue:13
msgid "A playlist containing media files hosted on this server."
msgstr ""
@ -30,7 +26,7 @@ msgid "Account List"
msgstr ""
#: ./vue/StationMedia.vue:189
#: ./vue/StationPlaylists.vue:134
#: ./vue/StationPlaylists.vue:140
#: ./vue/StationStreamers.vue:83
#: ./vue/station_playlists/PlaylistReorderModal.vue:11
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:71
@ -46,7 +42,7 @@ msgstr ""
msgid "Add Playlist"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:112
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:114
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:106
msgid "Add Schedule Item"
msgstr ""
@ -57,7 +53,7 @@ msgid "Add Streamer"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormAdvanced.vue:50
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:72
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:81
msgid "Advanced"
msgstr ""
@ -70,17 +66,19 @@ msgid "Advanced Playback Settings"
msgstr ""
#: ./vue/StationMedia.vue:153
#: ./vue/StationOnDemand.vue:77
#: ./vue/station_playlists/PlaylistReorderModal.vue:10
msgid "Album"
msgstr ""
#: ./vue/RadioPlayer.vue:323
#: ./vue/RadioPlayer.vue:300
#: ./vue/StationMedia.vue:212
#: ./vue/StationOnDemand.vue:106
#: ./vue/station_media/form/MediaFormAlbumArt.vue:42
msgid "Album Art"
msgstr ""
#: ./vue/StationPlaylists.vue:143
#: ./vue/StationPlaylists.vue:149
msgid "All Playlists"
msgstr ""
@ -92,27 +90,32 @@ msgstr ""
msgid "Amplify: Amplification (dB)"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:172
#: ./vue/station_media/MediaEditModal.vue:174
#: ./vue/station_media/MediaMoveFilesModal.vue:92
#: ./vue/station_media/MediaNewDirectoryModal.vue:76
#: ./vue/station_playlists/PlaylistEditModal.vue:179
#: ./vue/station_playlists/PlaylistEditModal.vue:182
#: ./vue/station_playlists/PlaylistImportModal.vue:68
#: ./vue/station_streamers/StreamerEditModal.vue:152
msgid "An error occurred and your request could not be completed."
msgstr ""
#: ./vue/StationPlaylists.vue:228
#: ./vue/StationPlaylists.vue:234
#: ./vue/station_media/MediaToolbar.vue:194
msgid "Applying changes..."
msgstr ""
#: ./vue/StationOnDemand.vue:69
msgid "Art"
msgstr ""
#: ./vue/StationMedia.vue:148
#: ./vue/StationOnDemand.vue:73
#: ./vue/station_playlists/PlaylistReorderModal.vue:9
#: ./vue/webcaster/settings.vue:115
msgid "Artist"
msgstr ""
#: ./vue/StationPlaylists.vue:79
#: ./vue/StationPlaylists.vue:82
msgid "Auto-Assigned"
msgstr ""
@ -124,7 +127,7 @@ msgstr ""
msgid "Back"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:202
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:211
#: ./vue/station_streamers/form/StreamerFormBasicInfo.vue:90
msgid "Basic Info"
msgstr ""
@ -137,8 +140,8 @@ msgstr ""
msgid "Broadcasts"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:164
#: ./vue/station_playlists/PlaylistEditModal.vue:171
#: ./vue/station_media/MediaEditModal.vue:166
#: ./vue/station_playlists/PlaylistEditModal.vue:174
#: ./vue/station_streamers/StreamerEditModal.vue:144
msgid "Changes saved."
msgstr ""
@ -180,7 +183,7 @@ msgstr ""
msgid "Cue"
msgstr ""
#: ./vue/StationPlaylists.vue:201
#: ./vue/StationPlaylists.vue:207
msgid "Custom"
msgstr ""
@ -208,12 +211,12 @@ msgstr ""
msgid "Custom Fields"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:188
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:197
msgid "Default"
msgstr ""
#: ./vue/StationPlaylists.vue:31
#: ./vue/StationPlaylists.vue:244
#: ./vue/StationPlaylists.vue:250
#: ./vue/StationStreamers.vue:43
#: ./vue/StationStreamers.vue:121
#: ./vue/station_media/MediaToolbar.vue:53
@ -235,7 +238,7 @@ msgstr ""
msgid "Delete broadcast?"
msgstr ""
#: ./vue/StationPlaylists.vue:245
#: ./vue/StationPlaylists.vue:251
msgid "Delete playlist?"
msgstr ""
@ -252,12 +255,12 @@ msgstr ""
msgid "Directory Name"
msgstr ""
#: ./vue/StationPlaylists.vue:170
#: ./vue/StationPlaylists.vue:176
msgid "Disable"
msgstr ""
#: ./vue/StationPlaylists.vue:82
#: ./vue/StationPlaylists.vue:181
#: ./vue/StationPlaylists.vue:85
#: ./vue/StationPlaylists.vue:187
#: ./vue/StationStreamers.vue:30
msgid "Disabled"
msgstr ""
@ -274,6 +277,7 @@ msgstr ""
msgid "Down"
msgstr ""
#: ./vue/StationOnDemand.vue:112
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:8
msgid "Download"
msgstr ""
@ -288,7 +292,7 @@ msgstr ""
msgid "Edit"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:82
#: ./vue/station_media/MediaEditModal.vue:83
msgid "Edit Media"
msgstr ""
@ -300,7 +304,7 @@ msgstr ""
msgid "Edit Streamer"
msgstr ""
#: ./vue/StationPlaylists.vue:171
#: ./vue/StationPlaylists.vue:177
msgid "Enable"
msgstr ""
@ -316,7 +320,7 @@ msgstr ""
msgid "Encoder"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:82
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:84
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:77
msgid "End Date"
msgstr ""
@ -351,19 +355,20 @@ msgstr ""
msgid "Files removed:"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:137
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:139
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:131
msgid "Friday"
msgstr ""
#: ./vue/InlinePlayer.vue:35
#: ./vue/RadioPlayer.vue:320
#: ./vue/InlinePlayer.vue:36
#: ./vue/RadioPlayer.vue:297
#: ./vue/components/Waveform.vue:38
msgid "Full Volume"
msgstr ""
#: ./vue/StationPlaylists.vue:186
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:45
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:87
#: ./vue/StationPlaylists.vue:192
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:54
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:96
msgid "General Rotation"
msgstr ""
@ -371,7 +376,7 @@ msgstr ""
msgid "Hide Metadata from Listeners (\"Jingle Mode\")"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:190
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:199
msgid "High"
msgstr ""
@ -387,7 +392,7 @@ msgstr ""
msgid "If any of these options are enabled, this playlist will be managed directly via Liquidsoap instead of via AzuraCast. This can have unintended effects and should only be used when you are comfortable with the results."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:93
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:102
msgid "If auto-assignment is enabled, use this playlist as one of the targets for songs to be redistributed into. This will overwrite the existing contents of this playlist."
msgstr ""
@ -411,16 +416,24 @@ msgstr ""
msgid "If the end time is before the start time, the schedule entry will continue overnight."
msgstr ""
#: ./vue/StationPlaylists.vue:158
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:40
msgid "If this station has on-demand streaming and downloading enabled, only songs that are in playlists with this setting enabled will be visible."
msgstr ""
#: ./vue/StationPlaylists.vue:164
#: ./vue/station_playlists/PlaylistImportModal.vue:20
#: ./vue/station_playlists/PlaylistImportModal.vue:42
msgid "Import from PLS/M3U"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:96
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:105
msgid "Include in Automated Assignment"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:43
msgid "Include in On-Demand Player"
msgstr ""
#: ./vue/station_streamers/form/StreamerFormBasicInfo.vue:50
msgid "Internal notes or comments about the user, visible only on this control panel."
msgstr ""
@ -445,11 +458,11 @@ msgstr ""
msgid "Jingle Mode"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:77
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:86
msgid "Learn about Advanced Playlists"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:98
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:100
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:92
msgid "Leave blank to play on every day of the week."
msgstr ""
@ -458,7 +471,7 @@ msgstr ""
msgid "Length"
msgstr ""
#: ./vue/RadioPlayer.vue:14
#: ./vue/RadioPlayer.vue:12
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:52
msgid "Live"
msgstr ""
@ -467,11 +480,11 @@ msgstr ""
msgid "Loading..."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:186
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:195
msgid "Low"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:75
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:84
msgid "Manually define how this playlist is used in Liquidsoap configuration."
msgstr ""
@ -491,7 +504,7 @@ msgstr ""
msgid "Microphone Source"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:159
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:168
msgid "Minute of Hour to Play"
msgstr ""
@ -503,12 +516,12 @@ msgstr ""
msgid "Modified"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:133
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:135
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:127
msgid "Monday"
msgstr ""
#: ./vue/StationPlaylists.vue:149
#: ./vue/StationPlaylists.vue:155
msgid "More"
msgstr ""
@ -528,8 +541,9 @@ msgstr ""
msgid "MP3"
msgstr ""
#: ./vue/InlinePlayer.vue:25
#: ./vue/RadioPlayer.vue:314
#: ./vue/InlinePlayer.vue:26
#: ./vue/RadioPlayer.vue:291
#: ./vue/components/Waveform.vue:28
msgid "Mute"
msgstr ""
@ -574,38 +588,46 @@ msgstr ""
msgid "Notes"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:135
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:144
msgid "Number of Minutes Between Plays"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:111
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:120
msgid "Number of Songs Between Plays"
msgstr ""
#: ./vue/StationPlaylists.vue:193
#: ./vue/StationPlaylists.vue:79
msgid "On-Demand"
msgstr ""
#: ./vue/StationOnDemand.vue:6
msgid "On-Demand Media"
msgstr ""
#: ./vue/StationPlaylists.vue:199
msgid "Once per %{minutes} Minutes"
msgstr ""
#: ./vue/StationPlaylists.vue:189
#: ./vue/StationPlaylists.vue:195
msgid "Once per %{songs} Songs"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:65
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:153
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:74
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:162
msgid "Once per Hour"
msgstr ""
#: ./vue/StationPlaylists.vue:197
#: ./vue/StationPlaylists.vue:203
msgid "Once per Hour (at %{minute})"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:58
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:129
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:67
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:138
msgid "Once per x Minutes"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:51
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:105
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:60
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:114
msgid "Once per x Songs"
msgstr ""
@ -621,32 +643,33 @@ msgstr ""
msgid "Password"
msgstr ""
#: ./vue/InlinePlayer.vue:19
#: ./vue/RadioPlayer.vue:311
#: ./vue/InlinePlayer.vue:20
#: ./vue/RadioPlayer.vue:288
msgid "Pause"
msgstr ""
#: ./vue/RadioPlayer.vue:308
#: ./vue/RadioPlayer.vue:285
msgid "Play"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:61
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:70
msgid "Play exactly once every <i>x</i> minutes."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:54
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:63
msgid "Play exactly once every <i>x</i> songs."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:68
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:77
msgid "Play once per hour at the specified minute."
msgstr ""
#: ./vue/StationMedia.vue:221
#: ./vue/StationOnDemand.vue:109
msgid "Play/Pause"
msgstr ""
#: ./vue/StationPlaylists.vue:135
#: ./vue/StationPlaylists.vue:141
msgid "Playlist"
msgstr ""
@ -666,7 +689,7 @@ msgstr ""
msgid "Playlist order set."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:40
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:49
msgid "Playlist Type"
msgstr ""
@ -736,7 +759,7 @@ msgstr ""
msgid "Rename File/Directory"
msgstr ""
#: ./vue/StationPlaylists.vue:152
#: ./vue/StationPlaylists.vue:158
msgid "Reorder"
msgstr ""
@ -752,7 +775,7 @@ msgstr ""
msgid "Replace Album Cover Art"
msgstr ""
#: ./vue/StationPlaylists.vue:155
#: ./vue/StationPlaylists.vue:161
msgid "Reshuffle"
msgstr ""
@ -765,7 +788,7 @@ msgstr ""
msgid "Sample Rate"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:138
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:140
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:132
msgid "Saturday"
msgstr ""
@ -780,17 +803,17 @@ msgstr ""
msgid "Save Changes"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:145
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:147
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:139
msgid "Schedule"
msgstr ""
#: ./vue/StationPlaylists.vue:146
#: ./vue/StationPlaylists.vue:152
#: ./vue/StationStreamers.vue:95
msgid "Schedule View"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:95
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:97
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:89
msgid "Scheduled Play Days of Week"
msgstr ""
@ -800,7 +823,7 @@ msgstr ""
msgid "Scheduled Time #%{num}"
msgstr ""
#: ./vue/StationPlaylists.vue:136
#: ./vue/StationPlaylists.vue:142
msgid "Scheduling"
msgstr ""
@ -816,7 +839,7 @@ msgstr ""
msgid "Seconds from the start of the song that the AutoDJ should stop playing."
msgstr ""
#: ./vue/InlinePlayer.vue:105
#: ./vue/InlinePlayer.vue:82
msgid "Seek"
msgstr ""
@ -844,19 +867,23 @@ msgstr ""
msgid "Sequential"
msgstr ""
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:25
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:3
msgid "Set cue and fade points using the visual editor. The timestamps will be saved to the corresponding fields in the advanced playback settings."
msgstr ""
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:22
msgid "Set Cue In"
msgstr ""
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:29
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:26
msgid "Set Cue Out"
msgstr ""
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:39
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:36
msgid "Set Fade In"
msgstr ""
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:43
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:40
msgid "Set Fade Out"
msgstr ""
@ -864,7 +891,7 @@ msgstr ""
msgid "Set or clear playlists from the selected media"
msgstr ""
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:34
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:31
msgid "Set Overlap"
msgstr ""
@ -915,11 +942,11 @@ msgstr ""
msgid "Source"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:162
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:171
msgid "Specify the minute of every hour that this playlist should play."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:47
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:56
msgid "Standard playlist, shuffles with other standard playlists based on weight."
msgstr ""
@ -938,6 +965,10 @@ msgstr ""
msgid "Start Time"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:70
msgid "Start/end date cannot be used on playlists with advanced settings!"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:56
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:53
msgid "Station Time Zone"
@ -967,7 +998,7 @@ msgstr ""
msgid "Streamer/DJ Accounts"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:139
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:141
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:133
msgid "Sunday"
msgstr ""
@ -1013,13 +1044,13 @@ msgstr ""
#: ./vue/station_media/form/MediaFormBasicInfo.vue:14
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:11
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:25
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:120
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:144
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:168
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:129
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:153
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:177
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:38
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:51
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:76
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:89
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:78
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:91
#: ./vue/station_playlists/form/PlaylistFormSource.vue:86
#: ./vue/station_playlists/form/PlaylistFormSource.vue:115
#: ./vue/station_streamers/form/StreamerFormBasicInfo.vue:14
@ -1041,11 +1072,11 @@ msgstr ""
msgid "This playlist currently has no scheduled times. It will play at all times. To add a new scheduled time, click the button below."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:138
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:147
msgid "This playlist will play every $x minutes, where $x is specified below."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:114
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:123
msgid "This playlist will play every $x songs, where $x is specified below."
msgstr ""
@ -1060,12 +1091,13 @@ msgstr ""
msgid "This streamer is not scheduled to play at any times."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:136
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:138
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:130
msgid "Thursday"
msgstr ""
#: ./vue/StationMedia.vue:145
#: ./vue/StationOnDemand.vue:70
#: ./vue/station_playlists/PlaylistReorderModal.vue:8
#: ./vue/webcaster/settings.vue:108
msgid "Title"
@ -1080,7 +1112,7 @@ msgstr ""
msgid "To set this schedule to run only within a certain date range, specify a start and end date."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:134
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:136
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:128
msgid "Tuesday"
msgstr ""
@ -1114,12 +1146,13 @@ msgstr ""
msgid "View tracks in playlist"
msgstr ""
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:65
#: ./vue/station_media/form/MediaFormWaveformEditor.vue:61
msgid "Visual Cue Editor"
msgstr ""
#: ./vue/InlinePlayer.vue:108
#: ./vue/RadioPlayer.vue:317
#: ./vue/InlinePlayer.vue:85
#: ./vue/RadioPlayer.vue:294
#: ./vue/components/Waveform.vue:107
msgid "Volume"
msgstr ""
@ -1127,15 +1160,15 @@ msgstr ""
msgid "Warning"
msgstr ""
#: ./vue/components/Waveform.vue:11
#: ./vue/components/Waveform.vue:15
msgid "Waveform Zoom"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:135
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:137
#: ./vue/station_streamers/form/StreamerFormSchedule.vue:129
msgid "Wednesday"
msgstr ""
#: ./vue/StationPlaylists.vue:186
#: ./vue/StationPlaylists.vue:192
msgid "Weight"
msgstr ""

View File

@ -0,0 +1,40 @@
<?php
namespace App\Controller\Api\Stations\OnDemand;
use App\Entity;
use App\Flysystem\Filesystem;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class DownloadAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $media_id,
Entity\Repository\StationMediaRepository $mediaRepo,
Filesystem $filesystem
): ResponseInterface {
$station = $request->getStation();
// Verify that the station supports on-demand streaming.
if (!$station->getEnableOnDemand()) {
return $response->withStatus(403)
->withJson(new Entity\Api\Error(403, __('This station does not support on-demand streaming.')));
}
$media = $mediaRepo->findByUniqueId($media_id, $station);
if (!($media instanceof Entity\StationMedia)) {
return $response->withStatus(404)
->withJson(new Entity\Api\Error(404, __('File not found.')));
}
$filePath = $media->getPathUri();
$fs = $filesystem->getForStation($station);
set_time_limit(600);
return $response->withFlysystemFile($fs, $filePath);
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Controller\Api\Stations\OnDemand;
use App\ApiUtilities;
use App\Doctrine\Paginator;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Utilities;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\ResponseInterface;
class ListAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManager $em,
ApiUtilities $apiUtils
): ResponseInterface {
$station = $request->getStation();
// Verify that the station supports on-demand streaming.
if (!$station->getEnableOnDemand()) {
return $response->withStatus(403)
->withJson(new Entity\Api\Error(403, __('This station does not support on-demand streaming.')));
}
$qb = $em->createQueryBuilder();
$qb->select('sm, s, spm, sp')
->from(Entity\StationMedia::class, 'sm')
->join('sm.song', 's')
->leftJoin('sm.playlists', 'spm')
->leftJoin('spm.playlist', 'sp')
->where('sm.station_id = :station_id')
->andWhere('sp.id IS NOT NULL')
->andWhere('sp.is_enabled = 1')
->andWhere('sp.include_in_on_demand = 1')
->setParameter('station_id', $station->getId());
$params = $request->getQueryParams();
if (!empty($params['sort'])) {
$sort_fields = [
'song_title' => 'sm.title',
'song_artist' => 'sm.artist',
'song_album' => 'sm.album',
];
foreach ($params['sort'] as $sort_key => $sort_direction) {
if (isset($sort_fields[$sort_key])) {
$qb->addOrderBy($sort_fields[$sort_key], $sort_direction);
}
}
} else {
$qb->orderBy('sm.artist', 'ASC')
->addOrderBy('sm.title', 'ASC');
}
$search_phrase = trim($params['searchPhrase']);
if (!empty($search_phrase)) {
$qb->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query OR sm.album LIKE :query)')
->setParameter('query', '%' . $search_phrase . '%');
}
$paginator = new Paginator($qb);
$paginator->setFromRequest($request);
$isBootgrid = $paginator->isFromBootgrid();
$router = $request->getRouter();
$paginator->setPostprocessor(function ($media) use ($station, $isBootgrid, $router, $apiUtils) {
/** @var Entity\StationMedia $media */
$row = new Entity\Api\StationOnDemand();
$row->track_id = $media->getUniqueId();
$row->media = $media->api($apiUtils);
$row->download_url = (string)$router->named('api:stations:ondemand:download', [
'station_id' => $station->getId(),
'media_id' => $media->getUniqueId(),
]);
$row->resolveUrls($router->getBaseUrl());
if ($isBootgrid) {
return Utilities::flattenArray($row, '_');
}
return $row;
});
return $paginator->write($response);
}
}

View File

@ -1,190 +0,0 @@
<?php
namespace App\Controller\Frontend;
use App\Entity;
use App\Exception\StationNotFoundException;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Radio\Backend\Liquidsoap;
use App\Radio\Remote\AdapterProxy;
use Psr\Http\Message\ResponseInterface;
class PublicController
{
public function indexAction(ServerRequest $request, Response $response): ResponseInterface
{
return $this->_getPublicPage($request, $response, 'frontend/public/index');
}
protected function _getPublicPage(ServerRequest $request, Response $response, $template_name, $template_vars = [])
{
// Override system-wide iframe refusal
$response = $response->withHeader('X-Frame-Options', '*');
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw new StationNotFoundException;
}
$np = [
'station' => [
'listen_url' => '',
'mounts' => [],
'remotes' => [],
],
'now_playing' => [
'song' => [
'title' => __('Song Title'),
'artist' => __('Song Artist'),
'art' => '',
],
'playlist' => '',
'is_request' => false,
'duration' => 0,
],
'live' => [
'is_live' => false,
'streamer_name' => '',
],
'song_history' => [],
];
$station_np = $station->getNowplaying();
if ($station_np instanceof Entity\Api\NowPlaying) {
$station_np->resolveUrls($request->getRouter()->getBaseUrl());
$np = array_intersect_key($station_np->toArray(), $np) + $np;
}
return $request->getView()->renderToResponse($response, $template_name, $template_vars + [
'station' => $station,
'nowplaying' => $np,
]);
}
public function embedAction(ServerRequest $request, Response $response): ResponseInterface
{
return $this->_getPublicPage($request, $response, 'frontend/public/embed');
}
public function embedrequestsAction(ServerRequest $request, Response $response): ResponseInterface
{
return $this->_getPublicPage($request, $response, 'frontend/public/embedrequests');
}
public function playlistAction(
ServerRequest $request,
Response $response,
$station_id,
$format = 'pls'
): ResponseInterface {
$station = $request->getStation();
$streams = [];
$stream_urls = [];
$fa = $request->getStationFrontend();
foreach ($station->getMounts() as $mount) {
/** @var Entity\StationMount $mount */
if (!$mount->isVisibleOnPublicPages()) {
continue;
}
$stream_url = $fa->getUrlForMount($station, $mount, null, false);
$stream_urls[] = $stream_url;
$streams[] = [
'name' => $station->getName() . ' - ' . $mount->getDisplayName(),
'url' => $stream_url,
];
}
$remotes = $request->getStationRemotes();
foreach ($remotes as $remote_proxy) {
/** @var AdapterProxy $remote_proxy */
$adapter = $remote_proxy->getAdapter();
$remote = $remote_proxy->getRemote();
$stream_url = $adapter->getPublicUrl($remote);
$stream_urls[] = $stream_url;
$streams[] = [
'name' => $station->getName() . ' - ' . $remote->getDisplayName(),
'url' => $stream_url,
];
}
$format = strtolower($format);
switch ($format) {
// M3U Playlist Format
case 'm3u':
$m3u_file = implode("\n", $stream_urls);
$response->getBody()->write($m3u_file);
return $response
->withHeader('Content-Type', 'audio/x-mpegurl')
->withHeader('Content-Disposition', 'attachment; filename=' . $station->getShortName() . '.m3u');
break;
// PLS Playlist Format
case 'pls':
default:
$output = [
'[playlist]',
];
$i = 1;
foreach ($streams as $stream) {
$output[] = 'File' . $i . '=' . $stream['url'];
$output[] = 'Title' . $i . '=' . $stream['name'];
$output[] = 'Length' . $i . '=-1';
$output[] = '';
$i++;
}
$output[] = 'NumberOfEntries=' . count($streams);
$output[] = 'Version=2';
$response->getBody()->write(implode("\n", $output));
return $response
->withHeader('Content-Type', 'audio/x-scpls')
->withHeader('Content-Disposition', 'attachment; filename=' . $station->getShortName() . '.pls');
break;
}
}
public function djAction(
ServerRequest $request,
Response $response,
$station_id,
$format = 'pls'
): ResponseInterface {
// Override system-wide iframe refusal
$response = $response->withHeader('X-Frame-Options', '*');
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw new StationNotFoundException;
}
if (!$station->getEnableStreamers()) {
throw new StationUnsupportedException;
}
$backend = $request->getStationBackend();
if (!($backend instanceof Liquidsoap)) {
throw new StationUnsupportedException;
}
$wss_url = (string)$backend->getWebStreamingUrl($station, $request->getRouter()->getBaseUrl());
$wss_url = str_replace('wss://', '', $wss_url);
return $request->getView()->renderToResponse($response, 'frontend/public/dj', [
'station' => $station,
'base_uri' => $wss_url,
]);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Controller\Frontend\PublicPages;
use App\Exception\StationNotFoundException;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\ServerRequest;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\ResponseInterface;
class OnDemandAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManager $em,
bool $embed = false
): ResponseInterface {
// Override system-wide iframe refusal
$response = $response->withHeader('X-Frame-Options', '*');
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw new StationNotFoundException;
}
if (!$station->getEnableOnDemand()) {
throw new StationUnsupportedException;
}
// Get list of custom fields.
$customFieldsRaw = $em->createQuery(/** @lang DQL */ 'SELECT cf.id, cf.short_name, cf.name FROM App\Entity\CustomField cf ORDER BY cf.name ASC')
->getArrayResult();
$customFields = [];
foreach ($customFieldsRaw as $row) {
$customFields[] = [
'display_key' => 'media_custom_' . $row['id'],
'key' => $row['short_name'],
'label' => $row['name'],
];
}
$templateName = ($embed)
? 'frontend/public/ondemand_embed'
: 'frontend/public/ondemand';
return $request->getView()->renderToResponse($response, $templateName, [
'station' => $station,
'custom_fields' => $customFields,
]);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Controller\Frontend\PublicPages;
use App\Entity;
use App\Exception\StationNotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class PlayerAction
{
public function __invoke(
ServerRequest $request,
Response $response,
bool $embed = false
): ResponseInterface {
// Override system-wide iframe refusal
$response = $response->withHeader('X-Frame-Options', '*');
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw new StationNotFoundException;
}
$np = [
'station' => [
'listen_url' => '',
'mounts' => [],
'remotes' => [],
],
'now_playing' => [
'song' => [
'title' => __('Song Title'),
'artist' => __('Song Artist'),
'art' => '',
],
'playlist' => '',
'is_request' => false,
'duration' => 0,
],
'live' => [
'is_live' => false,
'streamer_name' => '',
],
'song_history' => [],
];
$station_np = $station->getNowplaying();
if ($station_np instanceof Entity\Api\NowPlaying) {
$station_np->resolveUrls($request->getRouter()->getBaseUrl());
$np = array_intersect_key($station_np->toArray(), $np) + $np;
}
$templateName = ($embed)
? 'frontend/public/embed'
: 'frontend/public/index';
return $request->getView()->renderToResponse($response, $templateName, [
'station' => $station,
'nowplaying' => $np,
]);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Controller\Frontend\PublicPages;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class PlaylistAction
{
public function __invoke(
ServerRequest $request,
Response $response,
$station_id,
$format = 'pls'
): ResponseInterface {
$station = $request->getStation();
$streams = [];
$stream_urls = [];
$fa = $request->getStationFrontend();
foreach ($station->getMounts() as $mount) {
/** @var Entity\StationMount $mount */
if (!$mount->isVisibleOnPublicPages()) {
continue;
}
$stream_url = $fa->getUrlForMount($station, $mount, null, false);
$stream_urls[] = $stream_url;
$streams[] = [
'name' => $station->getName() . ' - ' . $mount->getDisplayName(),
'url' => $stream_url,
];
}
$remotes = $request->getStationRemotes();
foreach ($remotes as $remote_proxy) {
$adapter = $remote_proxy->getAdapter();
$remote = $remote_proxy->getRemote();
$stream_url = $adapter->getPublicUrl($remote);
$stream_urls[] = $stream_url;
$streams[] = [
'name' => $station->getName() . ' - ' . $remote->getDisplayName(),
'url' => $stream_url,
];
}
$format = strtolower($format);
switch ($format) {
// M3U Playlist Format
case 'm3u':
$m3u_file = implode("\n", $stream_urls);
$response->getBody()->write($m3u_file);
return $response
->withHeader('Content-Type', 'audio/x-mpegurl')
->withHeader('Content-Disposition', 'attachment; filename=' . $station->getShortName() . '.m3u');
break;
// PLS Playlist Format
case 'pls':
default:
$output = [
'[playlist]',
];
$i = 1;
foreach ($streams as $stream) {
$output[] = 'File' . $i . '=' . $stream['url'];
$output[] = 'Title' . $i . '=' . $stream['name'];
$output[] = 'Length' . $i . '=-1';
$output[] = '';
$i++;
}
$output[] = 'NumberOfEntries=' . count($streams);
$output[] = 'Version=2';
$response->getBody()->write(implode("\n", $output));
return $response
->withHeader('Content-Type', 'audio/x-scpls')
->withHeader('Content-Disposition', 'attachment; filename=' . $station->getShortName() . '.pls');
break;
}
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Controller\Frontend\PublicPages;
use App\Exception\StationNotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class RequestsAction
{
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
// Override system-wide iframe refusal
$response = $response->withHeader('X-Frame-Options', '*');
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw new StationNotFoundException;
}
return $request->getView()->renderToResponse($response, 'frontend/public/embedrequests', [
'station' => $station,
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Controller\Frontend\PublicPages;
use App\Exception\StationNotFoundException;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Radio\Backend\Liquidsoap;
use Psr\Http\Message\ResponseInterface;
class WebDjAction
{
public function __invoke(
ServerRequest $request,
Response $response,
$station_id,
$format = 'pls'
): ResponseInterface {
// Override system-wide iframe refusal
$response = $response->withHeader('X-Frame-Options', '*');
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw new StationNotFoundException;
}
if (!$station->getEnableStreamers()) {
throw new StationUnsupportedException;
}
$backend = $request->getStationBackend();
if (!($backend instanceof Liquidsoap)) {
throw new StationUnsupportedException;
}
$wss_url = (string)$backend->getWebStreamingUrl($station, $request->getRouter()->getBaseUrl());
$wss_url = str_replace('wss://', '', $wss_url);
return $request->getView()->renderToResponse($response, 'frontend/public/dj', [
'station' => $station,
'base_uri' => $wss_url,
]);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Entity\Api;
use OpenApi\Annotations as OA;
use Psr\Http\Message\UriInterface;
/**
* @OA\Schema(type="object", schema="Api_StationOnDemand")
*/
class StationOnDemand implements ResolvableUrlInterface
{
/**
* Track ID unique identifier
*
* @OA\Property(example=1)
* @var string
*/
public string $track_id;
/**
* URL to download/play track.
*
* @OA\Property(example="/api/station/1/ondemand/download/1")
* @var string
*/
public string $download_url;
/**
* Song
*
* @OA\Property
* @var Song
*/
public Song $media;
/**
* Re-resolve any Uri instances to reflect base URL changes.
*
* @param UriInterface $base
*/
public function resolveUrls(UriInterface $base): void
{
$this->media->resolveUrls($base);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20200514061004 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add "on-demand" options for stations and playlists.';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql',
'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station ADD enable_on_demand TINYINT(1) NOT NULL');
$this->addSql('ALTER TABLE station_playlists ADD include_in_on_demand TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql',
'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station DROP enable_on_demand');
$this->addSql('ALTER TABLE station_playlists DROP include_in_on_demand');
}
}

View File

@ -255,6 +255,14 @@ class Station
*/
protected $enable_public_page = true;
/**
* @ORM\Column(name="enable_on_demand", type="boolean", nullable=false)
*
* @OA\Property(example=true)
* @var bool Whether this station has a public "on-demand" streaming and download page.
*/
protected $enable_on_demand = false;
/**
* @ORM\Column(name="needs_restart", type="boolean")
*
@ -844,6 +852,16 @@ class Station
$this->enable_public_page = $enable_public_page;
}
public function getEnableOnDemand(): bool
{
return $this->enable_on_demand;
}
public function setEnableOnDemand(bool $enable_on_demand): void
{
$this->enable_on_demand = $enable_on_demand;
}
public function isEnabled(): bool
{
return $this->is_enabled;

View File

@ -205,6 +205,15 @@ class StationPlaylist
*/
protected $include_in_requests = true;
/**
* @ORM\Column(name="include_in_on_demand", type="boolean")
*
* @OA\Property(example=true)
*
* @var bool Whether this playlist's media is included in "on demand" download/streaming if enabled.
*/
protected $include_in_on_demand = false;
/**
* @ORM\Column(name="include_in_automation", type="boolean")
*
@ -428,6 +437,16 @@ class StationPlaylist
$this->include_in_requests = $include_in_requests;
}
public function getIncludeInOnDemand(): bool
{
return $this->include_in_on_demand;
}
public function setIncludeInOnDemand(bool $include_in_on_demand): void
{
$this->include_in_on_demand = $include_in_on_demand;
}
/**
* Indicates whether this playlist can be used as a valid source of requestable media.
*

View File

@ -0,0 +1,19 @@
<?php
$props = [
'listUrl' => $router->fromHere('api:stations:ondemand:list'),
'customFields' => $custom_fields,
'stationName' => $station->getName(),
]
?>
var station_on_demand;
$(function () {
station_on_demand = new Vue({
el: '#station_on_demand',
render: function (createElement) {
return createElement(StationOnDemand.default, {
props: <?=json_encode($props) ?>
});
}
});
});

View File

@ -0,0 +1,22 @@
<?php
$this->layout('minimal', [
'page_class' => 'ondemand station-' . $station->getShortName(),
'title' => 'On-Demand Media - ' . $this->e($station->getName()),
'hide_footer' => true,
]);
/** @var \App\Assets $assets */
$assets
->load('vue')
->load('fancybox')
->load('station_on_demand')
->addInlineJs($this->fetch('frontend/public/ondemand.js', [
'station' => $station,
'custom_fields' => $custom_fields,
]));
?>
<section id="content" role="main" style="height: 100vh;">
<div class="container pt-5 pb-5">
<div id="station_on_demand"></div>
</div>
</section>

View File

@ -0,0 +1,18 @@
<?php
$this->layout('minimal', [
'page_class' => 'embed ondemand station-' . $station->getShortName(),
'title' => 'On-Demand Media - ' . $this->e($station->getName()),
'hide_footer' => true,
]);
/** @var \App\Assets $assets */
$assets
->load('vue')
->load('station_on_demand')
->addInlineJs($this->fetch('frontend/public/ondemand.js', [
'station' => $station,
'custom_fields' => $custom_fields,
]));
?>
<div id="station_on_demand"></div>

View File

@ -14,62 +14,92 @@
<?php if ($station->getEnablePublicPage()): ?>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<?=__('Public Pages') ?>
<small class="badge badge-pill badge-success"><?=__('Enabled') ?></small>
<?=__('Public Pages')?>
<small class="badge badge-pill badge-success"><?=__('Enabled')?></small>
</h3>
</div>
<table class="table table-striped table-responsive-md mb-0">
<tr>
<td style="width: 30%;"><?=__('Public Page') ?></td>
<td style="width: 30%;"><?=__('Public Page')?></td>
<td style="width: 70%;">
<?=$this->link($router->named('public:index', ['station_id' => $station->getShortName()], [], true)) ?>
<?=$this->link($router->named('public:index', ['station_id' => $station->getShortName()], [],
true))?>
</td>
</tr>
<?php if ($backend::supportsStreamers() && $station->getEnableStreamers()): ?>
<tr>
<td><?=__('Web DJ') ?></td>
<td>
<?=$this->link($router->named('public:dj', ['station_id' => $station->getShortName()], [], true)) ?>
</td>
</tr>
<?php endif; ?>
<tr>
<td><?=__('Player Embed Code') ?></td>
<td><?=__('Player Embed Code')?></td>
<td class="form-field">
<textarea id="player_embed_url" class="full-width form-control text-preformatted" spellcheck="false" style="height: 70px;"><iframe src="<?=$router->named('public:embed', ['station_id' => $station->getShortName()], [], true) ?>" frameborder="0" allowtransparency="true" style="width: 100%; min-height: 150px; border: 0;"></iframe></textarea>
<button class="btn btn-copy btn-link btn-xs" data-clipboard-target="#player_embed_url"><i class="material-icons sm">file_copy</i> <?=__('Copy to Clipboard') ?></button>
<textarea id="player_embed_url" class="full-width form-control text-preformatted" spellcheck="false" style="height: 70px;"><iframe src="<?=$router->named('public:index',
['station_id' => $station->getShortName(), 'embed' => 'embed'], [],
true)?>" frameborder="0" allowtransparency="true" style="width: 100%; min-height: 150px; border: 0;"></iframe></textarea>
<button class="btn btn-copy btn-link btn-xs" data-clipboard-target="#player_embed_url">
<i class="material-icons sm">file_copy</i> <?=__('Copy to Clipboard')?></button>
</td>
</tr>
<?php if ($backend::supportsRequests() && $station->getEnableRequests()): ?>
<tr>
<td><?=__('Request Embed Code') ?></td>
<td class="form-field">
<textarea id="request_embed_url" class="full-width form-control text-preformatted" spellcheck="false" style="height: 70px;"><iframe src="<?=$router->named('public:embedrequests', ['station_id' => $station->getShortName()], [], true) ?>" frameborder="0" allowtransparency="true" style="width: 100%; min-height: 850px; border: 0;"></iframe></textarea>
<button class="btn btn-copy btn-link btn-xs" data-clipboard-target="#request_embed_url"><i class="material-icons sm">file_copy</i> <?=__('Copy to Clipboard') ?></button>
</td>
</tr>
<?php endif; ?>
<?php if ($backend::supportsStreamers() && $station->getEnableStreamers()): ?>
<tr>
<td><?=__('Web DJ')?></td>
<td>
<?=$this->link($router->named('public:dj', ['station_id' => $station->getShortName()], [],
true))?>
</td>
</tr>
<?php endif; ?>
<?php if ($station->getEnableOnDemand()): ?>
<tr>
<td><?=__('On-Demand Media')?></td>
<td>
<?=$this->link($router->named('public:ondemand', ['station_id' => $station->getShortName()], [],
true))?>
</td>
</tr>
<tr>
<td><?=__('On-Demand Embed Code')?></td>
<td>
<textarea id="ondemand_embed_url" class="full-width form-control text-preformatted" spellcheck="false" style="height: 70px;"><iframe src="<?=$router->named('public:ondemand',
['station_id' => $station->getShortName(), 'embed' => 'embed'], [],
true)?>" frameborder="0" allowtransparency="true" style="width: 100%; min-height: 400px; border: 0;"></iframe></textarea>
<button class="btn btn-copy btn-link btn-xs" data-clipboard-target="#ondemand_embed_url">
<i class="material-icons sm">file_copy</i> <?=__('Copy to Clipboard')?></button>
</td>
</tr>
<?php endif; ?>
<?php if ($backend::supportsRequests() && $station->getEnableRequests()): ?>
<tr>
<td><?=__('Request Embed Code')?></td>
<td class="form-field">
<textarea id="request_embed_url" class="full-width form-control text-preformatted" spellcheck="false" style="height: 70px;"><iframe src="<?=$router->named('public:embedrequests',
['station_id' => $station->getShortName()], [],
true)?>" frameborder="0" allowtransparency="true" style="width: 100%; min-height: 850px; border: 0;"></iframe></textarea>
<button class="btn btn-copy btn-link btn-xs" data-clipboard-target="#request_embed_url">
<i class="material-icons sm">file_copy</i> <?=__('Copy to Clipboard')?></button>
</td>
</tr>
<?php endif; ?>
</table>
<?php if ($acl->userAllowed($user, \App\Acl::STATION_PROFILE, $station->getId())): ?>
<div class="card-actions">
<a class="btn btn-outline-danger" data-confirm-title=<?=$this->escapeJs(__('Disable public pages?')) ?> href="<?=$router->fromHere('stations:profile:toggle', ['feature' => 'public', 'csrf' => $csrf]) ?>">
<a class="btn btn-outline-danger" data-confirm-title=<?=$this->escapeJs(__('Disable public pages?'))?> href="<?=$router->fromHere('stations:profile:toggle',
['feature' => 'public', 'csrf' => $csrf])?>">
<i class="material-icons" aria-hidden="true">close</i>
<?=__('Disable') ?>
<?=__('Disable')?>
</a>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<?=__('Public Pages') ?>
<small class="badge badge-pill badge-danger"><?=__('Disabled') ?></small>
<?=__('Public Pages')?>
<small class="badge badge-pill badge-danger"><?=__('Disabled')?></small>
</h3>
</div>
<?php if ($acl->userAllowed($user, \App\Acl::STATION_PROFILE, $station->getId())): ?>
<div class="card-actions">
<a class="btn btn-outline-success" data-confirm-title=<?=$this->escapeJs(__('Enable public pages?')) ?> href="<?=$router->fromHere('stations:profile:toggle', ['feature' => 'public', 'csrf' => $csrf]) ?>">
<a class="btn btn-outline-success" data-confirm-title=<?=$this->escapeJs(__('Enable public pages?'))?> href="<?=$router->fromHere('stations:profile:toggle',
['feature' => 'public', 'csrf' => $csrf])?>">
<i class="material-icons" aria-hidden="true">check</i>
<?=__('Enable') ?>
<?=__('Enable')?>
</a>
</div>
<?php endif; ?>

View File

@ -2,7 +2,7 @@
"dist/app.js": "dist/app-e71c721caa.js",
"dist/bootgrid.js": "dist/bootgrid-dbc21837bc.js",
"dist/dark.css": "dist/dark-2eb39cde96.css",
"dist/inline_player.js": "dist/inline_player-eed4875c5a.js",
"dist/inline_player.js": "dist/inline_player-7df0d22bee.js",
"dist/lib/autosize/autosize.min.js": "dist/lib/autosize/autosize-ad0656589d.min.js",
"dist/lib/bootgrid/jquery.bootgrid.min.css": "dist/lib/bootgrid/jquery-14a6b139e8.bootgrid.min.css",
"dist/lib/bootgrid/jquery.bootgrid.updated.js": "dist/lib/bootgrid/jquery-09b807f309.bootgrid.updated.js",
@ -49,9 +49,10 @@
"dist/lib/zxcvbn/zxcvbn.js": "dist/lib/zxcvbn/zxcvbn-9cf6916dc0.js",
"dist/light.css": "dist/light-b6b5ab39b6.css",
"dist/material.js": "dist/material-df68dbf23f.js",
"dist/radio_player.js": "dist/radio_player-f88abea4e8.js",
"dist/radio_player.js": "dist/radio_player-562bd0b7db.js",
"dist/station_media.js": "dist/station_media-0d81c6e9d1.js",
"dist/station_playlists.js": "dist/station_playlists-c1c36c2497.js",
"dist/station_on_demand.js": "dist/station_on_demand-77728edf82.js",
"dist/station_playlists.js": "dist/station_playlists-dc79da8967.js",
"dist/station_streamers.js": "dist/station_streamers-2e160bdd93.js",
"dist/vue_gettext.js": "dist/vue_gettext-2d25ba9686.js",
"dist/webcaster.js": "dist/webcaster-09a0e0221a.js",

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long