New feature: Track broadcasts and record them. (#2353)

* Add form entries and LS config writing for live stream recording.

* Create new Broadcast entity; implement new djon/djoff handling.

* Rework record command procedure and add Station relation to Broadcast.

* Run code reformat on JS to add semicolons back.

* Properly save recording path on entity.

* Initial commit of new streamers Vue component.

* Finish frontend Vue dev and add necessary API endpoints.

* Add loader to Datatable; update npm deps; polish on components.
This commit is contained in:
Buster "Silver Eagle" Neece 2020-01-28 20:23:55 -06:00 committed by GitHub
parent d7371c4802
commit ca9c5db39d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 3368 additions and 2231 deletions

View File

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

View File

@ -28,12 +28,12 @@ return function (Application $console) {
)->setDescription('Authorize a streamer to connect as a source for the radio service.');
$console->command(
'azuracast:internal:djoff station-id',
'azuracast:internal:djoff station-id [--dj-user=]',
Command\Internal\DjOffCommand::class
)->setDescription('Indicate that a DJ has finished streaming to a station.');
$console->command(
'azuracast:internal:djon station-id',
'azuracast:internal:djon station-id [--dj-user=]',
Command\Internal\DjOnCommand::class
)->setDescription('Indicate that a DJ has begun streaming to a station.');

View File

@ -1,5 +1,6 @@
<?php
use App\Entity\Station;
use App\Entity\StationMountInterface;
use App\Radio\Adapters;
$frontends = Adapters::listFrontendAdapters(true);
@ -309,7 +310,55 @@ return [
'selected_text' => __('Yes'),
'deselected_text' => __('No'),
'default' => false,
'form_group_class' => 'col-md-8',
'form_group_class' => 'col-md-12',
]
],
'record_streams' => [
'toggle',
[
'label' => __('Record Live Broadcasts'),
'description' => __('If enabled, AzuraCast will automatically record any live broadcasts made to this station to per-broadcast recordings.'),
'selected_text' => __('Yes'),
'deselected_text' => __('No'),
'default' => false,
'belongsTo' => 'backend_config',
'form_group_class' => 'col-md-4',
]
],
'record_streams_format' => [
'radio',
[
'label' => __('Live Broadcast Recording Format'),
'choices' => [
StationMountInterface::FORMAT_MP3 => 'MP3',
StationMountInterface::FORMAT_OGG => 'OGG Vorbis',
StationMountInterface::FORMAT_OPUS => 'OGG Opus',
StationMountInterface::FORMAT_AAC => 'AAC+ (MPEG4 HE-AAC v2)',
],
'belongsTo' => 'backend_config',
'form_group_class' => 'col-md-4',
]
],
'record_streams_bitrate' => [
'radio',
[
'label' => __('Live Broadcast Recording Bitrate (kbps)'),
'choices' => [
32 => '32',
48 => '48',
64 => '64',
96 => '96',
128 => '128',
192 => '192',
256 => '256',
320 => '320',
],
'default' => 128,
'belongsTo' => 'backend_config',
'form_group_class' => 'col-md-4',
]
],

View File

@ -1,73 +0,0 @@
<?php
return [
'method' => 'post',
'groups' => [
[
'use_grid' => true,
'elements' => [
'is_active' => [
'toggle',
[
'label' => __('Account is Active'),
'description' => __('Enable to allow this account to log in and stream.'),
'selected_text' => __('Yes'),
'deselected_text' => __('No'),
'default' => true,
'form_group_class' => 'col-sm-12',
]
],
'streamer_username' => [
'text',
[
'label' => __('Streamer Username'),
'description' => __('The streamer will use this username to connect to the radio server.'),
'required' => true,
'form_group_class' => 'col-md-6',
]
],
'streamer_password' => [
'text',
[
'label' => __('Streamer Password'),
'description' => __('The streamer will use this password to connect to the radio server.'),
'required' => true,
'form_group_class' => 'col-md-6',
]
],
'display_name' => [
'text',
[
'label' => __('Streamer Display Name'),
'description' => __('This is the informal display name that will be shown in API responses if the streamer/DJ is live.'),
'form_group_class' => 'col-md-6',
]
],
'comments' => [
'textarea',
[
'label' => __('Comments'),
'description' => __('Internal notes or comments about the user, visible only on this control panel.'),
'form_group_class' => 'col-md-6',
]
],
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
'form_group_class' => 'col-sm-12',
]
],
],
]
],
];

View File

@ -391,6 +391,18 @@ return function (App $app) {
})->add(new Middleware\Permissions(Acl::STATION_MEDIA, true));
$group->group('/streamer/{id}', function (RouteCollectorProxy $group) {
$group->get('/broadcasts',
Controller\Api\Stations\StreamersController::class . ':broadcastsAction')
->setName('api:stations:streamer:broadcasts');
$group->get('/broadcast/{broadcast_id}',
Controller\Api\Stations\StreamersController::class . ':downloadBroadcastAction')
->setName('api:stations:streamer:broadcast:download');
})->add(new Middleware\Permissions(Acl::STATION_STREAMERS, true));
$group->get('/status', Controller\Api\Stations\ServicesController::class . ':statusAction')
->setName('api:stations:status')
->add(new Middleware\Permissions(Acl::STATION_VIEW, true));

View File

@ -256,6 +256,11 @@ var vueProjects = {
'src_file': 'vue/StationPlaylists.vue',
'filename': 'station_playlists.js',
'library': 'StationPlaylist'
},
'station_streamers': {
'src_file': 'vue/StationStreamers.vue',
'filename': 'station_streamers.js',
'library': 'StationStreamers'
}
}

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@
},
"dependencies": {
"@fancyapps/fancybox": "^3.5.7",
"@flowjs/flow.js": "^2.13.2",
"@flowjs/flow.js": "^2.14.0",
"@fullcalendar/core": "^4.3.1",
"@fullcalendar/daygrid": "^4.3.0",
"@fullcalendar/moment": "^4.3.0",
@ -21,12 +21,12 @@
"bootstrap": "^4.4.1",
"bootstrap-daterangepicker": "^3.0.5",
"bootstrap-notify": "^3.1.3",
"bootstrap-vue": "^2.1.0",
"bootstrap-vue": "^2.3.0",
"chart.js": "^2.9.3",
"chartjs-plugin-colorschemes": "^0.3.0",
"chosen-js": "^1.8.7",
"clipboard": "^2.0.4",
"codemirror": "^5.50.0",
"codemirror": "^5.51.0",
"dirrty": "^1.0.0",
"easygettext": "^2.9.0",
"fullcalendar": "^3.10.1",
@ -37,21 +37,21 @@
"moment": "^2.24.0",
"moment-timezone": "^0.5.27",
"nchan": "^1.0.10",
"popper.js": "^1.16.0",
"popper.js": "^1.16.1",
"roboto-fontface": "^0.10.0",
"sortablejs": "^1.10.2",
"store": "^1.3.20",
"sweetalert": "^2.1.2",
"vue": "^2.6.11",
"vue-gettext": "^2.1.6",
"vue-gettext": "^2.1.8",
"vuedraggable": "^2.23.2",
"vuelidate": "^0.7.4",
"vuelidate": "^0.7.5",
"webpack": "^4.41.5",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/core": "^7.7.7",
"@babel/preset-env": "^7.7.7",
"@babel/core": "^7.8.3",
"@babel/preset-env": "^7.8.3",
"css-loader": "^2.1.1",
"del": "^3.0.0",
"gulp": "^4.0.2",
@ -63,7 +63,7 @@
"gulp-sass": "^4.0.1",
"gulp-sourcemaps": "^2.6.5",
"gulp-uglify": "^3.0.2",
"node-sass": "^4.13.0",
"node-sass": "^4.13.1",
"prettier": "1.12.1",
"sass-loader": "^7.3.1",
"vue-loader": "14.2.2",

View File

@ -41,8 +41,8 @@
</style>
<script>
import store from 'store'
import getLogarithmicVolume from './inc/logarithmic_volume'
import store from 'store';
import getLogarithmicVolume from './inc/logarithmic_volume';
export default {
data () {
@ -50,88 +50,88 @@
'isPlaying': false,
'volume': 55,
'audio': null
}
};
},
created () {
// Allow pausing from the mobile metadata update.
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('pause', () => {
this.stop()
})
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.volume = store.get('player_volume', this.volume);
}
this.$eventHub.$on('player_toggle', (url) => {
if (this.isPlaying && this.audio.src === url) {
this.stop()
this.stop();
} else {
this.stop()
this.stop();
Vue.nextTick(() => {
this.play(url)
})
this.play(url);
});
}
})
});
},
computed: {
lang_volume () {
return this.$gettext('Volume')
return this.$gettext('Volume');
}
},
watch: {
volume (volume) {
if (this.audio !== null) {
this.audio.volume = getLogarithmicVolume(volume)
this.audio.volume = getLogarithmicVolume(volume);
}
if (store.enabled) {
store.set('player_volume', volume)
store.set('player_volume', volume);
}
}
},
methods: {
play (url) {
if (this.isPlaying) {
this.stop()
this.stop();
Vue.nextTick(() => {
this.play(url)
})
this.play(url);
});
}
this.isPlaying = true
this.isPlaying = true;
Vue.nextTick(() => {
this.audio = this.$refs.player
this.audio = this.$refs.player;
this.audio.onended = () => {
this.stop()
}
this.stop();
};
this.audio.volume = getLogarithmicVolume(this.volume)
this.audio.volume = getLogarithmicVolume(this.volume);
this.audio.src = url
this.audio.src = url;
this.audio.load()
this.audio.play()
})
this.audio.load();
this.audio.play();
});
this.$eventHub.$emit('player_playing', url)
this.$eventHub.$emit('player_playing', url);
},
stop () {
if (!this.isPlaying) {
return
return;
}
this.$eventHub.$emit('player_stopped', this.audio.src)
this.$eventHub.$emit('player_stopped', this.audio.src);
this.audio.pause()
this.audio.src = ''
this.audio.pause();
this.audio.src = '';
this.isPlaying = false
this.isPlaying = false;
}
}
}
};
</script>

View File

@ -64,7 +64,7 @@
{{ current_stream.name }}
</button>
<div class="dropdown-menu" aria-labelledby="btn-select-stream">
<a class="dropdown-item" v-for="stream in streams" href="javascript:;"
<a class="dropdown-item" v-for="stream in streams" href="javascript:"
@click="switchStream(stream)">
{{ stream.name }}
</a>
@ -214,10 +214,10 @@
</style>
<script>
import axios from 'axios'
import NchanSubscriber from 'nchan'
import store from 'store'
import getLogarithmicVolume from './inc/logarithmic_volume'
import axios from 'axios';
import NchanSubscriber from 'nchan';
import store from 'store';
import getLogarithmicVolume from './inc/logarithmic_volume';
export default {
props: {
@ -273,211 +273,211 @@
'np_timeout': null,
'nchan_subscriber': null,
'clock_interval': null
}
};
},
mounted: function () {
this.clock_interval = setInterval(this.iterateTimer, 1000)
this.clock_interval = setInterval(this.iterateTimer, 1000);
// Allow pausing from the mobile metadata update.
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('pause', () => {
this.stop()
})
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.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)
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('volume')) {
this.volume = parseInt(urlParams.get('volume'))
this.volume = parseInt(urlParams.get('volume'));
}
}
// Convert initial NP data from prop to data.
this.setNowPlaying(this.np)
this.setNowPlaying(this.np);
this.np_timeout = setTimeout(this.checkNowPlaying, 5000)
this.np_timeout = setTimeout(this.checkNowPlaying, 5000);
},
computed: {
lang_play_btn () {
return this.$gettext('Play')
return this.$gettext('Play');
},
lang_pause_btn () {
return this.$gettext('Pause')
return this.$gettext('Pause');
},
lang_mute_btn () {
return this.$gettext('Mute')
return this.$gettext('Mute');
},
lang_volume_slider () {
return this.$gettext('Volume')
return this.$gettext('Volume');
},
lang_full_volume_btn () {
return this.$gettext('Full Volume')
return this.$gettext('Full Volume');
},
lang_album_art_alt () {
return this.$gettext('Album Art')
return this.$gettext('Album Art');
},
streams () {
let all_streams = []
let all_streams = [];
this.np.station.mounts.forEach(function (mount) {
all_streams.push({
'name': mount.name,
'url': mount.url
})
})
});
});
this.np.station.remotes.forEach(function (remote) {
all_streams.push({
'name': remote.name,
'url': remote.url
})
})
return all_streams
});
});
return all_streams;
},
time_percent () {
let time_played = this.np_elapsed
let time_total = this.np.now_playing.duration
let time_played = this.np_elapsed;
let time_total = this.np.now_playing.duration;
if (!time_total) {
return 0
return 0;
}
if (time_played > time_total) {
return 100
return 100;
}
return (time_played / time_total) * 100
return (time_played / time_total) * 100;
},
time_display_played () {
let time_played = this.np_elapsed
let time_total = this.np.now_playing.duration
let time_played = this.np_elapsed;
let time_total = this.np.now_playing.duration;
if (!time_total) {
return null
return null;
}
if (time_played > time_total) {
time_played = time_total
time_played = time_total;
}
return this.formatTime(time_played)
return this.formatTime(time_played);
},
time_display_total () {
let time_total = this.np.now_playing.duration
return (time_total) ? this.formatTime(time_total) : null
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)
this.audio.volume = getLogarithmicVolume(volume);
}
if (store.enabled) {
store.set('player_volume', volume)
store.set('player_volume', volume);
}
}
},
methods: {
play () {
if (this.is_playing) {
return
return;
}
this.is_playing = true
this.is_playing = true;
// Wait for "next tick" to force Vue to recreate the <audio> element.
Vue.nextTick(() => {
this.audio = this.$refs.player
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)
console.log('Network interrupted stream. Automatically reconnecting shortly...');
setTimeout(this.play, 5000);
}
}
};
this.audio.onended = () => {
if (this.is_playing) {
this.stop()
this.stop();
console.log('Network interrupted stream. Automatically reconnecting shortly...')
setTimeout(this.play, 5000)
console.log('Network interrupted stream. Automatically reconnecting shortly...');
setTimeout(this.play, 5000);
} else {
this.stop()
this.stop();
}
}
};
this.audio.volume = getLogarithmicVolume(this.volume)
this.audio.volume = getLogarithmicVolume(this.volume);
this.audio.src = this.current_stream.url
this.audio.load()
this.audio.src = this.current_stream.url;
this.audio.load();
this.audio.play()
})
this.audio.play();
});
},
stop () {
this.audio.pause()
this.audio.src = ''
this.audio.pause();
this.audio.src = '';
this.is_playing = false
this.is_playing = false;
},
toggle () {
if (this.is_playing) {
this.stop()
this.stop();
} else {
this.play()
this.play();
}
},
switchStream (new_stream) {
this.current_stream = 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()
this.stop();
Vue.nextTick(() => {
this.play()
})
this.play();
});
},
checkNowPlaying () {
if (this.use_nchan) {
this.nchan_subscriber = new NchanSubscriber(this.now_playing_uri)
this.nchan_subscriber = new NchanSubscriber(this.now_playing_uri);
this.nchan_subscriber.on('message', (message, message_metadata) => {
let np_new = JSON.parse(message)
let np_new = JSON.parse(message);
setTimeout(() => {
this.setNowPlaying(np_new)
}, 5000)
})
this.nchan_subscriber.start()
this.setNowPlaying(np_new);
}, 5000);
});
this.nchan_subscriber.start();
} else {
axios.get(this.now_playing_uri).then((response) => {
this.setNowPlaying(response.data)
this.setNowPlaying(response.data);
}).catch((error) => {
console.error(error)
console.error(error);
}).then(() => {
clearTimeout(this.np_timeout)
this.np_timeout = setTimeout(this.checkNowPlaying, 15000)
})
clearTimeout(this.np_timeout);
this.np_timeout = setTimeout(this.checkNowPlaying, 15000);
});
}
},
setNowPlaying (np_new) {
this.np = np_new
this.np = np_new;
// Set a "default" current stream if none exists.
if (this.current_stream.url === '' && np_new.station.listen_url !== '' && this.streams.length > 0) {
let current_stream = null
let current_stream = null;
this.streams.forEach(function (stream) {
if (stream.url === np_new.station.listen_url) {
current_stream = stream
current_stream = stream;
}
})
});
this.current_stream = current_stream
this.current_stream = current_stream;
}
// Update the browser metadata for browsers that support it (i.e. Mobile Chrome)
@ -488,39 +488,39 @@
artwork: [
{ src: np_new.now_playing.song.art }
]
})
});
}
this.$eventHub.$emit('np_updated', np_new)
this.$eventHub.$emit('np_updated', np_new);
},
iterateTimer () {
let current_time = Math.floor(Date.now() / 1000)
let np_elapsed = current_time - this.np.now_playing.played_at
let current_time = Math.floor(Date.now() / 1000);
let np_elapsed = current_time - this.np.now_playing.played_at;
if (np_elapsed < 0) {
np_elapsed = 0
np_elapsed = 0;
} else if (np_elapsed >= this.np.now_playing.duration) {
np_elapsed = this.np.now_playing.duration
np_elapsed = this.np.now_playing.duration;
}
this.np_elapsed = np_elapsed
this.np_elapsed = np_elapsed;
},
formatTime (time) {
let sec_num = parseInt(time, 10)
let sec_num = parseInt(time, 10);
let hours = Math.floor(sec_num / 3600)
let minutes = Math.floor((sec_num - (hours * 3600)) / 60)
let seconds = sec_num - (hours * 3600) - (minutes * 60)
let hours = Math.floor(sec_num / 3600);
let minutes = Math.floor((sec_num - (hours * 3600)) / 60);
let seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {
hours = '0' + hours
hours = '0' + hours;
}
if (minutes < 10) {
minutes = '0' + minutes
minutes = '0' + minutes;
}
if (seconds < 10) {
seconds = '0' + seconds
seconds = '0' + seconds;
}
return (hours !== '00' ? hours + ':' : '') + minutes + ':' + seconds
return (hours !== '00' ? hours + ':' : '') + minutes + ':' + seconds;
}
}
}
};
</script>

View File

