Merge pull request #693 from chme/web_player

Add option to stream audio from web interface
This commit is contained in:
Christian Meffert 2019-02-18 13:18:14 +01:00 committed by GitHub
commit 09c1cf1563
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 294 additions and 50 deletions

View File

@ -46,8 +46,6 @@ extern struct event_base *evbase_httpd;
#define STREAMING_SILENCE_INTERVAL 1
// Buffer size for transmitting from player to httpd thread
#define STREAMING_RAWBUF_SIZE (STOB(AIRTUNES_V2_PACKET_SAMPLES))
// Should prevent that we keep transcoding to dead connections
#define STREAMING_CONNECTION_TIMEOUT 60
// Linked list of mp3 streaming requests
struct streaming_session {
@ -74,6 +72,7 @@ static struct player_status streaming_player_status;
static int streaming_player_changed;
static int streaming_pipe[2];
static void
streaming_fail_cb(struct evhttp_connection *evcon, void *arg)
{
@ -243,6 +242,8 @@ streaming_request(struct evhttp_request *req, struct httpd_uri_parsed *uri_parse
evhttp_add_header(output_headers, "Pragma", "no-cache");
evhttp_add_header(output_headers, "Expires", "Mon, 31 Aug 2015 06:00:00 GMT");
evhttp_add_header(output_headers, "icy-name", name);
evhttp_add_header(output_headers, "Access-Control-Allow-Origin", "*");
evhttp_add_header(output_headers, "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
// TODO ICY metaint
evhttp_send_reply_start(req, HTTP_OK, "OK");
@ -263,7 +264,6 @@ streaming_request(struct evhttp_request *req, struct httpd_uri_parsed *uri_parse
session->next = streaming_sessions;
streaming_sessions = session;
evhttp_connection_set_timeout(evcon, STREAMING_CONNECTION_TIMEOUT);
evhttp_connection_set_closecb(evcon, streaming_fail_cb, session);
return 0;

54
web-src/src/audio.js Normal file
View File

@ -0,0 +1,54 @@
/**
* Audio handler object
* Taken from https://github.com/rainner/soma-fm-player (released under MIT licence)
*/
export default {
_audio: new Audio(),
_context: new AudioContext(),
_source: null,
_gain: null,
// setup audio routing
setupAudio () {
this._source = this._context.createMediaElementSource(this._audio)
this._gain = this._context.createGain()
this._source.connect(this._gain)
this._gain.connect(this._context.destination)
this._audio.addEventListener('canplaythrough', e => {
this._audio.play()
})
this._audio.addEventListener('canplay', e => {
this._audio.play()
})
return this._audio
},
// set audio volume
setVolume (volume) {
if (!this._gain) return
volume = parseFloat(volume) || 0.0
volume = (volume < 0) ? 0 : volume
volume = (volume > 1) ? 1 : volume
this._gain.gain.value = volume
},
// play audio source url
playSource (source) {
this.stopAudio()
this._context.resume().then(() => {
console.log('playSource')
this._audio.src = String(source || '') + '?x=' + Date.now()
this._audio.crossOrigin = 'anonymous'
this._audio.load()
})
},
// stop playing audio
stopAudio () {
try { this._audio.pause() } catch (e) {}
try { this._audio.stop() } catch (e) {}
try { this._audio.close() } catch (e) {}
}
}

View File

@ -98,7 +98,7 @@ export default {
},
open_genre: function () {
this.$router.push({ path: '/music/genres/' + this.item.genre })
this.$router.push({ name: 'Genre', params: { genre: this.item.name } })
}
}
}

View File

@ -37,9 +37,9 @@
<span class="heading">Year</span>
<span class="title is-6">{{ track.year }}</span>
</p>
<p>
<p v-if="track.genre">
<span class="heading">Genre</span>
<span class="title is-6">{{ track.genre }}</span>
<a class="title is-6 has-text-link" @click="open_genre">{{ track.genre }}</a>
</p>
<p>
<span class="heading">Track / Disc</span>
@ -55,7 +55,7 @@
</p>
<p>
<span class="heading">Type</span>
<span class="title is-6">{{ track.media_kind }} - {{ track.data_kind }}</span>
<span class="title is-6">{{ track.media_kind }} - {{ track.data_kind }} <span class="has-text-weight-normal" v-if="track.data_kind === 'spotify'">(<a @click="open_spotify_artist">artist</a>, <a @click="open_spotify_album">album</a>)</span></span>
</p>
<p>
<span class="heading">Added at</span>
@ -88,6 +88,7 @@
<script>
import webapi from '@/webapi'
import SpotifyWebApi from 'spotify-web-api-js'
export default {
name: 'ModalDialogTrack',
@ -96,6 +97,7 @@ export default {
data () {
return {
spotify_track: {}
}
},
@ -131,6 +133,20 @@ export default {
this.$router.push({ path: '/music/artists/' + this.track.album_artist_id })
},
open_genre: function () {
this.$router.push({ name: 'Genre', params: { genre: this.track.genre } })
},
open_spotify_artist: function () {
this.$emit('close')
this.$router.push({ path: '/music/spotify/artists/' + this.spotify_track.artists[0].id })
},
open_spotify_album: function () {
this.$emit('close')
this.$router.push({ path: '/music/spotify/albums/' + this.spotify_track.album.id })
},
mark_new: function () {
webapi.library_track_update(this.track.id, { 'play_count': 'reset' }).then(() => {
this.$emit('play_count_changed')
@ -144,6 +160,20 @@ export default {
this.$emit('close')
})
}
},
watch: {
'track' () {
if (this.track && this.track.data_kind === 'spotify') {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(this.$store.state.spotify.webapi_token)
spotifyApi.getTrack(this.track.path.slice(this.track.path.lastIndexOf(':') + 1)).then((response) => {
this.spotify_track = response
})
} else {
this.spotify_track = {}
}
}
}
}
</script>

View File

@ -3,7 +3,7 @@
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<span class="icon fd-has-action" :class="{ 'has-text-grey-light': !output.selected }" v-on:click="set_enabled"><i class="mdi mdi-18px" v-bind:class="type_class"></i></span>
<a class="button is-white is-small"><span class="icon fd-has-action" :class="{ 'has-text-grey-light': !output.selected }" v-on:click="set_enabled"><i class="mdi mdi-18px" v-bind:class="type_class"></i></span></a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">

View File

@ -39,7 +39,9 @@
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<span class="icon"><i class="mdi mdi-18px mdi-volume-high"></i></span>
<a class="button is-white is-small" @click="toggle_mute_volume">
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-volume-off': player.volume <= 0, 'mdi-volume-high': player.volume > 0 }"></i></span>
</a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
@ -60,6 +62,31 @@
<hr class="navbar-divider">
<nav-bar-item-output v-for="output in outputs" :key="output.id" :output="output"></nav-bar-item-output>
<hr class="navbar-divider">
<div class="navbar-item">
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<a class="button is-white is-small" :class="{ 'is-loading': loading }"><span class="icon fd-has-action" :class="{ 'has-text-grey-light': !playing && !loading, 'is-loading': loading }" @click="togglePlay"><i class="mdi mdi-18px mdi-radio-tower"></i></span></a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="/stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider
class="slider fd-has-action"
min="0"
max="100"
step="1"
:disabled="!playing"
:value="stream_volume"
@change="set_stream_volume">
</range-slider>
</div>
</div>
</div>
</div>
</div>
<hr class="navbar-divider">
<div class="navbar-item">
<div class="level is-mobile">
@ -104,6 +131,7 @@
<script>
import webapi from '@/webapi'
import _audio from '@/audio'
import NavBarItemOutput from './NavBarItemOutput'
import PlayerButtonPlayPause from './PlayerButtonPlayPause'
import PlayerButtonNext from './PlayerButtonNext'
@ -120,7 +148,12 @@ export default {
data () {
return {
search_query: ''
search_query: '',
old_volume: 0,
playing: false,
loading: false,
stream_volume: 10
}
},
@ -163,10 +196,91 @@ export default {
webapi.player_volume(newVolume)
},
toggle_mute_volume: function () {
if (this.player.volume > 0) {
this.set_volume(0)
} else {
this.set_volume(this.old_volume)
}
},
open_about: function () {
this.$store.commit(types.SHOW_BURGER_MENU, false)
this.$router.push({ path: '/about' })
},
setupAudio: function () {
const a = _audio.setupAudio()
a.addEventListener('waiting', e => {
this.playing = false
this.loading = true
})
a.addEventListener('playing', e => {
this.playing = true
this.loading = false
})
a.addEventListener('ended', e => {
this.playing = false
this.loading = false
})
a.addEventListener('error', e => {
this.closeAudio()
this.$store.dispatch('add_notification', { text: 'HTTP stream error: failed to load stream or stopped loading due to network problem', type: 'danger' })
this.playing = false
this.loading = false
})
},
// close active audio
closeAudio: function () {
_audio.stopAudio()
this.playing = false
},
playChannel: function () {
if (this.playing) {
return
}
const channel = '/stream.mp3'
this.loading = true
_audio.playSource(channel)
_audio.setVolume(this.stream_volume / 100)
},
togglePlay: function () {
if (this.loading) {
return
}
if (this.playing) {
return this.closeAudio()
}
return this.playChannel()
},
set_stream_volume: function (newVolume) {
this.stream_volume = newVolume
_audio.setVolume(this.stream_volume / 100)
}
},
watch: {
'$store.state.player.volume' () {
if (this.player.volume > 0) {
this.old_volume = this.player.volume
}
}
},
// on app mounted
mounted () {
this.setupAudio()
},
// on app destroyed
destroyed () {
this.closeAudio()
}
}
</script>

View File

@ -6,14 +6,12 @@
</template>
<template slot="heading-right">
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_album_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
</a>
<!--
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span>Play</span>
</a>
-->
</div>
</template>
<template slot="content">
@ -26,6 +24,7 @@
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-album :show="show_album_details_modal" :album="album" @close="show_album_details_modal = false" />
</template>
</content-with-heading>
</template>
@ -35,6 +34,7 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import webapi from '@/webapi'
const albumData = {
@ -54,7 +54,7 @@ const albumData = {
export default {
name: 'PageAlbum',
mixins: [ LoadDataBeforeEnterMixin(albumData) ],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack },
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum },
data () {
return {
@ -62,7 +62,9 @@ export default {
tracks: [],
show_details_modal: false,
selected_track: {}
selected_track: {},
show_album_details_modal: false
}
},

View File

@ -4,9 +4,14 @@
<p class="title is-4">{{ artist.name }}</p>
</template>
<template slot="heading-right">
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
</a>
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_artist_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
</a>
</div>
</template>
<template slot="content">
<p class="heading has-text-centered-mobile">{{ artist.album_count }} albums | <a class="has-text-link" @click="open_tracks">{{ artist.track_count }} tracks</a></p>
@ -18,6 +23,7 @@
</template>
</list-item-album>
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
<modal-dialog-artist :show="show_artist_details_modal" :artist="artist" @close="show_artist_details_modal = false" />
</template>
</content-with-heading>
</template>
@ -27,6 +33,7 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import webapi from '@/webapi'
const artistData = {
@ -46,7 +53,7 @@ const artistData = {
export default {
name: 'PageArtist',
mixins: [ LoadDataBeforeEnterMixin(artistData) ],
components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum },
components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum, ModalDialogArtist },
data () {
return {
@ -54,7 +61,9 @@ export default {
albums: {},
show_details_modal: false,
selected_album: {}
selected_album: {},
show_artist_details_modal: false
}
},

View File

@ -5,12 +5,17 @@
<div class="title is-4 has-text-grey has-text-weight-normal">{{ album.artist }}</div>
</template>
<template slot="heading-right">
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon">
<i class="mdi mdi-play"></i>
</span>
<span>Play</span>
</a>
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_album_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon">
<i class="mdi mdi-play"></i>
</span>
<span>Play</span>
</a>
</div>
</template>
<template slot="content">
<p class="heading has-text-centered-mobile">{{ album.track_count }} tracks</p>
@ -22,6 +27,7 @@
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-album :show="show_album_details_modal" :album="album" :media_kind="'audiobook'" @close="show_album_details_modal = false" />
</template>
</content-with-heading>
</template>
@ -31,6 +37,7 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import webapi from '@/webapi'
const albumData = {
@ -50,7 +57,7 @@ const albumData = {
export default {
name: 'PageAudiobook',
mixins: [ LoadDataBeforeEnterMixin(albumData) ],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack },
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum },
data () {
return {
@ -58,7 +65,9 @@ export default {
tracks: [],
show_details_modal: false,
selected_track: {}
selected_track: {},
show_album_details_modal: false
}
},

View File

@ -6,9 +6,14 @@
<p class="title is-7 has-text-grey">{{ current_directory }}</p>
</template>
<template slot="heading-right">
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span>Play</span>
</a>
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="open_directory_dialog({ 'path': current_directory })">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span>Play</span>
</a>
</div>
</template>
<template slot="content">
<div class="media" v-if="$route.query.directory" @click="open_parent_directory()">

View File

@ -72,7 +72,7 @@ export default {
methods: {
open_tracks: function () {
this.show_details_modal = false
this.$router.push({ path: '/music/genres/' + this.name + '/tracks' })
this.$router.push({ name: 'GenreTracks', params: { genre: this.name } })
},
play: function () {

View File

@ -71,7 +71,7 @@ export default {
methods: {
open_genre: function () {
this.show_details_modal = false
this.$router.push({ path: '/music/genres/' + this.genre })
this.$router.push({ name: 'Genre', params: { genre: this.genre } })
},
play: function () {

View File

@ -4,9 +4,14 @@
<div class="title is-4">{{ playlist.name }}</div>
</template>
<template slot="heading-right">
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
</a>
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_playlist_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
</a>
</div>
</template>
<template slot="content">
<p class="heading has-text-centered-mobile">{{ tracks.length }} tracks</p>
@ -18,6 +23,7 @@
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-playlist :show="show_playlist_details_modal" :playlist="playlist" @close="show_playlist_details_modal = false" />
</template>
</content-with-heading>
</template>
@ -27,6 +33,7 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import webapi from '@/webapi'
const playlistData = {
@ -46,7 +53,7 @@ const playlistData = {
export default {
name: 'PagePlaylist',
mixins: [ LoadDataBeforeEnterMixin(playlistData) ],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack },
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogPlaylist },
data () {
return {
@ -54,7 +61,9 @@ export default {
tracks: [],
show_details_modal: false,
selected_track: {}
selected_track: {},
show_playlist_details_modal: false
}
},

View File

@ -4,12 +4,17 @@
<div class="title is-4">{{ album.name }}</div>
</template>
<template slot="heading-right">
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon">
<i class="mdi mdi-play"></i>
</span>
<span>Play</span>
</a>
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_album_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon">
<i class="mdi mdi-play"></i>
</span>
<span>Play</span>
</a>
</div>
</template>
<template slot="content">
<p class="heading has-text-centered-mobile">{{ album.track_count }} tracks</p>
@ -31,6 +36,7 @@
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" @play_count_changed="reload_tracks" />
<modal-dialog-album :show="show_album_details_modal" :album="album" :media_kind="'podcast'" @close="show_album_details_modal = false" />
</template>
</content-with-heading>
</template>
@ -40,6 +46,7 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import RangeSlider from 'vue-range-slider'
import webapi from '@/webapi'
@ -60,7 +67,7 @@ const albumData = {
export default {
name: 'PagePodcast',
mixins: [ LoadDataBeforeEnterMixin(albumData) ],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, RangeSlider },
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, RangeSlider, ModalDialogAlbum },
data () {
return {
@ -68,7 +75,9 @@ export default {
tracks: [],
show_details_modal: false,
selected_track: {}
selected_track: {},
show_album_details_modal: false
}
},

View File

@ -24,10 +24,13 @@ module.exports = {
// localhost:3689
proxy: {
'/api': {
target: 'http://localhost:3689',
target: 'http://localhost:3689'
},
'/artwork': {
target: 'http://localhost:3689',
target: 'http://localhost:3689'
},
'/stream.mp3': {
target: 'http://localhost:3689'
}
}
}