@ -106,153 +106,165 @@
</style>
<script>
import DataTable from './components/DataTable'
import MediaToolbar from './station_media/MediaToolbar'
import Breadcrumb from './station_media/MediaBreadcrumb'
import FileUpload from './station_media/MediaFileUpload'
import NewDirectoryModal from './station_media/MediaNewDirectoryModal'
import MoveFilesModal from './station_media/MediaMoveFilesModal'
import RenameModal from './station_media/MediaRenameModal'
import EditModal from './station_media/MediaEditModal'
import { formatFileSize } from './station_media/utils'
import _ from 'lodash'
import DataTable from './components/DataTable';
import MediaToolbar from './station_media/MediaToolbar';
import Breadcrumb from './station_media/MediaBreadcrumb';
import FileUpload from './station_media/MediaFileUpload';
import NewDirectoryModal from './station_media/MediaNewDirectoryModal';
import MoveFilesModal from './station_media/MediaMoveFilesModal';
import RenameModal from './station_media/MediaRenameModal';
import EditModal from './station_media/MediaEditModal';
import { formatFileSize } from './station_media/utils';
import _ from 'lodash';
export default {
components: {
EditModal,
RenameModal,
MoveFilesModal,
NewDirectoryModal,
FileUpload,
MediaToolbar,
DataTable,
Breadcrumb
},
props: {
listUrl: String,
batchUrl: String,
uploadUrl: String,
listDirectoriesUrl: String,
mkdirUrl: String,
renameUrl: String,
editUrl: String,
initialPlaylists: Array,
customFields: Array
},
data () {
let fields = [
{ key: 'name', label: this.$gettext('Name'), sortable: true },
{ key: 'media_title', label: this.$gettext('Title'), sortable: true, selectable: true, visible: false },
{ key: 'media_artist', label: this.$gettext('Artist'), sortable: true, selectable: true, visible: false },
{ key: 'media_album', label: this.$gettext('Album'), sortable: true, selectable: true, visible: false },
{ key: 'media_length', label: this.$gettext('Length'), sortable: true, selectable: true, visible: true }
]
_.forEach(this.customFields.slice(), (field) => {
fields.push({
key: field.display_key,
label: field.label,
sortable: true,
selectable: true,
visible: false
})
})
fields.push(
{ key: 'size', label: this.$gettext('Size'), sortable: true, selectable: true, visible: true },
{
key: 'mtime',
label: this.$gettext('Modified'),
sortable: true,
formatter: (value, key, item) => {
if (!value) {
return ''
}
return moment.unix(value).format('lll')
},
selectable: true,
visible: true
export default {
components: {
EditModal,
RenameModal,
MoveFilesModal,
NewDirectoryModal,
FileUpload,
MediaToolbar,
DataTable,
Breadcrumb
},
{ key: 'playlists', label: this.$gettext('Playlists'), sortable: true, selectable: true, visible: true },
{ key: 'commands', label: this.$gettext('Actions'), sortable: false }
)
props: {
listUrl: String,
batchUrl: String,
uploadUrl: String,
listDirectoriesUrl: String,
mkdirUrl: String,
renameUrl: String,
editUrl: String,
initialPlaylists: Array,
customFields: Array
},
data () {
let fields = [
{ key: 'name', label: this.$gettext('Name'), sortable: true },
{ key: 'media_title', label: this.$gettext('Title'), sortable: true, selectable: true, visible: false },
{
key: 'media_artist',
label: this.$gettext('Artist'),
sortable: true,
selectable: true,
visible: false
},
{ key: 'media_album', label: this.$gettext('Album'), sortable: true, selectable: true, visible: false },
{ key: 'media_length', label: this.$gettext('Length'), sortable: true, selectable: true, visible: true }
];
return {
fields: fields,
selectedFiles: [],
currentDirectory: '',
searchPhrase: null
}
},
mounted () {
// Load directory from URL hash, if applicable.
let urlHash = decodeURIComponent(window.location.hash.substr(1).replace(/\+/g, '%20'))
_.forEach(this.customFields.slice(), (field) => {
fields.push({
key: field.display_key,
label: field.label,
sortable: true,
selectable: true,
visible: false
});
});
if (urlHash.substr(0, 9) === 'playlist:') {
window.location.hash = ''
this.filter(urlHash)
} else if (urlHash !== '') {
this.changeDirectory(urlHash)
}
},
computed: {
langAlbumArt () {
return this.$gettext('Album Art')
},
langRenameButton () {
return this.$gettext('Rename')
},
langEditButton () {
return this.$gettext('Edit')
},
langPlayPause () {
return this.$gettext('Play/Pause')
},
langPlaylistSelect () {
return this.$gettext('View tracks in playlist')
}
},
methods: {
formatFileSize (size) {
return formatFileSize(size)
},
onRowSelected (items) {
this.selectedFiles = _.map(items, 'name')
},
onRefreshed () {
this.$eventHub.$emit('refreshed')
},
onTriggerNavigate () {
this.$refs.datatable.navigate()
},
onTriggerRelist () {
this.$refs.datatable.relist()
},
playAudio (url) {
this.$eventHub.$emit('player_toggle', url)
},
changeDirectory (newDir) {
window.location.hash = newDir
fields.push(
{ key: 'size', label: this.$gettext('Size'), sortable: true, selectable: true, visible: true },
{
key: 'mtime',
label: this.$gettext('Modified'),
sortable: true,
formatter: (value, key, item) => {
if (!value) {
return '';
}
return moment.unix(value).format('lll');
},
selectable: true,
visible: true
},
{
key: 'playlists',
label: this.$gettext('Playlists'),
sortable: true,
selectable: true,
visible: true
},
{ key: 'commands', label: this.$gettext('Actions'), sortable: false }
);
this.currentDirectory = newDir
this.onTriggerNavigate()
},
filter (newFilter) {
this.$refs.datatable.setFilter(newFilter)
},
onFiltered (newFilter) {
this.searchPhrase = newFilter
},
rename (path) {
this.$refs.renameModal.open(path)
},
edit (recordUrl, albumArtUrl) {
this.$refs.editModal.open(recordUrl, albumArtUrl)
},
requestConfig (config) {
config.params.file = this.currentDirectory
return config
}
}
}
return {
fields: fields,
selectedFiles: [],
currentDirectory: '',
searchPhrase: null
};
},
mounted () {
// Load directory from URL hash, if applicable.
let urlHash = decodeURIComponent(window.location.hash.substr(1).replace(/\+/g, '%20'));
if (urlHash.substr(0, 9) === 'playlist:') {
window.location.hash = '';
this.filter(urlHash);
} else if (urlHash !== '') {
this.changeDirectory(urlHash);
}
},
computed: {
langAlbumArt () {
return this.$gettext('Album Art');
},
langRenameButton () {
return this.$gettext('Rename');
},
langEditButton () {
return this.$gettext('Edit');
},
langPlayPause () {
return this.$gettext('Play/Pause');
},
langPlaylistSelect () {
return this.$gettext('View tracks in playlist');
}
},
methods: {
formatFileSize (size) {
return formatFileSize(size);
},
onRowSelected (items) {
this.selectedFiles = _.map(items, 'name');
},
onRefreshed () {
this.$eventHub.$emit('refreshed');
},
onTriggerNavigate () {
this.$refs.datatable.navigate();
},
onTriggerRelist () {
this.$refs.datatable.relist();
},
playAudio (url) {
this.$eventHub.$emit('player_toggle', url);
},
changeDirectory (newDir) {
window.location.hash = newDir;
this.currentDirectory = newDir;
this.onTriggerNavigate();
},
filter (newFilter) {
this.$refs.datatable.setFilter(newFilter);
},
onFiltered (newFilter) {
this.searchPhrase = newFilter;
},
rename (path) {
this.$refs.renameModal.open(path);
},
edit (recordUrl, albumArtUrl) {
this.$refs.editModal.open(recordUrl, albumArtUrl);
},
requestConfig (config) {
config.params.file = this.currentDirectory;
return config;
}
}
};
</script>

View File

@ -1,7 +1,7 @@
<template>
<div>
<b-card no-body>
<b-card-header>
<b-card-header header-bg-variant="primary-dark">
<b-row class="align-items-center">
<b-col md="6">
<h2 class="card-title" v-translate>Playlists</h2>
@ -99,11 +99,11 @@
</template>
<script>
import DataTable from './components/DataTable'
import Schedule from './station_playlists/PlaylistSchedule'
import EditModal from './station_playlists/PlaylistEditModal'
import ReorderModal from './station_playlists/PlaylistReorderModal'
import axios from 'axios'
import DataTable from './components/DataTable';
import Schedule from './station_playlists/PlaylistSchedule';
import EditModal from './station_playlists/PlaylistEditModal';
import ReorderModal from './station_playlists/PlaylistReorderModal';
import axios from 'axios';
export default {
name: 'StationPlaylists',
@ -123,101 +123,101 @@
{ key: 'scheduling', label: this.$gettext('Scheduling'), sortable: false },
{ key: 'num_songs', label: this.$gettext('# Songs'), sortable: false }
]
}
};
},
computed: {
langAllPlaylistsTab () {
return this.$gettext('All Playlists')
return this.$gettext('All Playlists');
},
langScheduleViewTab () {
return this.$gettext('Schedule View')
return this.$gettext('Schedule View');
},
langMore () {
return this.$gettext('More')
return this.$gettext('More');
},
langReorderButton () {
return this.$gettext('Reorder')
return this.$gettext('Reorder');
}
},
mounted () {
moment.relativeTimeThreshold('ss', 1)
moment.relativeTimeThreshold('ss', 1);
moment.relativeTimeRounding(function (value) {
return Math.round(value * 10) / 10
})
return Math.round(value * 10) / 10;
});
},
methods: {
langToggleButton (record) {
return (record.is_enabled)
? this.$gettext('Disable')
: this.$gettext('Enable')
: this.$gettext('Enable');
},
formatTime (time) {
return moment(time).tz(this.stationTimeZone).format('LT')
return moment(time).tz(this.stationTimeZone).format('LT');
},
formatLength (length) {
return moment.duration(length, 'seconds').humanize()
return moment.duration(length, 'seconds').humanize();
},
formatType (record) {
if (!record.is_enabled) {
return this.$gettext('Disabled')
return this.$gettext('Disabled');
}
switch (record.type) {
case 'default':
return this.$gettext('General Rotation') + '<br>' + this.$gettext('Weight') + ': ' + record.weight
return this.$gettext('General Rotation') + '<br>' + this.$gettext('Weight') + ': ' + record.weight;
case 'once_per_x_songs':
let oncePerSongs = this.$gettext('Once per %{songs} Songs')
return this.$gettextInterpolate(oncePerSongs, { songs: record.play_per_songs })
let oncePerSongs = this.$gettext('Once per %{songs} Songs');
return this.$gettextInterpolate(oncePerSongs, { songs: record.play_per_songs });
case 'once_per_x_minutes':
let oncePerMinutes = this.$gettext('Once per %{minutes} Minutes')
return this.$gettextInterpolate(oncePerMinutes, { minutes: record.play_per_minutes })
let oncePerMinutes = this.$gettext('Once per %{minutes} Minutes');
return this.$gettextInterpolate(oncePerMinutes, { minutes: record.play_per_minutes });
case 'once_per_hour':
let oncePerHour = this.$gettext('Once per Hour (at %{minute})')
return this.$gettextInterpolate(oncePerHour, { minute: record.play_per_hour_minute })
let oncePerHour = this.$gettext('Once per Hour (at %{minute})');
return this.$gettextInterpolate(oncePerHour, { minute: record.play_per_hour_minute });
default:
return this.$gettext('Custom')
return this.$gettext('Custom');
}
},
relist () {
if (this.$refs.datatable) {
this.$refs.datatable.refresh()
this.$refs.datatable.refresh();
}
if (this.$refs.schedule) {
this.$refs.schedule.refresh()
this.$refs.schedule.refresh();
}
},
doCreate () {
this.$refs.editModal.create()
this.$refs.editModal.create();
},
doEdit (url) {
this.$refs.editModal.edit(url)
this.$refs.editModal.edit(url);
},
doReorder (url) {
this.$refs.reorderModal.open(url)
this.$refs.reorderModal.open(url);
},
doToggle (url) {
notify('<b>' + this.$gettext('Applying changes...') + '</b>', 'warning', {
delay: 3000
})
});
axios.put(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success')
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist()
this.relist();
}).catch((err) => {
console.error(err)
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger')
notify('<b>' + err.response.message + '</b>', 'danger');
}
})
});
},
doDelete (url) {
let buttonText = this.$gettext('Delete')
let buttonConfirmText = this.$gettext('Delete playlist?')
let buttonText = this.$gettext('Delete');
let buttonConfirmText = this.$gettext('Delete playlist?');
swal({
title: buttonConfirmText,
@ -226,18 +226,18 @@
}).then((value) => {
if (value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success')
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist()
this.relist();
}).catch((err) => {
console.error(err)
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger')
notify('<b>' + err.response.message + '</b>', 'danger');
}
})
});
}
})
});
}
}
}
};
</script>

View File

@ -0,0 +1,102 @@
<template>
<div>
<b-card no-body>
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title" v-translate>Streamer/DJ Accounts</h2>
</b-card-header>
<b-card-body body-class="card-padding-sm">
<b-button variant="outline-primary" @click.prevent="doCreate">
<i class="material-icons" aria-hidden="true">add</i>
<translate>Add Streamer</translate>
</b-button>
</b-card-body>
<div class="table-responsive table-responsive-lg">
<data-table ref="datatable" id="station_streamers" :show-toolbar="false" :fields="fields"
:api-url="listUrl">
<template v-slot:cell(actions)="row">
<b-button-group size="sm">
<b-button size="sm" variant="primary" @click.prevent="doEdit(row.item.links_self)">
<translate>Edit</translate>
</b-button>
<b-button size="sm" variant="default" @click.prevent="doShowBroadcasts(row.item.links_broadcasts)">
<translate>Broadcasts</translate>
</b-button>
<b-button size="sm" variant="danger" @click.prevent="doDelete(row.item.links_self)">
<translate>Delete</translate>
</b-button>
</b-button-group>
</template>
</data-table>
</div>
</b-card>
<edit-modal ref="editModal" :create-url="listUrl" @relist="relist"></edit-modal>
<broadcasts-modal ref="broadcastsModal"></broadcasts-modal>
</div>
</template>
<script>
import DataTable from './components/DataTable';
import axios from 'axios';
import EditModal from './station_streamers/StreamerEditModal';
import BroadcastsModal from './station_streamers/StreamerBroadcastsModal';
export default {
name: 'StationStreamers',
components: { EditModal, BroadcastsModal, DataTable },
props: {
listUrl: String,
filesUrl: String,
stationTimeZone: String
},
data () {
return {
fields: [
{ key: 'actions', label: this.$gettext('Actions'), sortable: false },
{ key: 'streamer_username', label: this.$gettext('Username'), sortable: false },
{ key: 'display_name', label: this.$gettext('Display Name'), sortable: false },
{ key: 'comments', label: this.$gettext('Notes'), sortable: false }
]
};
},
computed: {},
methods: {
relist () {
this.$refs.datatable.refresh();
},
doCreate () {
this.$refs.editModal.create();
},
doEdit (url) {
this.$refs.editModal.edit(url);
},
doShowBroadcasts (url) {
this.$refs.broadcastsModal.open(url);
},
doDelete (url) {
let buttonText = this.$gettext('Delete');
let buttonConfirmText = this.$gettext('Delete streamer?');
swal({
title: buttonConfirmText,
buttons: [true, buttonText],
dangerMode: true
}).then((value) => {
if (value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();
}).catch((err) => {
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger');
}
});
}
});
}
}
};
</script>

View File

@ -28,45 +28,45 @@
</template>
<script>
import mixer from './webcaster/mixer.vue'
import microphone from './webcaster/microphone.vue'
import playlist from './webcaster/playlist.vue'
import settings from './webcaster/settings.vue'
import mixer from './webcaster/mixer.vue';
import microphone from './webcaster/microphone.vue';
import playlist from './webcaster/playlist.vue';
import settings from './webcaster/settings.vue';
import stream from './webcaster/stream.js'
import stream from './webcaster/stream.js';
export default {
data: function () {
return {
'stream': stream
}
},
components: {
mixer,
microphone,
playlist,
settings
},
props: {
stationName: String,
libUrls: Array,
baseUri: String
},
provide: function () {
return {
getStream: this.getStream,
resumeStream: this.resumeStream
}
},
methods: {
getStream: function () {
this.stream.init()
export default {
data: function () {
return {
'stream': stream
};
},
components: {
mixer,
microphone,
playlist,
settings
},
props: {
stationName: String,
libUrls: Array,
baseUri: String
},
provide: function () {
return {
getStream: this.getStream,
resumeStream: this.resumeStream
};
},
methods: {
getStream: function () {
this.stream.init();
return this.stream
},
resumeStream: function () {
this.stream.resumeContext()
}
}
}
return this.stream;
},
resumeStream: function () {
this.stream.resumeContext();
}
}
};
</script>

View File

@ -53,6 +53,7 @@
<b-table ref="table" show-empty striped hover :selectable="selectable" :api-url="apiUrl" :per-page="perPage"
:current-page="currentPage" @row-selected="onRowSelected" :items="loadItems" :fields="visibleFields"
:empty-text="langNoRecords" :empty-filtered-text="langNoRecords"
tbody-tr-class="align-middle" thead-tr-class="align-middle" selected-variant=""
:filter="filter" @filtered="onFiltered" @refreshed="onRefreshed">
<template v-slot:head(selected)="data">
@ -65,6 +66,27 @@
<template v-else>check_box_outline_blank</template>
</i>
</template>
<template v-slot:table-busy>
<div role="alert" aria-live="polite">
<div class="text-center my-2">
<div class="progress-circular progress-circular-primary mx-auto mb-3">
<div class="progress-circular-wrapper">
<div class="progress-circular-inner">
<div class="progress-circular-left">
<div class="progress-circular-spinner"></div>
</div>
<div class="progress-circular-gap"></div>
<div class="progress-circular-right">
<div class="progress-circular-spinner"></div>
</div>
</div>
</div>
</div>
{{ langLoading }}
</div>
</div>
</template>
<slot v-for="(_, name) in $slots" :name="name" :slot="name"/>
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
<slot :name="name" v-bind="slotData"/>
@ -93,9 +115,9 @@
</style>
<script>
import axios from 'axios'
import store from 'store'
import _ from 'lodash'
import axios from 'axios';
import store from 'store';
import _ from 'lodash';
export default {
name: 'DataTable',
@ -137,186 +159,194 @@
currentPage: 1,
totalRows: 0,
flushCache: false
}
};
},
mounted () {
this.loadStoredSettings()
this.loadStoredSettings();
},
computed: {
langRefreshTooltip () {
return this.$gettext('Refresh rows')
return this.$gettext('Refresh rows');
},
langPerPageTooltip () {
return this.$gettext('Rows per page')
return this.$gettext('Rows per page');
},
langSelectFieldsTooltip () {
return this.$gettext('Select displayed fields')
return this.$gettext('Select displayed fields');
},
langSelectAll () {
return this.$gettext('Select all visible rows')
return this.$gettext('Select all visible rows');
},
langSelectRow () {
return this.$gettext('Select this row')
return this.$gettext('Select this row');
},
langSearch () {
return this.$gettext('Search')
return this.$gettext('Search');
},
langNoRecords () {
return this.$gettext('No records to display.');
},
langLoading () {
return this.$gettext('Loading...');
},
visibleFields () {
let fields = this.fields.slice()
let fields = this.fields.slice();
if (this.selectable) {
fields.unshift({ key: 'selected', label: '', sortable: false })
fields.unshift({ key: 'selected', label: '', sortable: false });
}
if (!this.selectFields) {
return fields
return fields;
}
return _.filter(fields, (field) => {
let isSelectable = _.defaultTo(field.selectable, false)
let isSelectable = _.defaultTo(field.selectable, false);
if (!isSelectable) {
return true
return true;
}
return _.defaultTo(field.visible, true)
})
return _.defaultTo(field.visible, true);
});
},
selectableFields () {
return _.filter(this.fields.slice(), (field) => {
return _.defaultTo(field.selectable, false)
})
return _.defaultTo(field.selectable, false);
});
},
showPagination () {
return this.paginated && this.perPage !== 0
return this.paginated && this.perPage !== 0;
},
perPageLabel () {
return this.getPerPageLabel(this.perPage)
return this.getPerPageLabel(this.perPage);
}
},
methods: {
loadStoredSettings () {
if (store.enabled && store.get(this.storeKey) !== undefined) {
let settings = store.get(this.storeKey)
let settings = store.get(this.storeKey);
this.perPage = _.defaultTo(settings.perPage, this.defaultPerPage)
this.perPage = _.defaultTo(settings.perPage, this.defaultPerPage);
_.forEach(this.selectableFields, (field) => {
field.visible = _.includes(settings.visibleFields, field.key)
})
field.visible = _.includes(settings.visibleFields, field.key);
});
}
},
storeSettings () {
if (!store.enabled) {
return
return;
}
let settings = {
'perPage': this.perPage,
'visibleFields': _.map(this.visibleFields, 'key')
}
store.set(this.storeKey, settings)
};
store.set(this.storeKey, settings);
},
getPerPageLabel (num) {
return (num === 0) ? 'All' : num.toString()
return (num === 0) ? 'All' : num.toString();
},
setPerPage (num) {
this.perPage = num
this.storeSettings()
this.perPage = num;
this.storeSettings();
},
onClickRefresh (e) {
if (e.shiftKey) {
this.relist()
this.relist();
} else {
this.refresh()
this.refresh();
}
},
onRefreshed () {
this.$emit('refreshed')
this.$emit('refreshed');
},
refresh () {
this.$refs.table.refresh()
this.$refs.table.refresh();
},
navigate () {
this.filter = null
this.currentPage = 1
this.flushCache = true
this.refresh()
this.filter = null;
this.currentPage = 1;
this.flushCache = true;
this.refresh();
},
relist () {
this.filter = null
this.flushCache = true
this.refresh()
this.filter = null;
this.flushCache = true;
this.refresh();
},
setFilter (newTerm) {
this.currentPage = 1
this.filter = newTerm
this.currentPage = 1;
this.filter = newTerm;
},
loadItems (ctx, callback) {
let queryParams = {}
let queryParams = {};
if (this.paginated) {
queryParams.rowCount = ctx.perPage
queryParams.current = ctx.currentPage
queryParams.rowCount = ctx.perPage;
queryParams.current = ctx.currentPage;
} else {
queryParams.rowCount = 0;
}
if (this.flushCache) {
queryParams.flushCache = true
queryParams.flushCache = true;
}
if (typeof ctx.filter === 'string') {
queryParams.searchPhrase = ctx.filter
queryParams.searchPhrase = ctx.filter;
}
if ('' !== ctx.sortBy) {
queryParams.sort = ctx.sortBy
queryParams.sortOrder = (ctx.sortDesc) ? 'DESC' : 'ASC'
queryParams.sort = ctx.sortBy;
queryParams.sortOrder = (ctx.sortDesc) ? 'DESC' : 'ASC';
}
let requestConfig = { params: queryParams }
let requestConfig = { params: queryParams };
if (typeof this.requestConfig === 'function') {
requestConfig = this.requestConfig(requestConfig)
requestConfig = this.requestConfig(requestConfig);
}
axios.get(ctx.apiUrl, requestConfig).then((resp) => {
this.flushCache = false
this.totalRows = resp.data.total
this.flushCache = false;
this.totalRows = resp.data.total;
let rows = resp.data.rows
let rows = resp.data.rows;
if (typeof this.requestProcess === 'function') {
rows = this.requestProcess(rows)
rows = this.requestProcess(rows);
}
callback(rows)
callback(rows);
}).catch((err) => {
this.flushCache = false
this.totalRows = 0
this.flushCache = false;
this.totalRows = 0;
console.error(err.data.message)
callback([])
})
console.error(err.data.message);
callback([]);
});
},
onRowSelected (items) {
if (this.perPage === 0) {
this.allSelected = items.length === this.totalRows
this.allSelected = items.length === this.totalRows;
} else {
this.allSelected = items.length === this.perPage
this.allSelected = items.length === this.perPage;
}
this.selected = items
this.$emit('row-selected', items)
this.selected = items;
this.$emit('row-selected', items);
},
toggleSelected () {
if (this.allSelected) {
this.$refs.table.clearSelected()
this.allSelected = false
this.$refs.table.clearSelected();
this.allSelected = false;
} else {
this.$refs.table.selectAllRows()
this.allSelected = true
this.$refs.table.selectAllRows();
this.allSelected = true;
}
},
onFiltered (filter) {
this.$emit('filtered', filter)
this.$emit('filtered', filter);
}
}
}
};
</script>

View File

@ -3,37 +3,37 @@
</template>
<script>
import _ from 'lodash'
import _ from 'lodash';
export default {
props: ['value'],
computed: {
timeCode: {
get () {
return this.parseTimeCode(this.value)
export default {
props: ['value'],
computed: {
timeCode: {
get () {
return this.parseTimeCode(this.value);
},
set (newValue) {
this.$emit('input', this.convertToTimeCode(newValue));
}
}
},
set (newValue) {
this.$emit('input', this.convertToTimeCode(newValue))
}
}
},
methods: {
parseTimeCode (timeCode) {
if (timeCode !== '' && timeCode !== null) {
timeCode = _.padStart(timeCode, 4, '0')
return timeCode.substr(0, 2) + ':' + timeCode.substr(2)
}
methods: {
parseTimeCode (timeCode) {
if (timeCode !== '' && timeCode !== null) {
timeCode = _.padStart(timeCode, 4, '0');
return timeCode.substr(0, 2) + ':' + timeCode.substr(2);
}
return null
},
convertToTimeCode (time) {
if (_.isEmpty(time)) {
return null
}
return null;
},
convertToTimeCode (time) {
if (_.isEmpty(time)) {
return null;
}
let timeParts = time.split(':')
return (100 * parseInt(timeParts[0], 10)) + parseInt(timeParts[1], 10)
}
}
}
let timeParts = time.split(':');
return (100 * parseInt(timeParts[0], 10)) + parseInt(timeParts[1], 10);
}
}
};
</script>

View File

@ -16,32 +16,32 @@
},
computed: {
directoryParts () {
let dirParts = []
let dirParts = [];
if (this.currentDirectory === '') {
return dirParts
return dirParts;
}
let builtDir = ''
let dirSegments = this.currentDirectory.split('/')
let builtDir = '';
let dirSegments = this.currentDirectory.split('/');
dirSegments.forEach((part) => {
if (builtDir === '') {
builtDir += part
builtDir += part;
} else {
builtDir += '/' + part
builtDir += '/' + part;
}
dirParts.push({ dir: builtDir, display: part })
})
dirParts.push({ dir: builtDir, display: part });
});
return dirParts
return dirParts;
}
},
methods: {
changeDirectory (newDir) {
this.$emit('change-directory', newDir)
this.$emit('change-directory', newDir);
}
}
}
};
</script>

View File

@ -21,14 +21,14 @@
</b-modal>
</template>
<script>
import { validationMixin } from 'vuelidate'
import axios from 'axios'
import required from 'vuelidate/src/validators/required'
import _ from 'lodash'
import MediaFormBasicInfo from './form/MediaFormBasicInfo'
import MediaFormAlbumArt from './form/MediaFormAlbumArt'
import MediaFormCustomFields from './form/MediaFormCustomFields'
import MediaFormAdvancedSettings from './form/MediaFormAdvancedSettings'
import { validationMixin } from 'vuelidate';
import axios from 'axios';
import required from 'vuelidate/src/validators/required';
import _ from 'lodash';
import MediaFormBasicInfo from './form/MediaFormBasicInfo';
import MediaFormAlbumArt from './form/MediaFormAlbumArt';
import MediaFormCustomFields from './form/MediaFormCustomFields';
import MediaFormAdvancedSettings from './form/MediaFormAdvancedSettings';
export default {
name: 'EditModal',
@ -44,7 +44,7 @@
albumArtUrl: null,
songLength: null,
form: this.getBlankForm()
}
};
},
validations: {
form: {
@ -67,16 +67,16 @@
},
computed: {
langTitle () {
return this.$gettext('Edit Media')
return this.$gettext('Edit Media');
}
},
methods: {
getBlankForm () {
let customFields = {}
let customFields = {};
_.forEach(this.customFields.slice(), (field) => {
customFields[field.key] = null
})
customFields[field.key] = null;
});
return {
path: null,
@ -92,19 +92,19 @@
cue_in: null,
cue_out: null,
custom_fields: customFields
}
};
},
open (recordUrl, albumArtUrl) {
this.loading = true
this.$refs.modal.show()
this.loading = true;
this.$refs.modal.show();
this.albumArtUrl = albumArtUrl
this.recordUrl = recordUrl
this.albumArtUrl = albumArtUrl;
this.recordUrl = recordUrl;
axios.get(recordUrl).then((resp) => {
let d = resp.data
let d = resp.data;
this.songLength = d.length_text
this.songLength = d.length_text;
this.form = {
path: d.path,
title: d.title,
@ -119,49 +119,49 @@
cue_in: d.cue_in,
cue_out: d.cue_out,
custom_fields: {}
}
};
_.forEach(this.customFields.slice(), (field) => {
this.form.custom_fields[field.key] = _.defaultTo(d.custom_fields[field.key], null)
})
this.form.custom_fields[field.key] = _.defaultTo(d.custom_fields[field.key], null);
});
this.loading = false
this.loading = false;
}).catch((err) => {
console.log(err)
this.close()
})
console.log(err);
this.close();
});
},
close () {
this.loading = false
this.albumArtUrl = null
this.loading = false;
this.albumArtUrl = null;
this.form = this.getBlankForm()
this.form = this.getBlankForm();
this.$v.form.$reset()
this.$refs.modal.hide()
this.$v.form.$reset();
this.$refs.modal.hide();
},
doEdit () {
this.$v.form.$touch()
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return
return;
}
axios.put(this.recordUrl, this.form).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.')
notify('<b>' + notifyMessage + '</b>', 'success', false)
let notifyMessage = this.$gettext('Changes saved.');
notify('<b>' + notifyMessage + '</b>', 'success', false);
this.$emit('relist')
this.close()
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err)
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.')
notify('<b>' + notifyMessage + '</b>', 'danger', false)
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.$emit('relist')
this.close()
})
this.$emit('relist');
this.close();
});
}
}
}
};
</script>

View File

@ -27,8 +27,8 @@
</template>
<script>
import { formatFileSize } from './utils.js'
import Flow from '@flowjs/flow.js'
import { formatFileSize } from './utils.js';
import Flow from '@flowjs/flow.js';
export default {
name: 'FileUpload',
@ -41,7 +41,7 @@
return {
flow: null,
files: []
}
};
},
mounted () {
this.flow = new Flow({
@ -50,7 +50,7 @@
return {
file: this.currentDirectory,
searchPhrase: this.searchPhrase
}
};
},
headers: {
'Accept': 'application/json'
@ -58,51 +58,51 @@
withCredentials: true,
allowDuplicateUploads: true,
fileParameterName: 'file_data'
})
});
this.flow.assignBrowse(document.getElementById('file_browse_target'))
this.flow.assignDrop(document.getElementById('file_drop_target'))
this.flow.assignBrowse(document.getElementById('file_browse_target'));
this.flow.assignDrop(document.getElementById('file_drop_target'));
this.flow.on('fileAdded', (file, event) => {
file.progress_percent = 0
file.is_completed = false
file.error = null
file.is_visible = true
file.progress_percent = 0;
file.is_completed = false;
file.error = null;
file.is_visible = true;
this.files.push(file)
return true
})
this.files.push(file);
return true;
});
this.flow.on('filesSubmitted', (array, event) => {
this.flow.upload()
})
this.flow.upload();
});
this.flow.on('fileProgress', (file) => {
file.progress_percent = file.progress() * 100
})
file.progress_percent = file.progress() * 100;
});
this.flow.on('fileSuccess', (file, message) => {
file.is_completed = true
})
file.is_completed = true;
});
this.flow.on('fileError', (file, message) => {
let messageJson = JSON.parse(message)
file.error = messageJson.message
})
let messageJson = JSON.parse(message);
file.error = messageJson.message;
});
this.flow.on('error', (message, file, chunk) => {
console.error(message, file, chunk)
})
console.error(message, file, chunk);
});
this.flow.on('complete', () => {
this.files = []
this.$emit('relist')
})
this.files = [];
this.$emit('relist');
});
},
methods: {
formatFileSize (bytes) {
return formatFileSize(bytes)
return formatFileSize(bytes);
}
}
}
};
</script>

View File

@ -41,8 +41,8 @@
</b-modal>
</template>
<script>
import DataTable from '../components/DataTable.vue'
import axios from 'axios'
import DataTable from '../components/DataTable.vue';
import axios from 'axios';
export default {
name: 'MoveFilesModal',
@ -60,20 +60,20 @@
fields: [
{ key: 'directory', label: this.$gettext('Directory'), sortable: false }
]
}
};
},
computed: {
langHeader () {
let headerText = this.$gettext('Move %{ num } File(s) to')
return this.$gettextInterpolate(headerText, { num: this.selectedFiles.length })
let headerText = this.$gettext('Move %{ num } File(s) to');
return this.$gettextInterpolate(headerText, { num: this.selectedFiles.length });
}
},
methods: {
close () {
this.dirHistory = []
this.destinationDirectory = ''
this.dirHistory = [];
this.destinationDirectory = '';
this.$refs.modal.hide()
this.$refs.modal.hide();
},
doMove () {
this.selectedFiles.length && axios.put(this.batchUrl, {
@ -81,41 +81,41 @@
'files': this.selectedFiles,
'directory': this.destinationDirectory
}).then((resp) => {
let notifyMessage = this.$gettext('Files moved:')
notify('<b>' + notifyMessage + '</b><br>' + this.selectedFiles.join('<br>'), 'success', false)
let notifyMessage = this.$gettext('Files moved:');
notify('<b>' + notifyMessage + '</b><br>' + this.selectedFiles.join('<br>'), 'success', false);
this.close()
this.$emit('relist')
this.close();
this.$emit('relist');
}).catch((err) => {
console.error(err)
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.')
notify('<b>' + notifyMessage + '</b>', 'danger', false)
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.close()
this.$emit('relist')
})
this.close();
this.$emit('relist');
});
},
enterDirectory (path) {
this.dirHistory.push(path)
this.destinationDirectory = path
this.dirHistory.push(path);
this.destinationDirectory = path;
this.$refs.datatable.refresh()
this.$refs.datatable.refresh();
},
pageBack: function (e) {
e.preventDefault()
e.preventDefault();
this.dirHistory.pop()
this.destinationDirectory = this.dirHistory.slice(-1)[0]
this.dirHistory.pop();
this.destinationDirectory = this.dirHistory.slice(-1)[0];
this.$refs.datatable.refresh()
this.$refs.datatable.refresh();
},
requestConfig (config) {
config.params.file = this.destinationDirectory
config.params.csrf = this.csrf
config.params.file = this.destinationDirectory;
config.params.csrf = this.csrf;
return config
return config;
}
}
}
};
</script>

View File

@ -23,9 +23,9 @@
</b-modal>
</template>
<script>
import { validationMixin } from 'vuelidate'
import { required } from 'vuelidate/lib/validators'
import axios from 'axios'
import { validationMixin } from 'vuelidate';
import { required } from 'vuelidate/lib/validators';
import axios from 'axios';
export default {
name: 'NewDirectoryModal',
@ -37,7 +37,7 @@
data () {
return {
newDirectory: null
}
};
},
validations: {
newDirectory: {
@ -46,40 +46,40 @@
},
computed: {
langNewDirectory () {
return this.$gettext('New Directory')
return this.$gettext('New Directory');
}
},
methods: {
close () {
this.newDirectory = null
this.$v.$reset()
this.$refs.modal.hide()
this.newDirectory = null;
this.$v.$reset();
this.$refs.modal.hide();
},
doMkdir () {
this.$v.$touch()
this.$v.$touch();
if (this.$v.$anyError) {
return
return;
}
axios.post(this.mkdirUrl, {
name: this.newDirectory,
file: this.currentDirectory
}).then((resp) => {
let notifyMessage = this.$gettext('New directory created.')
notify('<b>' + notifyMessage + '</b>', 'success', false)
let notifyMessage = this.$gettext('New directory created.');
notify('<b>' + notifyMessage + '</b>', 'success', false);
this.$emit('relist')
this.close()
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err)
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.')
notify('<b>' + notifyMessage + '</b>', 'danger', false)
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.$emit('relist')
this.close()
})
this.$emit('relist');
this.close();
});
}
}
}
};
</script>

View File

@ -23,9 +23,9 @@
</b-modal>
</template>
<script>
import { validationMixin } from 'vuelidate'
import { required } from 'vuelidate/lib/validators'
import axios from 'axios'
import { validationMixin } from 'vuelidate';
import { required } from 'vuelidate/lib/validators';
import axios from 'axios';
export default {
name: 'RenameModal',
@ -39,7 +39,7 @@
file: null,
newPath: null
}
}
};
},
validations: {
form: {
@ -50,36 +50,36 @@
},
computed: {
langRenameFile () {
return this.$gettext('Rename File/Directory')
return this.$gettext('Rename File/Directory');
}
},
methods: {
open (filePath) {
this.form.file = filePath
this.form.newPath = filePath
this.form.file = filePath;
this.form.newPath = filePath;
this.$refs.modal.show()
this.$refs.modal.show();
},
close () {
this.$v.form.$reset()
this.$refs.modal.hide()
this.$v.form.$reset();
this.$refs.modal.hide();
},
doRename () {
this.$v.form.$touch()
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return
return;
}
axios.put(this.renameUrl, this.form).then((resp) => {
this.$refs.modal.hide()
this.$emit('relist')
this.$refs.modal.hide();
this.$emit('relist');
}).catch((err) => {
console.error(err)
console.error(err);
this.$refs.modal.hide()
this.$emit('relist')
})
this.$refs.modal.hide();
this.$emit('relist');
});
}
}
}
};
</script>

View File

@ -63,7 +63,7 @@
</div>
</template>
<script>
import axios from 'axios'
import axios from 'axios';
export default {
name: 'station-media-toolbar',
@ -78,41 +78,41 @@
playlists: this.initialPlaylists,
checkedPlaylists: [],
newPlaylist: ''
}
};
},
watch: {
newPlaylist (text) {
if (text !== '') {
if (!this.checkedPlaylists.includes('new')) {
this.checkedPlaylists.push('new')
this.checkedPlaylists.push('new');
}
}
}
},
computed: {
langPlaylistDropdown () {
return this.$gettext('Set or clear playlists from the selected media')
return this.$gettext('Set or clear playlists from the selected media');
},
langNewPlaylist () {
return this.$gettext('New Playlist')
return this.$gettext('New Playlist');
},
langQueue () {
return this.$gettext('Queue the selected media to play next')
return this.$gettext('Queue the selected media to play next');
},
langErrors () {
return this.$gettext('The request could not be processed.')
return this.$gettext('The request could not be processed.');
},
newPlaylistIsChecked () {
return this.newPlaylist !== ''
return this.newPlaylist !== '';
}
},
methods: {
doQueue (e) {
this.doBatch('queue', this.$gettext('Files queued for playback:'))
this.doBatch('queue', this.$gettext('Files queued for playback:'));
},
doDelete (e) {
let buttonText = this.$gettext('Delete')
let buttonConfirmText = this.$gettext('Delete %{ num } media file(s)?')
let buttonText = this.$gettext('Delete');
let buttonConfirmText = this.$gettext('Delete %{ num } media file(s)?');
swal({
title: this.$gettextInterpolate(buttonConfirmText, { num: this.selectedFiles.length }),
@ -120,13 +120,13 @@
dangerMode: true
}).then((value) => {
if (value) {
this.doBatch('delete', this.$gettext('Files removed:'))
this.doBatch('delete', this.$gettext('Files removed:'));
}
})
});
},
doBatch (action, notifyMessage) {
if (this.selectedFiles.length) {
this.notifyPending()
this.notifyPending();
axios.put(this.batchUrl, {
'do': action,
@ -134,30 +134,30 @@
'file': this.currentDirectory
}).then((resp) => {
if (resp.data.success) {
notify('<b>' + notifyMessage + '</b><br>' + this.selectedFiles.join('<br>'), 'success', false)
notify('<b>' + notifyMessage + '</b><br>' + this.selectedFiles.join('<br>'), 'success', false);
} else {
notify('<b>' + this.langErrors + '</b><br>' + resp.data.errors.join('<br>'), 'danger')
notify('<b>' + this.langErrors + '</b><br>' + resp.data.errors.join('<br>'), 'danger');
}
this.$emit('relist')
this.$emit('relist');
}).catch((err) => {
this.handleError(err)
})
this.handleError(err);
});
} else {
this.notifyNoFiles()
this.notifyNoFiles();
}
},
clearPlaylists () {
this.checkedPlaylists = []
this.newPlaylist = ''
this.checkedPlaylists = [];
this.newPlaylist = '';
this.setPlaylists()
this.setPlaylists();
},
setPlaylists () {
this.$refs.setPlaylistsDropdown.hide()
this.$refs.setPlaylistsDropdown.hide();
if (this.selectedFiles.length) {
this.notifyPending()
this.notifyPending();
axios.put(this.batchUrl, {
'do': 'playlist',
@ -168,42 +168,42 @@
}).then((resp) => {
if (resp.data.success) {
if (resp.data.record) {
this.playlists.push(resp.data.record)
this.playlists.push(resp.data.record);
}
let notifyMessage = (this.checkedPlaylists.length > 0)
? this.$gettext('Playlists updated for selected files:')
: this.$gettext('Playlists cleared for selected files:')
notify('<b>' + notifyMessage + '</b><br>' + this.selectedFiles.join('<br>'), 'success')
: this.$gettext('Playlists cleared for selected files:');
notify('<b>' + notifyMessage + '</b><br>' + this.selectedFiles.join('<br>'), 'success');
this.checkedPlaylists = []
this.newPlaylist = ''
this.checkedPlaylists = [];
this.newPlaylist = '';
} else {
notify(resp.data.errors.join('<br>'), 'danger')
notify(resp.data.errors.join('<br>'), 'danger');
}
this.$emit('relist')
this.$emit('relist');
}).catch((err) => {
this.handleError(err)
})
this.handleError(err);
});
} else {
this.notifyNoFiles()
this.notifyNoFiles();
}
},
notifyPending () {
notify('<b>' + this.$gettext('Applying changes...') + '</b>', 'warning', {
delay: 3000
})
});
},
notifyNoFiles () {
notify('<b>' + this.$gettext('No files selected.') + '</b>', 'danger')
notify('<b>' + this.$gettext('No files selected.') + '</b>', 'danger');
},
handleError (err) {
console.error(err)
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger')
notify('<b>' + err.response.message + '</b>', 'danger');
}
}
}
}
};
</script>

View File

@ -89,8 +89,8 @@
},
computed: {
langTitle () {
return this.$gettext('Advanced Playback Settings')
return this.$gettext('Advanced Playback Settings');
}
}
}
};
</script>

View File

@ -24,7 +24,7 @@
</template>
<script>
import axios from 'axios'
import axios from 'axios';
export default {
name: 'MediaFormAlbumArt',
@ -35,45 +35,45 @@
return {
artFile: null,
albumArtSrc: null
}
};
},
computed: {
langTitle () {
return this.$gettext('Album Art')
return this.$gettext('Album Art');
}
},
watch: {
albumArtUrl: {
immediate: true,
handler (newVal, oldVal) {
this.albumArtSrc = newVal
this.albumArtSrc = newVal;
}
}
},
methods: {
uploadNewArt () {
let formData = new FormData()
formData.append('art', this.artFile)
let formData = new FormData();
formData.append('art', this.artFile);
axios.post(this.albumArtUrl, formData).then((resp) => {
this.reloadArt()
this.reloadArt();
}).catch((err) => {
console.log(err)
this.reloadArt()
})
console.log(err);
this.reloadArt();
});
},
deleteArt () {
axios.delete(this.albumArtUrl).then((resp) => {
this.reloadArt()
this.reloadArt();
}).catch((err) => {
console.log(err)
this.reloadArt()
})
console.log(err);
this.reloadArt();
});
},
reloadArt () {
this.artFile = null
this.albumArtSrc = this.albumArtUrl + '?' + Math.floor(Date.now() / 1000)
this.artFile = null;
this.albumArtSrc = this.albumArtUrl + '?' + Math.floor(Date.now() / 1000);
}
}
}
};
</script>

View File

@ -66,8 +66,8 @@
},
computed: {
langTitle () {
return this.$gettext('Basic Information')
return this.$gettext('Basic Information');
}
}
}
};
</script>

View File

@ -23,8 +23,8 @@
},
computed: {
langTitle () {
return this.$gettext('Custom Fields')
return this.$gettext('Custom Fields');
}
}
}
};
</script>

View File

@ -23,169 +23,169 @@
</template>
<script>
import axios from 'axios'
import { validationMixin } from 'vuelidate'
import required from 'vuelidate/src/validators/required'
import FormBasicInfo from './form/PlaylistFormBasicInfo'
import FormSource from './form/PlaylistFormSource'
import FormSchedule from './form/PlaylistFormSchedule'
import axios from 'axios';
import { validationMixin } from 'vuelidate';
import required from 'vuelidate/src/validators/required';
import FormBasicInfo from './form/PlaylistFormBasicInfo';
import FormSource from './form/PlaylistFormSource';
import FormSchedule from './form/PlaylistFormSchedule';
export default {
name: 'EditModal',
components: { FormSchedule, FormSource, FormBasicInfo },
mixins: [validationMixin],
props: {
createUrl: String,
stationTimeZone: String
},
data () {
return {
loading: true,
editUrl: null,
form: {}
}
},
computed: {
langTitle () {
return this.isEditMode
? this.$gettext('Edit Playlist')
: this.$gettext('Add Playlist')
},
isEditMode () {
return this.editUrl !== null
}
},
validations: {
form: {
'name': { required },
'is_enabled': { required },
'weight': { required },
'type': { required },
'source': { required },
'order': { required },
'remote_url': {},
'remote_type': {},
'remote_buffer': {},
'is_jingle': {},
'play_per_songs': {},
'play_per_minutes': {},
'play_per_hour_minute': {},
'include_in_requests': {},
'include_in_automation': {},
'backend_options': {},
'schedule_items': {
$each: {
'start_time': { required },
'end_time': { required },
'start_date': {},
'end_date': {},
'days': {}
}
export default {
name: 'EditModal',
components: { FormSchedule, FormSource, FormBasicInfo },
mixins: [validationMixin],
props: {
createUrl: String,
stationTimeZone: String
},
data () {
return {
loading: true,
editUrl: null,
form: {}
};
},
computed: {
langTitle () {
return this.isEditMode
? this.$gettext('Edit Playlist')
: this.$gettext('Add Playlist');
},
isEditMode () {
return this.editUrl !== null;
}
},
validations: {
form: {
'name': { required },
'is_enabled': { required },
'weight': { required },
'type': { required },
'source': { required },
'order': { required },
'remote_url': {},
'remote_type': {},
'remote_buffer': {},
'is_jingle': {},
'play_per_songs': {},
'play_per_minutes': {},
'play_per_hour_minute': {},
'include_in_requests': {},
'include_in_automation': {},
'backend_options': {},
'schedule_items': {
$each: {
'start_time': { required },
'end_time': { required },
'start_date': {},
'end_date': {},
'days': {}
}
}
}
},
methods: {
resetForm () {
this.form = {
'name': '',
'is_enabled': true,
'weight': 3,
'type': 'default',
'source': 'songs',
'order': 'shuffle',
'remote_url': null,
'remote_type': 'stream',
'remote_buffer': 0,
'is_jingle': false,
'play_per_songs': 0,
'play_per_minutes': 0,
'play_per_hour_minute': 0,
'include_in_requests': true,
'include_in_automation': false,
'backend_options': [],
'schedule_items': []
};
},
create () {
this.resetForm();
this.loading = false;
this.editUrl = null;
this.$refs.modal.show();
},
edit (recordUrl) {
this.resetForm();
this.loading = true;
this.editUrl = recordUrl;
this.$refs.modal.show();
axios.get(this.editUrl).then((resp) => {
let d = resp.data;
this.form = {
'name': d.name,
'is_enabled': d.is_enabled,
'weight': d.weight,
'type': d.type,
'source': d.source,
'order': d.order,
'remote_url': d.remote_url,
'remote_type': d.remote_type,
'remote_buffer': d.remote_buffer,
'is_jingle': d.is_jingle,
'play_per_songs': d.play_per_songs,
'play_per_minutes': d.play_per_minutes,
'play_per_hour_minute': d.play_per_hour_minute,
'include_in_requests': d.include_in_requests,
'include_in_automation': d.include_in_automation,
'backend_options': d.backend_options,
'schedule_items': d.schedule_items
};
this.loading = false;
}).catch((err) => {
console.log(err);
this.close();
});
},
doSubmit () {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
}
axios({
method: (this.isEditMode)
? 'PUT'
: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: this.form
}).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.');
notify('<b>' + notifyMessage + '</b>', 'success', false);
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.$emit('relist');
this.close();
});
},
close () {
this.loading = false;
this.editUrl = null;
this.resetForm();
this.$v.form.$reset();
this.$refs.modal.hide();
}
}
}
},
methods: {
resetForm () {
this.form = {
'name': '',
'is_enabled': true,
'weight': 3,
'type': 'default',
'source': 'songs',
'order': 'shuffle',
'remote_url': null,
'remote_type': 'stream',
'remote_buffer': 0,
'is_jingle': false,
'play_per_songs': 0,
'play_per_minutes': 0,
'play_per_hour_minute': 0,
'include_in_requests': true,
'include_in_automation': false,
'backend_options': [],
'schedule_items': []
}
},
create () {
this.resetForm()
this.loading = false
this.editUrl = null
this.$refs.modal.show()
},
edit (recordUrl) {
this.resetForm()
this.loading = true
this.editUrl = recordUrl
this.$refs.modal.show()
axios.get(this.editUrl).then((resp) => {
let d = resp.data
this.form = {
'name': d.name,
'is_enabled': d.is_enabled,
'weight': d.weight,
'type': d.type,
'source': d.source,
'order': d.order,
'remote_url': d.remote_url,
'remote_type': d.remote_type,
'remote_buffer': d.remote_buffer,
'is_jingle': d.is_jingle,
'play_per_songs': d.play_per_songs,
'play_per_minutes': d.play_per_minutes,
'play_per_hour_minute': d.play_per_hour_minute,
'include_in_requests': d.include_in_requests,
'include_in_automation': d.include_in_automation,
'backend_options': d.backend_options,
'schedule_items': d.schedule_items
}
this.loading = false
}).catch((err) => {
console.log(err)
this.close()
})
},
doSubmit () {
this.$v.form.$touch()
if (this.$v.form.$anyError) {
return
}
axios({
method: (this.isEditMode)
? 'PUT'
: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: this.form
}).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.')
notify('<b>' + notifyMessage + '</b>', 'success', false)
this.$emit('relist')
this.close()
}).catch((err) => {
console.error(err)
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.')
notify('<b>' + notifyMessage + '</b>', 'danger', false)
this.$emit('relist')
this.close()
})
},
close () {
this.loading = false
this.editUrl = null
this.resetForm()
this.$v.form.$reset()
this.$refs.modal.hide()
}
}
}
};
</script>

View File

@ -36,74 +36,74 @@
</template>
<script>
import axios from 'axios'
import Draggable from 'vuedraggable'
import axios from 'axios';
import Draggable from 'vuedraggable';
export default {
name: 'ReorderModal',
components: {
Draggable
},
data () {
return {
loading: true,
reorderUrl: null,
media: []
}
},
computed: {
langTitle () {
return this.$gettext('Reorder Playlist')
},
langDownBtn () {
return this.$gettext('Down')
},
langUpBtn () {
return this.$gettext('Up')
}
},
methods: {
open (reorderUrl) {
this.$refs.modal.show()
this.reorderUrl = reorderUrl
this.loading = true
export default {
name: 'ReorderModal',
components: {
Draggable
},
data () {
return {
loading: true,
reorderUrl: null,
media: []
};
},
computed: {
langTitle () {
return this.$gettext('Reorder Playlist');
},
langDownBtn () {
return this.$gettext('Down');
},
langUpBtn () {
return this.$gettext('Up');
}
},
methods: {
open (reorderUrl) {
this.$refs.modal.show();
this.reorderUrl = reorderUrl;
this.loading = true;
axios.get(this.reorderUrl).then((resp) => {
this.media = resp.data
this.loading = false
}).catch((err) => {
this.handleError(err)
})
},
moveDown (index) {
this.media.splice(index + 1, 0, this.media.splice(index, 1)[0])
this.save()
},
moveUp (index) {
this.media.splice(index - 1, 0, this.media.splice(index, 1)[0])
this.save()
},
save () {
let newOrder = {}
let i = 0
axios.get(this.reorderUrl).then((resp) => {
this.media = resp.data;
this.loading = false;
}).catch((err) => {
this.handleError(err);
});
},
moveDown (index) {
this.media.splice(index + 1, 0, this.media.splice(index, 1)[0]);
this.save();
},
moveUp (index) {
this.media.splice(index - 1, 0, this.media.splice(index, 1)[0]);
this.save();
},
save () {
let newOrder = {};
let i = 0;
this.media.forEach((row) => {
i++
newOrder[row.id] = i
})
this.media.forEach((row) => {
i++;
newOrder[row.id] = i;
});
axios.put(this.reorderUrl, { 'order': newOrder }).then((resp) => {
notify('<b>' + this.$gettext('Playlist order set.') + '</b>', 'success', false)
}).catch((err) => {
this.handleError(err)
})
},
handleError (err) {
console.error(err)
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger')
axios.put(this.reorderUrl, { 'order': newOrder }).then((resp) => {
notify('<b>' + this.$gettext('Playlist order set.') + '</b>', 'success', false);
}).catch((err) => {
this.handleError(err);
});
},
handleError (err) {
console.error(err);
if (err.response.message) {
notify('<b>' + err.response.message + '</b>', 'danger');
}
}
}
}
}
}
};
</script>

View File

@ -8,35 +8,35 @@
</template>
<script>
import FullCalendar from '@fullcalendar/vue'
import allLocales from '@fullcalendar/core/locales-all'
import momentPlugin from '@fullcalendar/moment'
import momentTimezonePlugin from '@fullcalendar/moment-timezone'
import timeGridPlugin from '@fullcalendar/timegrid'
import FullCalendar from '@fullcalendar/vue';
import allLocales from '@fullcalendar/core/locales-all';
import momentPlugin from '@fullcalendar/moment';
import momentTimezonePlugin from '@fullcalendar/moment-timezone';
import timeGridPlugin from '@fullcalendar/timegrid';
export default {
name: 'Schedule',
components: { FullCalendar },
props: {
scheduleUrl: String,
stationTimeZone: String,
locale: String
},
data () {
return {
locales: allLocales,
plugins: [momentPlugin, momentTimezonePlugin, timeGridPlugin]
}
},
methods: {
refresh () {
export default {
name: 'Schedule',
components: { FullCalendar },
props: {
scheduleUrl: String,
stationTimeZone: String,
locale: String
},
data () {
return {
locales: allLocales,
plugins: [momentPlugin, momentTimezonePlugin, timeGridPlugin]
};
},
methods: {
refresh () {
},
onEventClick (arg) {
this.$emit('edit', arg.event.extendedProps.edit_url)
}
}
}
},
onEventClick (arg) {
this.$emit('edit', arg.event.extendedProps.edit_url);
}
}
};
</script>
<style lang="scss">

View File

@ -214,19 +214,19 @@
{ value: 3, text: '3 - ' + this.$gettext('Default') },
{ value: 4, text: '4' },
{ value: 5, text: '5 - ' + this.$gettext('High') }
]
];
for (var i = 6; i <= 25; i++) {
weightOptions.push({ value: i, text: i })
weightOptions.push({ value: i, text: i });
}
return {
weightOptions: weightOptions
}
};
},
computed: {
langTabTitle () {
return this.$gettext('Basic Info')
return this.$gettext('Basic Info');
}
}
}
};
</script>

View File

@ -114,7 +114,7 @@
</template>
<script>
import PlaylistTime from '../../components/PlaylistTime'
import PlaylistTime from '../../components/PlaylistTime';
export default {
name: 'PlaylistEditSchedule',
@ -135,11 +135,11 @@
{ value: 6, text: this.$gettext('Saturday') },
{ value: 7, text: this.$gettext('Sunday') }
]
}
};
},
computed: {
langTabTitle () {
return this.$gettext('Schedule')
return this.$gettext('Schedule');
}
},
methods: {
@ -150,11 +150,11 @@
start_date: null,
end_date: null,
days: []
})
});
},
remove (index) {
this.scheduleItems.splice(index, 1)
this.scheduleItems.splice(index, 1);
}
}
}
};
</script>

View File

@ -130,8 +130,8 @@
},
computed: {
langTabTitle () {
return this.$gettext('Source')
return this.$gettext('Source');
}
}
}
};
</script>

View File

@ -0,0 +1,76 @@
<template>
<b-modal id="streamer_broadcasts" size="lg" centered ref="modal" :title="langHeader">
<template v-if="listUrl">
<data-table ref="datatable" id="station_streamer_broadcasts" :show-toolbar="false"
:fields="fields" :api-url="listUrl">
<template v-slot:cell(actions)="row">
<b-button-group size="sm" v-if="row.item.links_download">
<b-button size="sm" variant="primary" :href="row.item.links_download" target="_blank">
<translate>Download</translate>
</b-button>
</b-button-group>
<template v-else>&nbsp;</template>
</template>
</data-table>
</template>
<template v-slot:modal-footer>
<b-button variant="default" @click="close">
<translate>Close</translate>
</b-button>
</template>
</b-modal>
</template>
<script>
import DataTable from '../components/DataTable.vue';
export default {
name: 'StreamerBroadcastsModal',
components: { DataTable },
data () {
return {
listUrl: null,
fields: [
{
key: 'timestampStart',
label: this.$gettext('Start Time'),
sortable: false,
formatter: (value, key, item) => {
return moment.unix(value).format('lll');
}
},
{
key: 'timestampEnd',
label: this.$gettext('End Time'),
sortable: false,
formatter: (value, key, item) => {
if (value === 0) {
return this.$gettext('');
}
return moment.unix(value).format('lll');
}
},
{
key: 'actions',
label: this.$gettext('Actions'),
sortable: false
}
]
};
},
computed: {
langHeader () {
return this.$gettext('Streamer Broadcasts');
}
},
methods: {
open (listUrl) {
this.listUrl = listUrl;
this.$refs.modal.show();
},
close () {
this.listUrl = null;
this.$refs.modal.hide();
}
}
};
</script>

View File

@ -0,0 +1,203 @@
<template>
<b-modal size="lg" id="edit_modal" ref="modal" :title="langTitle" :busy="loading">
<b-spinner v-if="loading">
</b-spinner>
<b-form class="form" v-else @submit.prevent="doSubmit">
<b-form-group>
<b-row>
<b-form-group class="col-md-12" label-for="form_edit_is_active">
<template v-slot:description>
<translate>Enable to allow this account to log in and stream.</translate>
</template>
<b-form-checkbox id="form_edit_is_active" v-model="$v.form.is_active.$model">
<translate>Account is Active</translate>
</b-form-checkbox>
</b-form-group>
</b-row>
<b-row>
<b-form-group class="col-md-6" label-for="edit_form_streamer_username">
<template v-slot:label>
<translate>Streamer Username</translate>
</template>
<template v-slot:description>
<translate>The streamer will use this username to connect to the radio server.</translate>
</template>
<b-form-input type="text" id="edit_form_streamer_username" v-model="$v.form.streamer_username.$model"
:state="$v.form.streamer_username.$dirty ? !$v.form.streamer_username.$error : null"></b-form-input>
<b-form-invalid-feedback>
<translate>This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
<b-form-group class="col-md-6" label-for="edit_form_streamer_password">
<template v-slot:label>
<translate>Streamer password</translate>
</template>
<template v-slot:description>
<translate>The streamer will use this password to connect to the radio server.</translate>
</template>
<b-form-input type="password" id="edit_form_streamer_password" v-model="$v.form.streamer_password.$model"
:state="$v.form.streamer_password.$dirty ? !$v.form.streamer_password.$error : null"></b-form-input>
<b-form-invalid-feedback>
<translate>This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
</b-row>
<b-row>
<b-form-group class="col-md-6" label-for="edit_form_display_name">
<template v-slot:label>
<translate>Streamer Display Name</translate>
</template>
<template v-slot:description>
<translate>This is the informal display name that will be shown in API responses if the streamer/DJ is live.</translate>
</template>
<b-form-input type="text" id="edit_form_display_name" v-model="$v.form.display_name.$model"
:state="$v.form.display_name.$dirty ? !$v.form.display_name.$error : null"></b-form-input>
<b-form-invalid-feedback>
<translate>This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
<b-form-group class="col-md-6" label-for="edit_form_comments">
<template v-slot:label>
<translate>Comments</translate>
</template>
<template v-slot:description>
<translate>Internal notes or comments about the user, visible only on this control panel.</translate>
</template>
<b-form-textarea id="edit_form_comments" v-model="$v.form.comments.$model"
:state="$v.form.comments.$dirty ? !$v.form.comments.$error : null"></b-form-textarea>
<b-form-invalid-feedback>
<translate>This field is required.</translate>
</b-form-invalid-feedback>
</b-form-group>
</b-row>
</b-form-group>
</b-form>
<template v-slot:modal-footer>
<b-button variant="default" @click="close">
<translate>Close</translate>
</b-button>
<b-button variant="primary" @click="doSubmit" :disabled="$v.form.$invalid">
<translate>Save Changes</translate>
</b-button>
</template>
</b-modal>
</template>
<script>
import { validationMixin } from 'vuelidate';
import axios from 'axios';
import required from 'vuelidate/src/validators/required';
export default {
name: 'EditModal',
mixins: [validationMixin],
props: {
createUrl: String
},
data () {
return {
loading: true,
editUrl: null,
form: {}
};
},
validations: {
form: {
'streamer_username': { required },
'streamer_password': {},
'display_name': {},
'comments': {},
'is_active': {}
}
},
computed: {
langTitle () {
return this.isEditMode
? this.$gettext('Edit Streamer')
: this.$gettext('Add Streamer');
},
isEditMode () {
return this.editUrl !== null;
}
},
methods: {
resetForm () {
this.form = {
'streamer_username': null,
'streamer_password': null,
'display_name': null,
'comments': null,
'is_active': true
};
},
create () {
this.resetForm();
this.loading = false;
this.editUrl = null;
this.$refs.modal.show();
},
edit (recordUrl) {
this.resetForm();
this.loading = true;
this.editUrl = recordUrl;
this.$refs.modal.show();
axios.get(this.editUrl).then((resp) => {
let d = resp.data;
this.form = {
'streamer_username': d.streamer_username,
'streamer_password': null,
'display_name': d.display_name,
'comments': d.comments,
'is_active': d.is_active
};
this.loading = false;
}).catch((err) => {
console.log(err);
this.close();
});
},
doSubmit () {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
}
axios({
method: (this.isEditMode)
? 'PUT'
: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: this.form
}).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.');
notify('<b>' + notifyMessage + '</b>', 'success', false);
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.$emit('relist');
this.close();
});
},
close () {
this.loading = false;
this.editUrl = null;
this.resetForm();
this.$v.form.$reset();
this.$refs.modal.hide();
}
}
};
</script>

View File

@ -44,8 +44,8 @@
</div>
</template>
<script>
import track from './track.js'
import _ from 'lodash'
import track from './track.js';
import _ from 'lodash';
export default {
extends: track,
@ -55,104 +55,104 @@
'device': null,
'devices': [],
'isRecording': false
}
};
},
watch: {
device: function (val, oldVal) {
if (this.source == null) {
return
return;
}
return this.createSource()
return this.createSource();
}
},
mounted: function () {
var base, base1
var base, base1;
// Get multimedia devices by requesting them from the browser.
navigator.mediaDevices || (navigator.mediaDevices = {});
(base = navigator.mediaDevices).getUserMedia || (base.getUserMedia = function (constraints) {
var fn
fn = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia
var fn;
fn = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
if (fn == null) {
return Promise.reject(new Error('getUserMedia is not implemented in this browser'))
return Promise.reject(new Error('getUserMedia is not implemented in this browser'));
}
return new Promise(function (resolve, reject) {
return fn.call(navigator, constraints, resolve, reject)
})
return fn.call(navigator, constraints, resolve, reject);
});
});
(base1 = navigator.mediaDevices).enumerateDevices || (base1.enumerateDevices = function () {
return Promise.reject(new Error('enumerateDevices is not implemented on this browser'))
})
return Promise.reject(new Error('enumerateDevices is not implemented on this browser'));
});
var vm_mic = this
var vm_mic = this;
navigator.mediaDevices.getUserMedia({
audio: true,
video: false
}).then(function () {
return navigator.mediaDevices.enumerateDevices().then(vm_mic.setDevices)
})
return navigator.mediaDevices.enumerateDevices().then(vm_mic.setDevices);
});
this.$root.$on('new-cue', this.onNewCue)
this.$root.$on('new-cue', this.onNewCue);
},
methods: {
cue: function () {
this.resumeStream()
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : 'microphone')
this.resumeStream();
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : 'microphone');
},
onNewCue: function (new_cue) {
this.passThrough = (new_cue === 'microphone')
this.passThrough = (new_cue === 'microphone');
},
toggleRecording: function () {
this.resumeStream()
this.resumeStream();
if (this.playing) {
this.stop()
this.stop();
} else {
this.play()
this.play();
}
},
createSource: function (cb) {
var constraints
var constraints;
if (this.source != null) {
this.source.disconnect(this.destination)
this.source.disconnect(this.destination);
}
constraints = {
video: false
}
};
if (this.device) {
constraints.audio = {
deviceId: this.device
}
};
} else {
constraints.audio = true
constraints.audio = true;
}
return this.getStream().createMicrophoneSource(constraints, (source) => {
this.source = source
this.source.connect(this.destination)
return typeof cb === 'function' ? cb() : void 0
})
this.source = source;
this.source.connect(this.destination);
return typeof cb === 'function' ? cb() : void 0;
});
},
play: function () {
this.prepare()
this.prepare();
return this.createSource(() => {
this.playing = true
this.paused = false
})
this.playing = true;
this.paused = false;
});
},
setDevices: function (devices) {
devices = _.filter(devices, function ({ kind, deviceId }) {
return kind === 'audioinput'
})
return kind === 'audioinput';
});
if (_.isEmpty(devices)) {
return
return;
}
this.devices = devices
this.device = _.first(devices).deviceId
this.devices = devices;
this.device = _.first(devices).deviceId;
}
}
}
};
</script>

View File

@ -22,12 +22,12 @@
data () {
return {
'position': 0.5
}
};
},
watch: {
position (val, oldVal) {
this.$root.$emit('new-mixer-value', val)
this.$root.$emit('new-mixer-value', val);
}
}
}
};
</script>

View File

@ -92,8 +92,8 @@
</template>
<script>
import track from './track.js'
import _ from 'lodash'
import track from './track.js';
import _ from 'lodash';
export default {
extends: track,
@ -110,78 +110,78 @@
'isSeeking': false,
'seekPosition': 0,
'mixGainObj': null
}
};
},
computed: {
lang_header () {
return (this.id === 'playlist_1')
? this.$gettext('Playlist 1')
: this.$gettext('Playlist 2')
: this.$gettext('Playlist 2');
},
lang_unknown_title () {
return this.$gettext('Unknown Title')
return this.$gettext('Unknown Title');
},
lang_unknown_artist () {
return this.$gettext('Unknown Artist')
return this.$gettext('Unknown Artist');
},
positionPercent () {
return (100.0 * this.position / parseFloat(this.duration))
return (100.0 * this.position / parseFloat(this.duration));
},
seekingPosition () {
return (this.isSeeking) ? this.seekPosition : this.positionPercent
return (this.isSeeking) ? this.seekPosition : this.positionPercent;
}
},
props: {
id: String
},
mounted () {
this.mixGainObj = this.getStream().context.createGain()
this.mixGainObj.connect(this.getStream().webcast)
this.sink = this.mixGainObj
this.mixGainObj = this.getStream().context.createGain();
this.mixGainObj.connect(this.getStream().webcast);
this.sink = this.mixGainObj;
this.$root.$on('new-mixer-value', this.setMixGain)
this.$root.$on('new-cue', this.onNewCue)
this.$root.$on('new-mixer-value', this.setMixGain);
this.$root.$on('new-cue', this.onNewCue);
},
filters: {
prettifyTime (time) {
if (typeof time === 'undefined') {
return 'N/A'
return 'N/A';
}
var hours = parseInt(time / 3600)
time %= 3600
var minutes = parseInt(time / 60)
var seconds = parseInt(time % 60)
var hours = parseInt(time / 3600);
time %= 3600;
var minutes = parseInt(time / 60);
var seconds = parseInt(time % 60);
if (minutes < 10) {
minutes = '0' + minutes
minutes = '0' + minutes;
}
if (seconds < 10) {
seconds = '0' + seconds
seconds = '0' + seconds;
}
if (hours > 0) {
return hours + ':' + minutes + ':' + seconds
return hours + ':' + minutes + ':' + seconds;
} else {
return minutes + ':' + seconds
return minutes + ':' + seconds;
}
}
},
methods: {
cue () {
this.resumeStream()
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : this.id)
this.resumeStream();
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : this.id);
},
onNewCue (new_cue) {
this.passThrough = (new_cue === this.id)
this.passThrough = (new_cue === this.id);
},
setMixGain (new_value) {
if (this.id === 'playlist_1') {
this.mixGainObj.gain.value = 1.0 - new_value
this.mixGainObj.gain.value = 1.0 - new_value;
} else {
this.mixGainObj.gain.value = new_value
this.mixGainObj.gain.value = new_value;
}
},
@ -192,115 +192,115 @@
file: file,
audio: data.audio,
metadata: data.metadata || { title: '', artist: '' }
})
})
})
});
});
});
},
play (options) {
this.resumeStream()
this.resumeStream();
if (this.paused) {
this.togglePause()
return
this.togglePause();
return;
}
this.stop()
this.stop();
if (!(this.file = this.selectFile(options))) {
return
return;
}
this.prepare()
this.prepare();
return this.getStream().createFileSource(this.file, this, (source) => {
var ref1
this.source = source
this.source.connect(this.destination)
var ref1;
this.source = source;
this.source.connect(this.destination);
if (this.source.duration != null) {
this.duration = this.source.duration()
this.duration = this.source.duration();
} else {
if (((ref1 = this.file.audio) != null ? ref1.length : void 0) != null) {
this.duration = parseFloat(this.file.audio.length)
this.duration = parseFloat(this.file.audio.length);
}
}
this.source.play(this.file)
this.source.play(this.file);
this.$root.$emit('metadata-update', {
title: this.file.metadata.title,
artist: this.file.metadata.artist
})
});
this.playing = true
this.paused = false
})
this.playing = true;
this.paused = false;
});
},
selectFile (options = {}) {
if (this.files.length === 0) {
return
return;
}
if (options.fileIndex) {
this.fileIndex = options.fileIndex
this.fileIndex = options.fileIndex;
} else {
this.fileIndex += options.backward ? -1 : 1
this.fileIndex += options.backward ? -1 : 1;
if (this.fileIndex < 0) {
this.fileIndex = this.files.length - 1
this.fileIndex = this.files.length - 1;
}
if (this.fileIndex >= this.files.length) {
if (options.isAutoPlay && !this.loop) {
this.fileIndex = -1
return
this.fileIndex = -1;
return;
}
if (this.fileIndex < 0) {
this.fileIndex = this.files.length - 1
this.fileIndex = this.files.length - 1;
} else {
this.fileIndex = 0
this.fileIndex = 0;
}
}
}
return this.files[this.fileIndex]
return this.files[this.fileIndex];
},
previous () {
if (!this.playing) {
return
return;
}
return this.play({
backward: true
})
});
},
next () {
if (!this.playing) {
return
return;
}
return this.play()
return this.play();
},
onEnd () {
this.stop()
this.stop();
if (this.playThrough) {
return this.play({
isAutoPlay: true
})
});
}
},
doSeek (e) {
if (this.isSeeking) {
this.seekPosition = e.target.value
this.seek(this.seekPosition / 100)
this.seekPosition = e.target.value;
this.seek(this.seekPosition / 100);
}
}
}
}
};
</script>

View File

@ -162,22 +162,22 @@
'title': '',
'artist': ''
}
}
};
},
computed: {
langDjUsername () {
return this.$gettext('Username')
return this.$gettext('Username');
},
langDjPassword () {
return this.$gettext('Password')
return this.$gettext('Password');
},
langStreamButton () {
return (this.isStreaming)
? this.$gettext('Stop Streaming')
: this.$gettext('Start Streaming')
: this.$gettext('Start Streaming');
},
uri () {
return 'wss://' + this.djUsername + ':' + this.djPassword + '@' + this.baseUri
return 'wss://' + this.djUsername + ':' + this.djPassword + '@' + this.baseUri;
}
},
props: {
@ -186,73 +186,73 @@
baseUri: String
},
mounted () {
this.$root.$on('new-cue', this.onNewCue)
this.$root.$on('metadata-update', this.onMetadataUpdate)
this.$root.$on('new-cue', this.onNewCue);
this.$root.$on('metadata-update', this.onMetadataUpdate);
},
methods: {
cue () {
this.resumeStream()
this.resumeStream();
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : 'master')
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : 'master');
},
onNewCue (new_cue) {
this.passThrough = (new_cue === 'master')
this.getStream().webcast.setPassThrough(this.passThrough)
this.passThrough = (new_cue === 'master');
this.getStream().webcast.setPassThrough(this.passThrough);
},
startStreaming () {
this.resumeStream()
this.resumeStream();
var encoderClass
var encoderClass;
switch (this.encoder) {
case 'mp3':
encoderClass = Webcast.Encoder.Mp3
break
encoderClass = Webcast.Encoder.Mp3;
break;
case 'raw':
encoderClass = Webcast.Encoder.Raw
encoderClass = Webcast.Encoder.Raw;
}
let encoder = new encoderClass({
channels: 2,
samplerate: this.samplerate,
bitrate: this.bitrate
})
});
if (this.samplerate !== this.getStream().context.sampleRate) {
encoder = new Webcast.Encoder.Resample({
encoder: encoder,
type: Samplerate.LINEAR,
samplerate: this.getStream().context.sampleRate
})
});
}
if (this.asynchronous) {
encoder = new Webcast.Encoder.Asynchronous({
encoder: encoder,
scripts: this.libUrls
})
});
}
this.getStream().webcast.connectSocket(encoder, this.uri)
this.isStreaming = true
this.getStream().webcast.connectSocket(encoder, this.uri);
this.isStreaming = true;
},
stopStreaming () {
this.getStream().webcast.close()
this.isStreaming = false
this.getStream().webcast.close();
this.isStreaming = false;
},
updateMetadata () {
this.$root.$emit('metadata-update', {
title: this.metadata.title,
artist: this.metadata.artist
})
});
notify('Metadata updated!', 'success', true)
notify('Metadata updated!', 'success', true);
},
onMetadataUpdate (new_metadata) {
this.metadata.title = new_metadata.title
this.metadata.artist = new_metadata.artist
this.metadata.title = new_metadata.title;
this.metadata.artist = new_metadata.artist;
return this.getStream().webcast.sendMetadata(new_metadata)
return this.getStream().webcast.sendMetadata(new_metadata);
}
}
}
};
</script>

View File

@ -1,99 +1,99 @@
var stream = {};
var defaultChannels = 2;
var stream = {}
var defaultChannels = 2
// Function to be called upon the first user interaction.
stream.init = function() {
// Define the streaming radio context.
if (!this.context) {
if (typeof webkitAudioContext !== "undefined") {
this.context = new webkitAudioContext;
} else {
this.context = new AudioContext;
}
this.webcast = this.context.createWebcastSource(4096, defaultChannels);
this.webcast.connect(this.context.destination);
stream.init = function () {
// Define the streaming radio context.
if (!this.context) {
if (typeof webkitAudioContext !== 'undefined') {
this.context = new webkitAudioContext
} else {
this.context = new AudioContext
}
};
stream.resumeContext = function() {
if (this.context.state !== 'running') {
this.context.resume();
this.webcast = this.context.createWebcastSource(4096, defaultChannels)
this.webcast.connect(this.context.destination)
}
}
stream.resumeContext = function () {
if (this.context.state !== 'running') {
this.context.resume()
}
}
stream.createAudioSource = function ({ file, audio }, model, cb) {
var el, source
el = new Audio(URL.createObjectURL(file))
el.controls = false
el.autoplay = false
el.loop = false
el.addEventListener('ended', function () {
return model.onEnd()
})
source = null
return el.addEventListener('canplay', function () {
if (source != null) {
return
}
};
stream.createAudioSource = function({file, audio}, model, cb) {
var el, source;
el = new Audio(URL.createObjectURL(file));
el.controls = false;
el.autoplay = false;
el.loop = false;
el.addEventListener("ended", function() {
return model.onEnd();
});
source = null;
return el.addEventListener("canplay", function() {
if (source != null) {
return;
}
source = stream.context.createMediaElementSource(el);
source.play = function() {
return el.play();
};
source.position = function() {
return el.currentTime;
};
source.duration = function() {
return el.duration;
};
source.paused = function() {
return el.paused;
};
source.stop = function() {
el.pause();
return el.remove();
};
source.pause = function() {
return el.pause();
};
source.seek = function(percent) {
var time;
time = percent * parseFloat(audio.length);
el.currentTime = time;
return time;
};
return cb(source);
});
};
stream.createFileSource = function(file, model, cb) {
var ref;
if ((ref = this.source) != null) {
ref.disconnect();
source = stream.context.createMediaElementSource(el)
source.play = function () {
return el.play()
}
source.position = function () {
return el.currentTime
}
source.duration = function () {
return el.duration
}
source.paused = function () {
return el.paused
}
source.stop = function () {
el.pause()
return el.remove()
}
source.pause = function () {
return el.pause()
}
source.seek = function (percent) {
var time
time = percent * parseFloat(audio.length)
el.currentTime = time
return time
}
return this.createAudioSource(file, model, cb);
};
stream.createMicrophoneSource = function(constraints, cb) {
return navigator.mediaDevices.getUserMedia(constraints).then(function(bit_stream) {
var source;
return cb(source)
})
}
source = stream.context.createMediaStreamSource(bit_stream);
source.stop = function() {
var ref;
return (ref = bit_stream.getAudioTracks()) != null ? ref[0].stop() : void 0;
};
return cb(source);
});
};
stream.createFileSource = function (file, model, cb) {
var ref
if ((ref = this.source) != null) {
ref.disconnect()
}
return this.createAudioSource(file, model, cb)
}
stream.close = function(cb) {
return this.webcast.close(cb);
};
stream.createMicrophoneSource = function (constraints, cb) {
return navigator.mediaDevices.getUserMedia(constraints).then(function (bit_stream) {
var source
export default stream;
source = stream.context.createMediaStreamSource(bit_stream)
source.stop = function () {
var ref
return (ref = bit_stream.getAudioTracks()) != null ? ref[0].stop() : void 0
}
return cb(source)
})
}
stream.close = function (cb) {
return this.webcast.close(cb)
}
export default stream

View File

@ -1,192 +1,192 @@
export default {
inject: ['getStream', 'resumeStream'],
data: function() {
return {
"controlsNode": null,
inject: ['getStream', 'resumeStream'],
data: function () {
return {
'controlsNode': null,
"trackGain": 0,
"trackGainObj": null,
'trackGain': 0,
'trackGainObj': null,
"destination": null,
"sink": null,
'destination': null,
'sink': null,
"passThrough": false,
"passThroughObj": null,
'passThrough': false,
'passThroughObj': null,
"source": null,
"playing": false,
"paused": false,
"position": 0.0,
"volume": 100,
"volumeLeft": 0,
"volumeRight": 0,
};
},
mounted: function() {
this.sink = this.getStream().webcast;
},
watch: {
volume: function(val, oldVal) {
this.setTrackGain(val);
}
},
methods: {
createControlsNode: function() {
var bufferLength, bufferLog, bufferSize, log10, source;
bufferSize = 4096;
bufferLength = parseFloat(bufferSize) / parseFloat(this.getStream().context.sampleRate);
bufferLog = Math.log(parseFloat(bufferSize));
log10 = 2.0 * Math.log(10);
source = this.getStream().context.createScriptProcessor(bufferSize, 2, 2);
source.onaudioprocess = (buf) => {
var channel, channelData, i, j, k, ref1, ref2, ref3, results, ret, rms, volume;
ret = {};
if (((ref1 = this.source) != null ? ref1.position : void 0) != null) {
this.position = this.source.position();
} else {
if (this.source != null) {
this.position = parseFloat(this.position) + bufferLength;
}
}
results = [];
for (channel = j = 0, ref2 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref2 ? j <= ref2 : j >= ref2); channel = 0 <= ref2 ? ++j : --j) {
channelData = buf.inputBuffer.getChannelData(channel);
rms = 0.0;
for (i = k = 0, ref3 = channelData.length - 1; (0 <= ref3 ? k <= ref3 : k >= ref3); i = 0 <= ref3 ? ++k : --k) {
rms += Math.pow(channelData[i], 2);
}
volume = 100 * Math.exp((Math.log(rms) - bufferLog) / log10);
if (channel === 0) {
this.volumeLeft = volume;
} else {
this.volumeRight = volume;
}
results.push(buf.outputBuffer.getChannelData(channel).set(channelData));
}
return results;
};
return source;
},
createPassThrough: function() {
var source;
source = this.getStream().context.createScriptProcessor(256, 2, 2);
source.onaudioprocess = (buf) => {
var channel, channelData, j, ref1, results;
channelData = buf.inputBuffer.getChannelData(channel);
results = [];
for (channel = j = 0, ref1 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref1 ? j <= ref1 : j >= ref1); channel = 0 <= ref1 ? ++j : --j) {
if (this.passThrough) {
results.push(buf.outputBuffer.getChannelData(channel).set(channelData));
} else {
results.push(buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length)));
}
}
return results;
};
return source;
},
setTrackGain: function(new_gain) {
return this.trackGainObj.gain.value = parseFloat(new_gain) / 100.0;
},
togglePause: function() {
var ref1, ref2;
if (((ref1 = this.source) != null ? ref1.pause : void 0) == null) {
return;
}
if ((ref2 = this.source) != null ? typeof ref2.paused === "function" ? ref2.paused() : void 0 : void 0) {
this.source.play();
this.playing = true;
this.paused = false;
} else {
this.source.pause();
this.playing = false;
this.paused = true;
}
},
prepare: function() {
this.controlsNode = this.createControlsNode();
this.controlsNode.connect(this.sink);
this.trackGainObj = this.getStream().context.createGain();
this.trackGainObj.connect(this.controlsNode);
this.trackGainObj.gain.value = 1.0;
this.destination = this.trackGainObj;
this.passThroughObj = this.createPassThrough();
this.passThroughObj.connect(this.getStream().context.destination);
return this.trackGainObj.connect(this.passThroughObj);
},
stop: function() {
var ref1, ref2, ref3, ref4, ref5;
if ((ref1 = this.source) != null) {
if (typeof ref1.stop === "function") {
ref1.stop();
}
}
if ((ref2 = this.source) != null) {
ref2.disconnect();
}
if ((ref3 = this.trackGainObj) != null) {
ref3.disconnect();
}
if ((ref4 = this.controlsNode) != null) {
ref4.disconnect();
}
if ((ref5 = this.passThroughObj) != null) {
ref5.disconnect();
}
this.source = this.trackGainObj = this.controlsNode = this.passThroughObj = null;
this.position = 0.0;
this.volumeLeft = 0;
this.volumeRight = 0;
this.playing = false;
this.paused = false;
},
seek: function(percent) {
var position, ref1;
if (!(position = (ref1 = this.source) != null ? typeof ref1.seek === "function" ? ref1.seek(percent) : void 0 : void 0)) {
return;
}
this.position = position;
},
prettifyTime: function(time) {
var hours, minutes, result, seconds;
hours = parseInt(time / 3600);
time %= 3600;
minutes = parseInt(time / 60);
seconds = parseInt(time % 60);
if (minutes < 10) {
minutes = `0${minutes}`;
}
if (seconds < 10) {
seconds = `0${seconds}`;
}
result = `${minutes}:${seconds}`;
if (hours > 0) {
result = `${hours}:${result}`;
}
return result;
},
sendMetadata: function(file) {
this.getStream().webcast.sendMetadata(file.metadata);
}
'source': null,
'playing': false,
'paused': false,
'position': 0.0,
'volume': 100,
'volumeLeft': 0,
'volumeRight': 0
}
},
mounted: function () {
this.sink = this.getStream().webcast
},
watch: {
volume: function (val, oldVal) {
this.setTrackGain(val)
}
},
methods: {
createControlsNode: function () {
var bufferLength, bufferLog, bufferSize, log10, source
bufferSize = 4096
bufferLength = parseFloat(bufferSize) / parseFloat(this.getStream().context.sampleRate)
bufferLog = Math.log(parseFloat(bufferSize))
log10 = 2.0 * Math.log(10)
source = this.getStream().context.createScriptProcessor(bufferSize, 2, 2)
source.onaudioprocess = (buf) => {
var channel, channelData, i, j, k, ref1, ref2, ref3, results, ret, rms, volume
ret = {}
if (((ref1 = this.source) != null ? ref1.position : void 0) != null) {
this.position = this.source.position()
} else {
if (this.source != null) {
this.position = parseFloat(this.position) + bufferLength
}
}
results = []
for (channel = j = 0, ref2 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref2 ? j <= ref2 : j >= ref2); channel = 0 <= ref2 ? ++j : --j) {
channelData = buf.inputBuffer.getChannelData(channel)
rms = 0.0
for (i = k = 0, ref3 = channelData.length - 1; (0 <= ref3 ? k <= ref3 : k >= ref3); i = 0 <= ref3 ? ++k : --k) {
rms += Math.pow(channelData[i], 2)
}
volume = 100 * Math.exp((Math.log(rms) - bufferLog) / log10)
if (channel === 0) {
this.volumeLeft = volume
} else {
this.volumeRight = volume
}
results.push(buf.outputBuffer.getChannelData(channel).set(channelData))
}
return results
}
return source
},
createPassThrough: function () {
var source
source = this.getStream().context.createScriptProcessor(256, 2, 2)
source.onaudioprocess = (buf) => {
var channel, channelData, j, ref1, results
channelData = buf.inputBuffer.getChannelData(channel)
results = []
for (channel = j = 0, ref1 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref1 ? j <= ref1 : j >= ref1); channel = 0 <= ref1 ? ++j : --j) {
if (this.passThrough) {
results.push(buf.outputBuffer.getChannelData(channel).set(channelData))
} else {
results.push(buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length)))
}
}
return results
}
return source
},
setTrackGain: function (new_gain) {
return this.trackGainObj.gain.value = parseFloat(new_gain) / 100.0
},
togglePause: function () {
var ref1, ref2
if (((ref1 = this.source) != null ? ref1.pause : void 0) == null) {
return
}
if ((ref2 = this.source) != null ? typeof ref2.paused === 'function' ? ref2.paused() : void 0 : void 0) {
this.source.play()
this.playing = true
this.paused = false
} else {
this.source.pause()
this.playing = false
this.paused = true
}
},
prepare: function () {
this.controlsNode = this.createControlsNode()
this.controlsNode.connect(this.sink)
this.trackGainObj = this.getStream().context.createGain()
this.trackGainObj.connect(this.controlsNode)
this.trackGainObj.gain.value = 1.0
this.destination = this.trackGainObj
this.passThroughObj = this.createPassThrough()
this.passThroughObj.connect(this.getStream().context.destination)
return this.trackGainObj.connect(this.passThroughObj)
},
stop: function () {
var ref1, ref2, ref3, ref4, ref5
if ((ref1 = this.source) != null) {
if (typeof ref1.stop === 'function') {
ref1.stop()
}
}
if ((ref2 = this.source) != null) {
ref2.disconnect()
}
if ((ref3 = this.trackGainObj) != null) {
ref3.disconnect()
}
if ((ref4 = this.controlsNode) != null) {
ref4.disconnect()
}
if ((ref5 = this.passThroughObj) != null) {
ref5.disconnect()
}
this.source = this.trackGainObj = this.controlsNode = this.passThroughObj = null
this.position = 0.0
this.volumeLeft = 0
this.volumeRight = 0
this.playing = false
this.paused = false
},
seek: function (percent) {
var position, ref1
if (!(position = (ref1 = this.source) != null ? typeof ref1.seek === 'function' ? ref1.seek(percent) : void 0 : void 0)) {
return
}
this.position = position
},
prettifyTime: function (time) {
var hours, minutes, result, seconds
hours = parseInt(time / 3600)
time %= 3600
minutes = parseInt(time / 60)
seconds = parseInt(time % 60)
if (minutes < 10) {
minutes = `0${minutes}`
}
if (seconds < 10) {
seconds = `0${seconds}`
}
result = `${minutes}:${seconds}`
if (hours > 0) {
result = `${hours}:${result}`
}
return result
},
sendMetadata: function (file) {
this.getStream().webcast.sendMetadata(file.metadata)
}
}
}

View File

@ -5,6 +5,10 @@ msgstr ""
"Generated-By: easygettext\n"
"Project-Id-Version: \n"
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:47
msgid ""
msgstr ""
#: ./vue/StationPlaylists.vue:124
msgid "# Songs"
msgstr ""
@ -17,9 +21,15 @@ msgstr ""
msgid "A playlist that instructs the station to play from a remote URL."
msgstr ""
#: ./vue/StationMedia.vue:177
#: ./vue/station_streamers/StreamerEditModal.vue:12
msgid "Account is Active"
msgstr ""
#: ./vue/StationMedia.vue:189
#: ./vue/StationPlaylists.vue:121
#: ./vue/StationStreamers.vue:56
#: ./vue/station_playlists/PlaylistReorderModal.vue:11
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:54
msgid "Actions"
msgstr ""
@ -36,21 +46,26 @@ msgstr ""
msgid "Add Schedule Item"
msgstr ""
#: ./vue/StationStreamers.vue:9
#: ./vue/station_streamers/StreamerEditModal.vue:116
msgid "Add Streamer"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:69
msgid "Advanced"
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:81
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:92
msgid "Advanced Playback Settings"
msgstr ""
#: ./vue/StationMedia.vue:147
#: ./vue/StationMedia.vue:153
#: ./vue/station_playlists/PlaylistReorderModal.vue:10
msgid "Album"
msgstr ""
#: ./vue/RadioPlayer.vue:339
#: ./vue/StationMedia.vue:200
#: ./vue/RadioPlayer.vue:323
#: ./vue/StationMedia.vue:212
#: ./vue/station_media/form/MediaFormAlbumArt.vue:42
msgid "Album Art"
msgstr ""
@ -63,10 +78,15 @@ msgstr ""
msgid "Allow Requests from This Playlist"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:155
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:13
msgid "Amplify: Multiply Signal"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:158
#: ./vue/station_media/MediaMoveFilesModal.vue:92
#: ./vue/station_media/MediaNewDirectoryModal.vue:76
#: ./vue/station_playlists/PlaylistEditModal.vue:174
#: ./vue/station_streamers/StreamerEditModal.vue:186
msgid "An error occurred and your request could not be completed."
msgstr ""
@ -75,7 +95,7 @@ msgstr ""
msgid "Applying changes..."
msgstr ""
#: ./vue/StationMedia.vue:146
#: ./vue/StationMedia.vue:148
#: ./vue/station_playlists/PlaylistReorderModal.vue:9
#: ./vue/webcaster/settings.vue:115
msgid "Artist"
@ -101,8 +121,13 @@ msgstr ""
msgid "Basic Information"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:147
#: ./vue/StationStreamers.vue:22
msgid "Broadcasts"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:150
#: ./vue/station_playlists/PlaylistEditModal.vue:166
#: ./vue/station_streamers/StreamerEditModal.vue:178
msgid "Changes saved."
msgstr ""
@ -115,9 +140,15 @@ msgstr ""
#: ./vue/station_media/MediaNewDirectoryModal.vue:15
#: ./vue/station_media/MediaRenameModal.vue:16
#: ./vue/station_playlists/PlaylistEditModal.vue:15
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:17
#: ./vue/station_streamers/StreamerEditModal.vue:76
msgid "Close"
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:60
msgid "Comments"
msgstr ""
#: ./vue/webcaster/playlist.vue:66
msgid "Continuous Play"
msgstr ""
@ -140,23 +171,23 @@ msgstr ""
msgid "Custom"
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:46
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:57
msgid "Custom Cues: Cue-In Point (seconds)"
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:57
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:68
msgid "Custom Cues: Cue-Out Point (seconds)"
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:24
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:35
msgid "Custom Fading: Fade-In Time (seconds)"
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:35
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:46
msgid "Custom Fading: Fade-Out Time (seconds)"
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:13
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:24
msgid "Custom Fading: Overlap Time (seconds)"
msgstr ""
@ -170,6 +201,8 @@ msgstr ""
#: ./vue/StationPlaylists.vue:31
#: ./vue/StationPlaylists.vue:219
#: ./vue/StationStreamers.vue:25
#: ./vue/StationStreamers.vue:78
#: ./vue/station_media/MediaToolbar.vue:53
#: ./vue/station_media/MediaToolbar.vue:114
msgid "Delete"
@ -187,6 +220,10 @@ msgstr ""
msgid "Delete playlist?"
msgstr ""
#: ./vue/StationStreamers.vue:79
msgid "Delete streamer?"
msgstr ""
#: ./vue/StationMedia.vue:47
#: ./vue/station_media/MediaMoveFilesModal.vue:61
msgid "Directory"
@ -204,6 +241,10 @@ msgstr ""
msgid "Disabled"
msgstr ""
#: ./vue/StationStreamers.vue:58
msgid "Display Name"
msgstr ""
#: ./vue/webcaster/settings.vue:83
msgid "DJ Credentials"
msgstr ""
@ -212,16 +253,21 @@ msgstr ""
msgid "Down"
msgstr ""
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:8
msgid "Download"
msgstr ""
#: ./vue/station_media/MediaFileUpload.vue:16
msgid "Drag files here to upload to this folder or"
msgstr ""
#: ./vue/StationMedia.vue:206
#: ./vue/StationMedia.vue:218
#: ./vue/StationPlaylists.vue:28
#: ./vue/StationStreamers.vue:19
msgid "Edit"
msgstr ""
#: ./vue/station_media/MediaEditModal.vue:69
#: ./vue/station_media/MediaEditModal.vue:70
msgid "Edit Media"
msgstr ""
@ -229,6 +275,10 @@ msgstr ""
msgid "Edit Playlist"
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:115
msgid "Edit Streamer"
msgstr ""
#: ./vue/StationPlaylists.vue:152
msgid "Enable"
msgstr ""
@ -237,6 +287,10 @@ msgstr ""
msgid "Enable this setting to prevent metadata from being sent to the AutoDJ for files in this playlist. This is useful if the playlist contains jingles or bumpers."
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:9
msgid "Enable to allow this account to log in and stream."
msgstr ""
#: ./vue/webcaster/settings.vue:27
msgid "Encoder"
msgstr ""
@ -246,6 +300,7 @@ msgid "End Date"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:43
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:43
msgid "End Time"
msgstr ""
@ -273,8 +328,8 @@ msgstr ""
msgid "Friday"
msgstr ""
#: ./vue/InlinePlayer.vue:20
#: ./vue/RadioPlayer.vue:336
#: ./vue/InlinePlayer.vue:22
#: ./vue/RadioPlayer.vue:320
msgid "Full Volume"
msgstr ""
@ -320,6 +375,10 @@ msgstr ""
msgid "Include in Automated Assignment"
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:63
msgid "Internal notes or comments about the user, visible only on this control panel."
msgstr ""
#: ./vue/station_media/form/MediaFormBasicInfo.vue:50
msgid "International Standard Recording Code, used for licensing reports."
msgstr ""
@ -348,14 +407,18 @@ msgstr ""
msgid "Leave blank to play on every day of the week."
msgstr ""
#: ./vue/StationMedia.vue:148
#: ./vue/StationMedia.vue:154
msgid "Length"
msgstr ""
#: ./vue/RadioPlayer.vue:10
#: ./vue/RadioPlayer.vue:14
msgid "Live"
msgstr ""
#: ./vue/components/DataTable.vue:190
msgid "Loading..."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:212
msgid "Low"
msgstr ""
@ -388,7 +451,7 @@ msgstr ""
msgid "Mixer"
msgstr ""
#: ./vue/StationMedia.vue:165
#: ./vue/StationMedia.vue:171
msgid "Modified"
msgstr ""
@ -416,8 +479,12 @@ msgstr ""
msgid "MP3"
msgstr ""
#: ./vue/InlinePlayer.vue:10
#: ./vue/RadioPlayer.vue:330
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:16
msgid "Multiply the signal of the track to make it louder or quieter. Leave blank to use the system default."
msgstr ""
#: ./vue/InlinePlayer.vue:12
#: ./vue/RadioPlayer.vue:314
msgid "Mute"
msgstr ""
@ -449,10 +516,18 @@ msgstr ""
msgid "No files selected."
msgstr ""
#: ./vue/components/DataTable.vue:187
msgid "No records to display."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:4
msgid "Not Scheduled"
msgstr ""
#: ./vue/StationStreamers.vue:59
msgid "Notes"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:161
msgid "Number of Minutes Between Plays"
msgstr ""
@ -500,12 +575,12 @@ msgstr ""
msgid "Password"
msgstr ""
#: ./vue/InlinePlayer.vue:4
#: ./vue/RadioPlayer.vue:327
#: ./vue/InlinePlayer.vue:6
#: ./vue/RadioPlayer.vue:311
msgid "Pause"
msgstr ""
#: ./vue/RadioPlayer.vue:324
#: ./vue/RadioPlayer.vue:308
msgid "Play"
msgstr ""
@ -521,7 +596,7 @@ msgstr ""
msgid "Play once per hour at the specified minute."
msgstr ""
#: ./vue/StationMedia.vue:209
#: ./vue/StationMedia.vue:221
msgid "Play/Pause"
msgstr ""
@ -553,7 +628,7 @@ msgstr ""
msgid "Playlist Weight"
msgstr ""
#: ./vue/StationMedia.vue:176
#: ./vue/StationMedia.vue:184
#: ./vue/StationPlaylists.vue:6
#: ./vue/station_media/MediaToolbar.vue:8
msgid "Playlists"
@ -579,7 +654,7 @@ msgstr ""
msgid "Raw"
msgstr ""
#: ./vue/components/DataTable.vue:147
#: ./vue/components/DataTable.vue:169
msgid "Refresh rows"
msgstr ""
@ -605,7 +680,7 @@ msgstr ""
msgid "Remove"
msgstr ""
#: ./vue/StationMedia.vue:203
#: ./vue/StationMedia.vue:215
#: ./vue/station_media/MediaRenameModal.vue:19
msgid "Rename"
msgstr ""
@ -630,7 +705,7 @@ msgstr ""
msgid "Replace Album Cover Art"
msgstr ""
#: ./vue/components/DataTable.vue:150
#: ./vue/components/DataTable.vue:172
msgid "Rows per page"
msgstr ""
@ -649,6 +724,7 @@ msgstr ""
#: ./vue/station_media/MediaEditModal.vue:17
#: ./vue/station_playlists/PlaylistEditModal.vue:18
#: ./vue/station_streamers/StreamerEditModal.vue:79
msgid "Save Changes"
msgstr ""
@ -672,23 +748,23 @@ msgstr ""
msgid "Scheduling"
msgstr ""
#: ./vue/components/DataTable.vue:162
#: ./vue/components/DataTable.vue:184
msgid "Search"
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:49
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:60
msgid "Seconds from the start of the song that the AutoDJ should start playing."
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:60
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:71
msgid "Seconds from the start of the song that the AutoDJ should stop playing."
msgstr ""
#: ./vue/components/DataTable.vue:156
#: ./vue/components/DataTable.vue:178
msgid "Select all visible rows"
msgstr ""
#: ./vue/components/DataTable.vue:153
#: ./vue/components/DataTable.vue:175
msgid "Select displayed fields"
msgstr ""
@ -696,7 +772,7 @@ msgstr ""
msgid "Select File"
msgstr ""
#: ./vue/components/DataTable.vue:159
#: ./vue/components/DataTable.vue:181
msgid "Select this row"
msgstr ""
@ -712,7 +788,7 @@ msgstr ""
msgid "Settings"
msgstr ""
#: ./vue/StationMedia.vue:162
#: ./vue/StationMedia.vue:168
msgid "Size"
msgstr ""
@ -771,6 +847,7 @@ msgid "Start Streaming"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:30
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:35
msgid "Start Time"
msgstr ""
@ -782,6 +859,26 @@ msgstr ""
msgid "Stop Streaming"
msgstr ""
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:62
msgid "Streamer Broadcasts"
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:47
msgid "Streamer Display Name"
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:32
msgid "Streamer password"
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:19
msgid "Streamer Username"
msgstr ""
#: ./vue/StationStreamers.vue:4
msgid "Streamer/DJ Accounts"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:136
msgid "Sunday"
msgstr ""
@ -798,15 +895,23 @@ msgstr ""
msgid "The request could not be processed."
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:27
msgid "The time period that the song should fade in. Leave blank to use the system default."
#: ./vue/station_streamers/StreamerEditModal.vue:35
msgid "The streamer will use this password to connect to the radio server."
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:22
msgid "The streamer will use this username to connect to the radio server."
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:38
msgid "The time period that the song should fade in. Leave blank to use the system default."
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:49
msgid "The time period that the song should fade out. Leave blank to use the system default."
msgstr ""
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:16
#: ./vue/station_media/form/MediaFormAdvancedSettings.vue:27
msgid "The time that this song should overlap its surrounding songs when fading. Leave blank to use the system default."
msgstr ""
@ -828,9 +933,17 @@ msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:86
#: ./vue/station_playlists/form/PlaylistFormSource.vue:86
#: ./vue/station_playlists/form/PlaylistFormSource.vue:115
#: ./vue/station_streamers/StreamerEditModal.vue:27
#: ./vue/station_streamers/StreamerEditModal.vue:40
#: ./vue/station_streamers/StreamerEditModal.vue:55
#: ./vue/station_streamers/StreamerEditModal.vue:68
msgid "This field is required."
msgstr ""
#: ./vue/station_streamers/StreamerEditModal.vue:50
msgid "This is the informal display name that will be shown in API responses if the streamer/DJ is live."
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormSchedule.vue:7
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 ""
@ -890,16 +1003,17 @@ msgstr ""
msgid "Use Asynchronous Worker"
msgstr ""
#: ./vue/StationStreamers.vue:57
#: ./vue/webcaster/settings.vue:169
msgid "Username"
msgstr ""
#: ./vue/StationMedia.vue:212
#: ./vue/StationMedia.vue:224
msgid "View tracks in playlist"
msgstr ""
#: ./vue/InlinePlayer.vue:81
#: ./vue/RadioPlayer.vue:333
#: ./vue/RadioPlayer.vue:317
msgid "Volume"
msgstr ""

View File

@ -14,7 +14,8 @@ class DjOffCommand extends CommandAbstract
SymfonyStyle $io,
EntityManager $em,
Adapters $adapters,
int $stationId
int $stationId,
string $djUser = ''
) {
$station = $em->find(Entity\Station::class, $stationId);
@ -25,7 +26,8 @@ class DjOffCommand extends CommandAbstract
$adapter = $adapters->getBackendAdapter($station);
if ($adapter instanceof Liquidsoap) {
$adapter->toggleLiveStatus($station, false);
$io->write($adapter->onDisconnect($station, $djUser));
return 0;
}
$io->write('received');

View File

@ -14,7 +14,8 @@ class DjOnCommand extends CommandAbstract
SymfonyStyle $io,
EntityManager $em,
Adapters $adapters,
int $stationId
int $stationId,
string $djUser = ''
) {
$station = $em->find(Entity\Station::class, $stationId);
@ -25,7 +26,8 @@ class DjOnCommand extends CommandAbstract
$adapter = $adapters->getBackendAdapter($station);
if ($adapter instanceof Liquidsoap) {
$adapter->toggleLiveStatus($station, true);
$io->write($adapter->onConnect($station, $djUser));
return 0;
}
$io->write('received');

View File

@ -104,13 +104,16 @@ class InternalController
$adapter = $request->getStationBackend();
if ($adapter instanceof Liquidsoap) {
$station = $request->getStation();
$user = $request->getParam('dj-user', '');
$this->logger->info('Received "DJ connected" ping from Liquidsoap.', [
'station_id' => $station->getId(),
'station_name' => $station->getName(),
'dj' => $user,
]);
$adapter->toggleLiveStatus($station, true);
$response->getBody()->write($adapter->onConnect($station, $user));
return $response;
}
$response->getBody()->write('received');
@ -124,13 +127,16 @@ class InternalController
$adapter = $request->getStationBackend();
if ($adapter instanceof Liquidsoap) {
$station = $request->getStation();
$user = $request->getParam('dj-user', '');
$this->logger->info('Received "DJ disconnected" ping from Liquidsoap.', [
'station_id' => $station->getId(),
'station_name' => $station->getName(),
'dj' => $user,
]);
$adapter->toggleLiveStatus($station, false);
$response->getBody()->write($adapter->onDisconnect($station, $user));
return $response;
}
$response->getBody()->write('received');

View File

@ -3,8 +3,14 @@ namespace App\Controller\Api\Stations;
use App\Entity;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Radio\Filesystem;
use App\Utilities;
use Azura\Doctrine\Paginator;
use Azura\Http\RouterInterface;
use OpenApi\Annotations as OA;
use Psr\Http\Message\ResponseInterface;
class StreamersController extends AbstractStationApiCrudController
{
@ -95,6 +101,140 @@ class StreamersController extends AbstractStationApiCrudController
* )
*/
/**
* @param ServerRequest $request
* @param Response $response
* @param string|int $station_id
* @param int $id
*
* @return ResponseInterface
*/
public function broadcastsAction(
ServerRequest $request,
Response $response,
$station_id,
$id
): ResponseInterface {
$station = $this->_getStation($request);
$streamer = $this->_getRecord($station, $id);
if (null === $streamer) {
return $response->withStatus(404)
->withJson(new Entity\Api\Error(404, __('Record not found!')));
}
$query = $this->em->createQuery(/** @lang DQL */ 'SELECT ssb
FROM App\Entity\StationStreamerBroadcast ssb
WHERE ssb.station = :station AND ssb.streamer = :streamer
ORDER BY ssb.timestampStart DESC')
->setParameter('station', $station)
->setParameter('streamer', $streamer);
$paginator = new Paginator($query);
$paginator->setFromRequest($request);
$is_bootgrid = $paginator->isFromBootgrid();
$router = $request->getRouter();
$paginator->setPostprocessor(function ($row) use ($is_bootgrid, $router) {
/** @var Entity\StationStreamerBroadcast $row */
$return = $this->_normalizeRecord($row);
if (!empty($row->getRecordingPath())) {
$return['links'] = [
'download' => $router->fromHere(
'api:stations:streamer:broadcast:download',
['broadcast_id' => $row->getId()],
[],
true
),
];
}
if ($is_bootgrid) {
return Utilities::flattenArray($return, '_');
}
return $return;
});
return $paginator->write($response);
}
/**
* @param ServerRequest $request
* @param Response $response
* @param Filesystem $filesystem
* @param string|int $station_id
* @param int $id
* @param int $broadcast_id
*
* @return ResponseInterface
*/
public function downloadBroadcastAction(
ServerRequest $request,
Response $response,
Filesystem $filesystem,
$station_id,
$id,
$broadcast_id
): ResponseInterface {
$station = $this->_getStation($request);
$streamer = $this->_getRecord($station, $id);
/** @var Entity\StationStreamerBroadcast|null $broadcast */
$broadcast = $this->em->getRepository(Entity\StationStreamerBroadcast::class)->findOneBy([
'id' => $broadcast_id,
'station' => $station,
'streamer' => $streamer,
]);
if (null === $broadcast) {
return $response->withStatus(404)
->withJson(new Entity\Api\Error(404, __('Record not found!')));
}
$recordingPath = $broadcast->getRecordingPath();
if (empty($recordingPath)) {
return $response->withStatus(400)
->withJson(new Entity\Api\Error(400, __('No recording available.')));
}
$fs = $filesystem->getForStation($station);
$filename = basename($recordingPath);
$recordingPath = 'recordings://' . $recordingPath;
$fh = $fs->readStream($recordingPath);
$fileMeta = $fs->getMetadata($recordingPath);
try {
$fileMime = $fs->getMimetype($recordingPath);
} catch (\Exception $e) {
$fileMime = 'application/octet-stream';
}
return $response->withFileDownload($fh, $filename, $fileMime)
->withHeader('Content-Length', $fileMeta['size'])
->withHeader('X-Accel-Buffering', 'no');
}
/**
* @inheritDoc
*/
protected function _viewRecord($record, RouterInterface $router)
{
$return = parent::_viewRecord($record, $router);
$return['links']['broadcasts'] = $router->fromHere(
'api:stations:streamer:broadcasts',
['id' => $record->getId()],
[],
true
);
return $return;
}
/**
* @inheritDoc
*/

View File

@ -0,0 +1,37 @@
<?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 Version20200127071620 extends AbstractMigration
{
public function getDescription() : string
{
return '';
}
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('CREATE TABLE station_streamer_broadcasts (id INT AUTO_INCREMENT NOT NULL, station_id INT DEFAULT NULL, streamer_id INT DEFAULT NULL, timestamp_start INT NOT NULL, timestamp_end INT NOT NULL, recording_path VARCHAR(255) DEFAULT NULL, INDEX IDX_76169D6621BDB235 (station_id), INDEX IDX_76169D6625F432AD (streamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE station_streamer_broadcasts ADD CONSTRAINT FK_76169D6621BDB235 FOREIGN KEY (station_id) REFERENCES station (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE station_streamer_broadcasts ADD CONSTRAINT FK_76169D6625F432AD FOREIGN KEY (streamer_id) REFERENCES station_streamers (id) ON DELETE CASCADE');
}
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('DROP TABLE station_streamer_broadcasts');
}
}

View File

@ -0,0 +1,68 @@
<?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 Version20200129010322 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function preup(Schema $schema): void
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql',
'Migration can only be executed safely on \'mysql\'.');
// Deleting duplicate streamers to avoid constraint errors in subsequent update
$streamers = $this->connection->fetchAll('SELECT * FROM station_streamers ORDER BY station_id, id ASC');
$accounts = [];
foreach ($streamers as $row) {
$station_id = $row['station_id'];
$username = $row['streamer_username'];
if (isset($accounts[$station_id][$username])) {
$this->connection->delete('station_streamers', ['id' => $row['id']]);
} else {
$accounts[$station_id][$username] = $username;
}
}
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE station_streamers CHANGE streamer_password streamer_password VARCHAR(255) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX username_unique_idx ON station_streamers (station_id, streamer_username)');
}
public function postUp(Schema $schema): void
{
// Hash DJ passwords that are currently stored in plaintext.
$streamers = $this->connection->fetchAll('SELECT * FROM station_streamers ORDER BY station_id, id ASC');
foreach ($streamers as $row) {
$this->connection->update('station_streamers', [
'streamer_password' => password_hash($row['streamer_password'], \PASSWORD_ARGON2ID),
], ['id' => $row['id']]);
}
}
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('DROP INDEX username_unique_idx ON station_streamers');
$this->addSql('ALTER TABLE station_streamers CHANGE streamer_password streamer_password VARCHAR(50) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_general_ci`');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Entity\Repository;
use App\Entity;
use App\Radio\Adapters;
use Azura\Doctrine\Repository;
class StationStreamerRepository extends Repository
@ -13,28 +14,100 @@ class StationStreamerRepository extends Repository
* @param string $username
* @param string $password
*
* @return Entity\StationStreamer|bool
* @return bool
*/
public function authenticate(Entity\Station $station, $username, $password)
{
public function authenticate(
Entity\Station $station,
string $username = '',
string $password = ''
): bool {
// Extra safety check for the station's streamer status.
if (!$station->getEnableStreamers()) {
return false;
}
$streamer = $this->repository->findOneBy([
'station_id' => $station->getId(),
'streamer_username' => $username,
'is_active' => 1,
]);
$streamer = $this->getStreamer($station, $username);
if (!($streamer instanceof Entity\StationStreamer)) {
return false;
}
return (strcmp($streamer->getStreamerPassword(), $password) === 0)
? $streamer
: false;
return $streamer->authenticate($password);
}
/**
* @param Entity\Station $station
* @param string $username
*
* @return string|bool
*/
public function onConnect(Entity\Station $station, string $username = '')
{
// End all current streamer sessions.
$this->clearBroadcastsForStation($station);
$streamer = $this->getStreamer($station, $username);
if (!($streamer instanceof Entity\StationStreamer)) {
return false;
}
$station->setIsStreamerLive(true);
$station->setCurrentStreamer($streamer);
$this->em->persist($station);
$record = new Entity\StationStreamerBroadcast($streamer);
$this->em->persist($record);
if (Adapters::BACKEND_LIQUIDSOAP === $station->getBackendType()) {
$backendConfig = (array)$station->getBackendConfig();
$recordStreams = (bool)($backendConfig['record_streams'] ?? false);
if ($recordStreams) {
$format = $backendConfig['record_streams_format'] ?? Entity\StationMountInterface::FORMAT_MP3;
$recordingPath = $record->generateRecordingPath($format);
$this->em->persist($record);
$this->em->flush();
return $station->getRadioRecordingsDir() . '/' . $recordingPath;
}
}
$this->em->flush();
return true;
}
public function onDisconnect(Entity\Station $station): bool
{
$station->setIsStreamerLive(false);
$station->setCurrentStreamer(null);
$this->em->persist($station);
$this->em->flush();
$this->clearBroadcastsForStation($station);
return true;
}
protected function clearBroadcastsForStation(Entity\Station $station): void
{
$this->em->createQuery(/** @lang DQL */ 'UPDATE App\Entity\StationStreamerBroadcast ssb
SET ssb.timestampEnd = :time
WHERE ssb.station = :station
AND ssb.timestampEnd = 0')
->setParameter('time', time())
->setParameter('station', $station)
->execute();
}
protected function getStreamer(Entity\Station $station, string $username = ''): ?Entity\StationStreamer
{
/** @var Entity\StationStreamer|null $streamer */
$streamer = $this->repository->findOneBy([
'station' => $station,
'streamer_username' => $username,
'is_active' => 1,
]);
return $streamer;
}
/**
@ -44,7 +117,7 @@ class StationStreamerRepository extends Repository
*
* @return Entity\StationStreamer[]
*/
public function getStreamersDueForReactivation(int $reactivate_at = null)
public function getStreamersDueForReactivation(int $reactivate_at = null): array
{
$reactivate_at = $reactivate_at ?? time();

View File

@ -661,22 +661,21 @@ class Station
$this->radio_base_dir = $this->_truncateString(trim($new_dir));
}
/**
* @return string
*/
public function getRadioAlbumArtDir(): string
{
return $this->radio_base_dir . '/album_art';
}
/**
* @return string
*/
public function getRadioTempDir(): string
{
return $this->radio_base_dir . '/temp';
}
public function getRadioRecordingsDir(): string
{
return $this->radio_base_dir . '/recordings';
}
/**
* Given an absolute path, return a path relative to this station's media directory.
*

View File

@ -2,7 +2,6 @@
namespace App\Entity;
use App\Annotations\AuditLog;
use App\Validator\Constraints as AppAssert;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Annotations as OA;
use Symfony\Component\Validator\Constraints as Assert;
@ -10,7 +9,9 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* Station streamers (DJ accounts) allowed to broadcast to a station.
*
* @ORM\Table(name="station_streamers")
* @ORM\Table(name="station_streamers", uniqueConstraints={
* @ORM\UniqueConstraint(name="username_unique_idx", columns={"station_id", "streamer_username"})
* })
* @ORM\Entity()
* @ORM\HasLifecycleCallbacks
*
@ -57,9 +58,9 @@ class StationStreamer
protected $streamer_username;
/**
* @ORM\Column(name="streamer_password", type="string", length=50, nullable=false)
* @ORM\Column(name="streamer_password", type="string", length=255, nullable=false)
*
* @AppAssert\StreamerPassword()
* @Assert\NotBlank()
* @OA\Property(example="")
* @var string
*/
@ -133,7 +134,7 @@ class StationStreamer
/**
* @param string $streamer_username
*/
public function setStreamerUsername(string $streamer_username)
public function setStreamerUsername(string $streamer_username): void
{
$this->streamer_username = $this->_truncateString($streamer_username, 50);
}
@ -143,15 +144,24 @@ class StationStreamer
*/
public function getStreamerPassword(): string
{
return $this->streamer_password;
return '';
}
/**
* @param string $streamer_password
* @param string|null $streamer_password
*/
public function setStreamerPassword(string $streamer_password)
public function setStreamerPassword(?string $streamer_password): void
{
$this->streamer_password = $this->_truncateString($streamer_password, 50);
$streamer_password = trim($streamer_password);
if (!empty($streamer_password)) {
$this->streamer_password = password_hash($streamer_password, \PASSWORD_ARGON2ID);
}
}
public function authenticate(string $password): bool
{
return password_verify($password, $this->streamer_password);
}
/**
@ -185,7 +195,7 @@ class StationStreamer
/**
* @param null|string $comments
*/
public function setComments(string $comments = null)
public function setComments(string $comments = null): void
{
$this->comments = $comments;
}
@ -201,7 +211,7 @@ class StationStreamer
/**
* @param bool $is_active
*/
public function setIsActive(bool $is_active)
public function setIsActive(bool $is_active): void
{
$this->is_active = $is_active;
@ -222,7 +232,7 @@ class StationStreamer
/**
* @param int|null $reactivate_at
*/
public function setReactivateAt(?int $reactivate_at)
public function setReactivateAt(?int $reactivate_at): void
{
$this->reactivate_at = $reactivate_at;
}
@ -232,7 +242,7 @@ class StationStreamer
*
* @param int $seconds
*/
public function deactivateFor(int $seconds)
public function deactivateFor(int $seconds): void
{
$this->is_active = false;
$this->reactivate_at = time() + $seconds;

View File

@ -0,0 +1,157 @@
<?php
namespace App\Entity;
use Cake\Chronos\Chronos;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Annotations as OA;
/**
* Each individual broadcast associated with a streamer.
*
* @ORM\Table(name="station_streamer_broadcasts")
* @ORM\Entity()
* @ORM\HasLifecycleCallbacks
*
* @OA\Schema(type="object")
*/
class StationStreamerBroadcast
{
use Traits\TruncateStrings;
/**
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*
* @OA\Property(example=1)
* @var int
*/
protected $id;
/**
* @ORM\ManyToOne(targetEntity="Station", inversedBy="streamer_broadcasts")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="station_id", referencedColumnName="id", onDelete="CASCADE")
* })
* @var Station
*/
protected $station;
/**
* @ORM\ManyToOne(targetEntity="StationStreamer", inversedBy="broadcasts")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="streamer_id", referencedColumnName="id", onDelete="CASCADE")
* })
* @var StationStreamer
*/
protected $streamer;
/**
* @ORM\Column(name="timestamp_start", type="integer")
* @var int
*/
protected $timestampStart = 0;
/**
* @ORM\Column(name="timestamp_end", type="integer")
* @var int
*/
protected $timestampEnd = 0;
/**
* @ORM\Column(name="recording_path", type="string", length=255, nullable=true)
* @var string|null
*/
protected $recordingPath;
public function __construct(StationStreamer $streamer)
{
$this->streamer = $streamer;
$this->station = $streamer->getStation();
$this->timestampStart = time();
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return Station
*/
public function getStation(): Station
{
return $this->station;
}
/**
* @return StationStreamer
*/
public function getStreamer(): StationStreamer
{
return $this->streamer;
}
/**
* @return int
*/
public function getTimestampStart(): int
{
return $this->timestampStart;
}
/**
* @return int
*/
public function getTimestampEnd(): int
{
return $this->timestampEnd;
}
/**
* @param int $timestampEnd
*/
public function setTimestampEnd(int $timestampEnd): void
{
$this->timestampEnd = $timestampEnd;
}
/**
* @return string|null
*/
public function getRecordingPath(): ?string
{
return $this->recordingPath;
}
public function generateRecordingPath(string $format = StationMountInterface::FORMAT_MP3): string
{
switch (strtolower($format)) {
case StationMountInterface::FORMAT_AAC:
$ext = 'mp4';
break;
case StationMountInterface::FORMAT_OGG:
$ext = 'ogg';
break;
case StationMountInterface::FORMAT_OPUS:
$ext = 'opus';
break;
case StationMountInterface::FORMAT_MP3:
default:
$ext = 'mp3';
break;
}
$now = Chronos::createFromTimestamp($this->timestampStart, $this->station->getTimezone());
$this->recordingPath = $this->streamer->getStreamerUsername() . '/' . $now->format('Ymd-His') . '.' . $ext;
return $this->recordingPath;
}
}

View File

@ -19,6 +19,8 @@ class AutoDJ implements EventSubscriberInterface
protected Entity\Repository\StationPlaylistMediaRepository $spmRepo;
protected Entity\Repository\StationStreamerRepository $streamerRepo;
protected EventDispatcher $dispatcher;
protected Filesystem $filesystem;
@ -29,6 +31,7 @@ class AutoDJ implements EventSubscriberInterface
EntityManager $em,
Entity\Repository\SongRepository $songRepo,
Entity\Repository\StationPlaylistMediaRepository $spmRepo,
Entity\Repository\StationStreamerRepository $streamerRepo,
EventDispatcher $dispatcher,
Filesystem $filesystem,
Logger $logger
@ -36,6 +39,7 @@ class AutoDJ implements EventSubscriberInterface
$this->em = $em;
$this->songRepo = $songRepo;
$this->spmRepo = $spmRepo;
$this->streamerRepo = $streamerRepo;
$this->dispatcher = $dispatcher;
$this->filesystem = $filesystem;
$this->logger = $logger;
@ -119,8 +123,7 @@ class AutoDJ implements EventSubscriberInterface
}
// The "get next song" function is only called when a streamer is not live
$station->setIsStreamerLive(false);
$this->em->persist($station);
$this->streamerRepo->onDisconnect($station);
$this->em->flush();
}

View File

@ -743,36 +743,48 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
return;
}
$settings = (array)$station->getBackendConfig();
$charset = $settings['charset'] ?? 'UTF-8';
$dj_mount = $settings['dj_mount_point'] ?? '/';
$recordLiveStreams = $settings['record_streams'] ?? false;
$event->appendLines([
'# DJ Authentication',
'live_enabled = ref false',
'last_authenticated_dj = ref ""',
'live_dj = ref ""',
'',
'def dj_auth(user,password) =',
' log("Authenticating DJ: #{user}")',
' ret = ' . $this->_getApiUrlCommand($station, 'auth', ['dj-user' => 'user', 'dj-password' => 'password']),
' log("AzuraCast DJ Auth Response: #{ret}")',
' bool_of_string(ret)',
' authed = bool_of_string(ret)',
' if authed then',
' last_authenticated_dj := user',
' end',
' authed',
'end',
'',
'live_enabled = ref false',
'',
'def live_connected(header) =',
' log("DJ Source connected! #{header}")',
' dj = !last_authenticated_dj',
' log("DJ Source connected! Last authenticated DJ: #{dj} - #{header}")',
' live_enabled := true',
' ret = ' . $this->_getApiUrlCommand($station, 'djon'),
' live_dj := dj',
' ret = ' . $this->_getApiUrlCommand($station, 'djon', ['dj-user' => 'dj']),
' log("AzuraCast Live Connected Response: #{ret}")',
'end',
'',
'def live_disconnected() =',
' log("DJ Source disconnected!")',
' live_enabled := false',
' ret = ' . $this->_getApiUrlCommand($station, 'djoff'),
' dj = !live_dj',
' log("DJ Source disconnected! Current live DJ: #{dj}")',
' ret = ' . $this->_getApiUrlCommand($station, 'djoff', ['dj-user' => 'dj']),
' log("AzuraCast Live Disconnected Response: #{ret}")',
' live_enabled := false',
' last_authenticated_dj := ""',
' live_dj := ""',
'end',
]);
$settings = (array)$station->getBackendConfig();
$charset = $settings['charset'] ?? 'UTF-8';
$dj_mount = $settings['dj_mount_point'] ?? '/';
$harbor_params = [
'"' . $this->_cleanUpString($dj_mount) . '"',
'id="' . $this->_getVarName('input_streamer', $station) . '"',
@ -802,6 +814,32 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
'radio = switch(id="' . $this->_getVarName('live_switch',
$station) . '", track_sensitive=false, [({!live_enabled}, live), ({true}, radio)])',
]);
if ($recordLiveStreams) {
$recordLiveStreamsFormat = $settings['record_streams_format'] ?? Entity\StationMountInterface::FORMAT_MP3;
$recordLiveStreamsBitrate = (int)($settings['record_streams_bitrate'] ?? 128);
$formatString = $this->getOutputFormatString($recordLiveStreamsFormat, $recordLiveStreamsBitrate);
$event->appendLines([
'# Record Live Broadcasts',
'stop_recording_f = ref (fun () -> ())',
'',
'def start_recording(path) =',
' output_live_recording = output.file('.$formatString.', fallible=true, reopen_on_metadata=false, "#{path}", live)',
' stop_recording_f := fun () -> source.shutdown(output_live_recording)',
'end',
'',
'def stop_recording() =',
' f = !stop_recording_f',
' f ()',
' stop_recording_f := fun () -> ()',
'end',
'',
'server.register(namespace="recording", description="Start recording.", usage="recording.start <filename>", "start", fun (s) -> begin start_recording(s) "Done!" end)',
'server.register(namespace="recording", description="Stop recording.", usage="recording.stop", "stop", fun (s) -> begin stop_recording() "Done!" end)',
]);
}
}
public function writeCustomConfiguration(WriteLiquidsoapConfiguration $event)
@ -913,7 +951,7 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
continue;
}
$ls_config[] = $this->_getOutputString($station, $mount_row, 'local_' . $i);
$ls_config[] = $this->getOutputString($station, $mount_row, 'local_' . $i);
}
$event->appendLines($ls_config);
@ -928,34 +966,15 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
*
* @return string
*/
protected function _getOutputString(Entity\Station $station, Entity\StationMountInterface $mount, $id = '')
protected function getOutputString(Entity\Station $station, Entity\StationMountInterface $mount, $id = ''): string
{
$settings = (array)$station->getBackendConfig();
$charset = $settings['charset'] ?? 'UTF-8';
$bitrate = ($mount->getAutodjBitrate() ?? 128);
switch (strtolower($mount->getAutodjFormat())) {
case $mount::FORMAT_AAC:
$afterburner = ($bitrate >= 160) ? 'true' : 'false';
$aot = ($bitrate >= 96) ? 'mpeg4_aac_lc' : 'mpeg4_he_aac_v2';
$output_format = '%fdkaac(channels=2, samplerate=44100, bitrate=' . $bitrate . ', afterburner=' . $afterburner . ', aot="' . $aot . '", sbr_mode=true)';
break;
case $mount::FORMAT_OGG:
$output_format = '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')';
break;
case $mount::FORMAT_OPUS:
$output_format = '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="none", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")';
break;
case $mount::FORMAT_MP3:
default:
$output_format = '%mp3(samplerate=44100, stereo=true, bitrate=' . $bitrate . ', id3v2=true)';
break;
}
$output_format = $this->getOutputFormatString(
$mount->getAutodjFormat(),
$mount->getAutodjBitrate() ?? 128
);
$output_params = [];
$output_params[] = $output_format;
@ -995,6 +1014,31 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
return 'output.icecast(' . implode(', ', $output_params) . ')';
}
protected function getOutputFormatString(string $format, int $bitrate = 128): string
{
switch (strtolower($format)) {
case Entity\StationMountInterface::FORMAT_AAC:
$afterburner = ($bitrate >= 160) ? 'true' : 'false';
$aot = ($bitrate >= 96) ? 'mpeg4_aac_lc' : 'mpeg4_he_aac_v2';
return '%fdkaac(channels=2, samplerate=44100, bitrate=' . $bitrate . ', afterburner=' . $afterburner . ', aot="' . $aot . '", sbr_mode=true)';
break;
case Entity\StationMountInterface::FORMAT_OGG:
return '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')';
break;
case Entity\StationMountInterface::FORMAT_OPUS:
return '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="none", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")';
break;
case Entity\StationMountInterface::FORMAT_MP3:
default:
return '%mp3(samplerate=44100, stereo=true, bitrate=' . $bitrate . ', id3v2=true)';
break;
}
}
public function writeRemoteBroadcastConfiguration(WriteLiquidsoapConfiguration $event)
{
$station = $event->getStation();
@ -1013,7 +1057,7 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
continue;
}
$ls_config[] = $this->_getOutputString($station, $remote_row, 'relay_' . $i);
$ls_config[] = $this->getOutputString($station, $remote_row, 'relay_' . $i);
}
$event->appendLines($ls_config);
@ -1112,8 +1156,11 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
);
}
public function authenticateStreamer(Entity\Station $station, $user, $pass): string
{
public function authenticateStreamer(
Entity\Station $station,
string $user = '',
string $pass = ''
): string {
// Allow connections using the exact broadcast source password.
$fe_config = (array)$station->getFrontendConfig();
if (!empty($fe_config['source_pw']) && strcmp($fe_config['source_pw'], $pass) === 0) {
@ -1128,36 +1175,39 @@ class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
[$user, $pass] = explode(':', $pass);
}
$streamer = $this->streamerRepo->authenticate($station, $user, $pass);
if ($streamer instanceof Entity\StationStreamer) {
Logger::getInstance()->debug('DJ successfully authenticated.', ['username' => $user]);
try {
// Successful authentication: update current streamer on station.
$station->setCurrentStreamer($streamer);
$this->em->persist($station);
$this->em->flush();
} catch (Exception $e) {
Logger::getInstance()->error('Error when calling post-DJ-authentication functions.', [
'file' => $e->getFile(),
'line' => $e->getLine(),
'code' => $e->getCode(),
]);
}
return 'true';
}
return 'false';
return $this->streamerRepo->authenticate($station, $user, $pass)
? 'true'
: 'false';
}
public function toggleLiveStatus(Entity\Station $station, $is_streamer_live = true): void
{
$station->setIsStreamerLive($is_streamer_live);
public function onConnect(
Entity\Station $station,
string $user = ''
): string {
$resp = $this->streamerRepo->onConnect($station, $user);
$this->em->persist($station);
$this->em->flush();
if (is_string($resp)) {
$this->command($station, 'recording.start '.$resp);
return 'recording';
}
return $resp ? 'true' : 'false';
}
public function onDisconnect(
Entity\Station $station,
string $user = ''
): string {
$backendConfig = (array)$station->getBackendConfig();
$recordStreams = (bool)($backendConfig['record_streams'] ?? false);
if ($recordStreams) {
$this->command($station, 'recording.stop');
}
return $this->streamerRepo->onDisconnect($station)
? 'true'
: 'false';
}
public function getWebStreamingUrl(Entity\Station $station, UriInterface $base_url): UriInterface

View File

@ -85,6 +85,7 @@ class Configuration
$station->getRadioPlaylistsDir(),
$station->getRadioConfigDir(),
$station->getRadioTempDir(),
$station->getRadioRecordingsDir(),
];
foreach ($radio_dirs as $radio_dir) {
if (!file_exists($radio_dir) && !mkdir($radio_dir, 0777) && !is_dir($radio_dir)) {

View File

@ -34,6 +34,7 @@ class Filesystem
'albumart' => $station->getRadioAlbumArtDir(),
'playlists' => $station->getRadioPlaylistsDir(),
'config' => $station->getRadioConfigDir(),
'recordings' => $station->getRadioRecordingsDir(),
'temp' => $station->getRadioTempDir(),
];

View File

@ -1,19 +0,0 @@
<?php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class StreamerPassword extends Constraint
{
public $message;
public function __construct($options = null)
{
$this->message = __('Password cannot contain the following characters: %s', '{{ chars }}');
parent::__construct($options);
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class StreamerPasswordValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof StreamerPassword) {
throw new UnexpectedTypeException($constraint, StreamerPassword::class);
}
// custom constraints should ignore null and empty values to allow
// other constraints (NotBlank, NotNull, etc.) take care of that
if (null === $value || '' === $value) {
return;
}
if (!is_string($value)) {
throw new UnexpectedValueException($value, 'string');
}
$avoid_chars = ['@', ':', ',', '#'];
if (0 < count(array_intersect(str_split($value), $avoid_chars))) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ chars }}', implode(', ', $avoid_chars))
->addViolation();
}
}
}

View File

@ -0,0 +1,17 @@
<?php
$props = [
'listUrl' => $router->fromHere('api:stations:streamers'),
]
?>
var station_streamers;
$(function () {
station_streamers = new Vue({
el: '#station-streamers',
render: function (createElement) {
return createElement(StationStreamers.default, {
props: <?=json_encode($props) ?>
});
}
});
});

View File

@ -1,91 +1,59 @@
<?php $this->layout('main', ['title' => __('Streamer/DJ Accounts'), 'manual' => true]) ?>
<?php
$this->layout('main', ['title' => __('Streamer/DJ Accounts'), 'manual' => true]);
/** @var \Azura\Assets $assets */
$assets->load('station_streamers')
->addInlineJs($this->fetch('stations/streamers/index.js'));
?>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Streamer/DJ Accounts') ?></h2>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" role="button" href="<?=$router->fromHere('stations:streamers:add') ?>">
<i class="material-icons" aria-hidden="true">add</i>
<?=__('Add Streamer') ?>
</a>
</div>
<table class="table table-responsive-lg table-striped mb-0">
<colgroup>
<col width="25%">
<col width="15%">
<col width="20%">
<col width="40%">
</colgroup>
<thead>
<tr>
<th><?=__('Actions') ?></th>
<th><?=__('Username') ?></th>
<th><?=__('Display Name') ?></th>
<th><?=__('Notes') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($streamers as $row): ?>
<?php /** @var App\Entity\StationStreamer $row */ ?>
<tr class="align-middle <?php if (!$row->getIsActive()): ?>text-muted<?php endif; ?>">
<td>
<div class="btn-group btn-group-sm">
<a class="btn btn-sm btn-primary" href="<?=$router->fromHere('stations:streamers:edit', ['id' => $row->getId()]) ?>"><?=__('Edit') ?></a>
<a class="btn btn-sm btn-danger" data-confirm-title="<?=$this->e(__('Delete streamer "%s"?', $row->getStreamerUsername())) ?>" href="<?=$router->fromHere('stations:streamers:delete', ['id' => $row->getId(), 'csrf' => $csrf]) ?>"><?=__('Delete') ?></a>
</div>
</td>
<td><code><?=$this->e($row->getStreamerUsername()) ?></code></td>
<td><?=$this->e($row->getDisplayName()) ?></td>
<td><?=nl2br($this->e($row->getComments())) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div id="station-streamers"></div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Connection Information') ?></h2>
<h2 class="card-title"><?=__('Connection Information')?></h2>
</div>
<div class="card-body">
<h3 class="card-title"><?=__('IceCast Clients') ?></h3>
<h3 class="card-title"><?=__('IceCast Clients')?></h3>
<dl>
<dt class="mb-1"><?=__('Server') ?>:</dt>
<dd><code><?=$this->e($server_url) ?></code></dd>
<?php if ($ip): ?>
<dd><?=__('You may need to connect directly via your IP address, which is <code>%s</code>.', $ip) ?></dd>
<?php endif; ?>
<dt class="mb-1"><?=__('Server')?>:</dt>
<dd><code><?=$this->e($server_url)?></code></dd>
<?php if ($ip): ?>
<dd><?=__('You may need to connect directly via your IP address, which is <code>%s</code>.',
$ip)?></dd>
<?php endif; ?>
<dt class="mb-1"><?=__('Port') ?>:</dt>
<dd><code><?=(int)$stream_port ?></code></dd>
<dt class="mb-1"><?=__('Port')?>:</dt>
<dd><code><?=(int)$stream_port?></code></dd>
<dt class="mb-1"><?=__('Mount Name') ?>:</dt>
<dd><code><?=$this->e($dj_mount_point) ?></code></dd>
<dt class="mb-1"><?=__('Mount Name')?>:</dt>
<dd><code><?=$this->e($dj_mount_point)?></code></dd>
</dl>
<h3 class="card-title mt-3"><?=__('ShoutCast v1 Clients') ?></h3>
<h3 class="card-title mt-3"><?=__('ShoutCast v1 Clients')?></h3>
<dl>
<dt class="mb-1"><?=__('Server') ?>:</dt>
<dd><code><?=$this->e($server_url) ?></code></dd>
<?php if ($ip): ?>
<dd><?=__('You may need to connect directly via your IP address, which is <code>%s</code>.', $ip) ?></dd>
<?php endif; ?>
<dt class="mb-1"><?=__('Server')?>:</dt>
<dd><code><?=$this->e($server_url)?></code></dd>
<?php if ($ip): ?>
<dd><?=__('You may need to connect directly via your IP address, which is <code>%s</code>.',
$ip)?></dd>
<?php endif; ?>
<dt class="mb-1"><?=__('Port') ?>:</dt>
<dd><code><?=__('%d (%d for some clients)', (int)$stream_port, ((int)$stream_port + 1)) ?></code></dd>
<dt class="mb-1"><?=__('Port')?>:</dt>
<dd><code><?=__('%d (%d for some clients)', (int)$stream_port, ((int)$stream_port + 1))?></code>
</dd>
<dt class="mb-1"><?=__('Password') ?>:</dt>
<dt class="mb-1"><?=__('Password')?>:</dt>
<dd>
<code>dj_username:dj_password</code><br/>
<small><?=__('(DJ username and password separated by a colon)') ?></small>
<small><?=__('(DJ username and password separated by a colon)')?></small>
</dd>
</dl>
<p><?=sprintf(__('Setup instructions for broadcasting software are available <a href="%s" target="_blank">on the AzuraCast Wiki</a>.'), 'https://www.azuracast.com/help/streaming_software.html') ?></p>
<p><?=sprintf(__('Setup instructions for broadcasting software are available <a href="%s" target="_blank">on the AzuraCast Wiki</a>.'),
'https://www.azuracast.com/help/streaming_software.html')?></p>
</div>
</div>
</div>

View File

@ -7,8 +7,8 @@
"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",
"dist/lib/bootstrap-notify/bootstrap-notify.min.js": "dist/lib/bootstrap-notify/bootstrap-notify-a02f92a499.min.js",
"dist/lib/bootstrap-vue/bootstrap-vue.min.css": "dist/lib/bootstrap-vue/bootstrap-vue-d02588705d.min.css",
"dist/lib/bootstrap-vue/bootstrap-vue.min.js": "dist/lib/bootstrap-vue/bootstrap-vue-1eded317c5.min.js",
"dist/lib/bootstrap-vue/bootstrap-vue.min.css": "dist/lib/bootstrap-vue/bootstrap-vue-0a0c224f0e.min.css",
"dist/lib/bootstrap-vue/bootstrap-vue.min.js": "dist/lib/bootstrap-vue/bootstrap-vue-782e710810.min.js",
"dist/lib/bootstrap/bootstrap.bundle.min.js": "dist/lib/bootstrap/bootstrap-a5334e4752.bundle.min.js",
"dist/lib/chartjs/Chart.min.css": "dist/lib/chartjs/Chart-7d8693e997.min.css",
"dist/lib/chartjs/Chart.min.js": "dist/lib/chartjs/Chart-b5c2301eb1.min.js",
@ -17,7 +17,7 @@
"dist/lib/chosen/chosen.min.css": "dist/lib/chosen/chosen-d7ca5ca944.min.css",
"dist/lib/clipboard/clipboard.min.js": "dist/lib/clipboard/clipboard-f06c52bfdd.min.js",
"dist/lib/codemirror/codemirror.css": "dist/lib/codemirror/codemirror-fc217d502b.css",
"dist/lib/codemirror/codemirror.js": "dist/lib/codemirror/codemirror-ed9a10fe72.js",
"dist/lib/codemirror/codemirror.js": "dist/lib/codemirror/codemirror-df75d1f313.js",
"dist/lib/codemirror/css.js": "dist/lib/codemirror/css-4e1489f478.js",
"dist/lib/codemirror/material.css": "dist/lib/codemirror/material-a40a918802.css",
"dist/lib/daterangepicker/daterangepicker.css": "dist/lib/daterangepicker/daterangepicker-55e1d56082.css",
@ -25,7 +25,7 @@
"dist/lib/dirrty/jquery.dirrty.js": "dist/lib/dirrty/jquery-7fe05e7084.dirrty.js",
"dist/lib/fancybox/jquery.fancybox.min.css": "dist/lib/fancybox/jquery-a2d4258429.fancybox.min.css",
"dist/lib/fancybox/jquery.fancybox.min.js": "dist/lib/fancybox/jquery-49a6b4d019.fancybox.min.js",
"dist/lib/flowjs/flow.min.js": "dist/lib/flowjs/flow-8cdc40f63e.min.js",
"dist/lib/flowjs/flow.min.js": "dist/lib/flowjs/flow-7c9b92e725.min.js",
"dist/lib/jquery/jquery.min.js": "dist/lib/jquery/jquery-220afd743d.min.js",
"dist/lib/leaflet/leaflet.css": "dist/lib/leaflet/leaflet-6b7939304e.css",
"dist/lib/leaflet/leaflet.js": "dist/lib/leaflet/leaflet-21f4844183.js",
@ -47,10 +47,11 @@
"dist/lib/zxcvbn/zxcvbn.js": "dist/lib/zxcvbn/zxcvbn-9cf6916dc0.js",
"dist/light.css": "dist/light-a0a23d9ad3.css",
"dist/material.js": "dist/material-df68dbf23f.js",
"dist/radio_player.js": "dist/radio_player-2f046091fc.js",
"dist/station_media.js": "dist/station_media-6ece7f0f12.js",
"dist/station_playlists.js": "dist/station_playlists-dee62b5f6b.js",
"dist/vue_gettext.js": "dist/vue_gettext-9f5e925aaa.js",
"dist/radio_player.js": "dist/radio_player-4c86c8d744.js",
"dist/station_media.js": "dist/station_media-93be801afc.js",
"dist/station_playlists.js": "dist/station_playlists-040ce6dec8.js",
"dist/station_streamers.js": "dist/station_streamers-a5972d34d7.js",
"dist/vue_gettext.js": "dist/vue_gettext-542451fab1.js",
"dist/webcaster.js": "dist/webcaster-788fdd9f3d.js",
"dist/zxcvbn.js": "dist/zxcvbn-f4433cd930.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

View File

@ -2208,7 +2208,7 @@
var isWidget = classTest("CodeMirror-linewidget");
for (var node = lineView.node.firstChild, next = (void 0); node; node = next) {
next = node.nextSibling;
if (isWidget.test(node)) { lineView.node.removeChild(node); }
if (isWidget.test(node.className)) { lineView.node.removeChild(node); }
}
insertLineWidgets(cm, lineView, dims);
}
@ -6231,7 +6231,12 @@
for (var i$1 = 0; i$1 < hist.undone.length; i$1++) { if (!hist.undone[i$1].ranges) { ++undone; } }
return {undo: done, redo: undone}
},
clearHistory: function() {this.history = new History(this.history.maxGeneration);},
clearHistory: function() {
var this$1 = this;
this.history = new History(this.history.maxGeneration);
linkedDocs(this, function (doc) { return doc.history = this$1.history; }, true);
},
markClean: function() {
this.cleanGeneration = this.changeGeneration(true);
@ -6482,28 +6487,39 @@
// and insert it.
if (files && files.length && window.FileReader && window.File) {
var n = files.length, text = Array(n), read = 0;
var loadFile = function (file, i) {
if (cm.options.allowDropFileTypes &&
indexOf(cm.options.allowDropFileTypes, file.type) == -1)
{ return }
var reader = new FileReader;
reader.onload = operation(cm, function () {
var content = reader.result;
if (/[\x00-\x08\x0e-\x1f]{2}/.test(content)) { content = ""; }
text[i] = content;
if (++read == n) {
var markAsReadAndPasteIfAllFilesAreRead = function () {
if (++read == n) {
operation(cm, function () {
pos = clipPos(cm.doc, pos);
var change = {from: pos, to: pos,
text: cm.doc.splitLines(text.join(cm.doc.lineSeparator())),
text: cm.doc.splitLines(
text.filter(function (t) { return t != null; }).join(cm.doc.lineSeparator())),
origin: "paste"};
makeChange(cm.doc, change);
setSelectionReplaceHistory(cm.doc, simpleSelection(pos, changeEnd(change)));
})();
}
};
var readTextFromFile = function (file, i) {
if (cm.options.allowDropFileTypes &&
indexOf(cm.options.allowDropFileTypes, file.type) == -1) {
markAsReadAndPasteIfAllFilesAreRead();
return
}
var reader = new FileReader;
reader.onerror = function () { return markAsReadAndPasteIfAllFilesAreRead(); };
reader.onload = function () {
var content = reader.result;
if (/[\x00-\x08\x0e-\x1f]{2}/.test(content)) {
markAsReadAndPasteIfAllFilesAreRead();
return
}
});
text[i] = content;
markAsReadAndPasteIfAllFilesAreRead();
};
reader.readAsText(file);
};
for (var i = 0; i < n; ++i) { loadFile(files[i], i); }
for (var i = 0; i < files.length; i++) { readTextFromFile(files[i], i); }
} else { // Normal drop
// Don't do a replace if the drop happened inside of the selected text.
if (cm.state.draggingText && cm.doc.sel.contains(pos) > -1) {
@ -6813,6 +6829,7 @@
function endOfLine(visually, cm, lineObj, lineNo, dir) {
if (visually) {
if (cm.getOption("direction") == "rtl") { dir = -dir; }
var order = getOrder(lineObj, cm.doc.direction);
if (order) {
var part = dir < 0 ? lst(order) : order[0];
@ -7890,6 +7907,9 @@
// which point we can't mess with it anymore. Context menu is
// handled in onMouseDown for these browsers.
on(d.scroller, "contextmenu", function (e) { return onContextMenu(cm, e); });
on(d.input.getField(), "contextmenu", function (e) {
if (!d.scroller.contains(e.target)) { onContextMenu(cm, e); }
});
// Used to suppress mouse event handling when a touch happens
var touchFinished, prevTouch = {end: 0};
@ -8620,8 +8640,9 @@
var oldPos = pos;
var origDir = dir;
var lineObj = getLine(doc, pos.line);
var lineDir = visually && doc.cm && doc.cm.getOption("direction") == "rtl" ? -dir : dir;
function findNextLine() {
var l = pos.line + dir;
var l = pos.line + lineDir;
if (l < doc.first || l >= doc.first + doc.size) { return false }
pos = new Pos(l, pos.ch, pos.sticky);
return lineObj = getLine(doc, l)
@ -8635,7 +8656,7 @@
}
if (next == null) {
if (!boundToLine && findNextLine())
{ pos = endOfLine(visually, doc.cm, lineObj, pos.line, dir); }
{ pos = endOfLine(visually, doc.cm, lineObj, pos.line, lineDir); }
else
{ return false }
} else {
@ -9712,7 +9733,7 @@
addLegacyProps(CodeMirror);
CodeMirror.version = "5.50.0";
CodeMirror.version = "5.51.0";
return CodeMirror;

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

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