Skip to content
Snippets Groups Projects
Commit 8974881f authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '392-new-player' into 'develop'

Resolve "Investigate Howler.js as a way to manage low-level audio and get cross-browser compatibility"

Closes #392 and #390

See merge request funkwhale/funkwhale!351
parents 73757ff9 50fb5543
No related branches found
No related tags found
No related merge requests found
Fixed broken audio playback on Chrome and invisible volume control (#390)
Use Howler to manage audio instead of our own dirty/untested code (#392)
......@@ -21,6 +21,7 @@
"axios": "^0.17.1",
"dateformat": "^2.0.0",
"django-channels": "^1.1.6",
"howler": "^2.0.14",
"js-logger": "^1.3.0",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.4",
......
<template>
<div class="ui inverted segment player-wrapper" :style="style">
<div class="player">
<keep-alive>
<audio-track
ref="currentAudio"
v-if="renderAudio && currentTrack"
:is-current="true"
:start-time="$store.state.player.currentTime"
:autoplay="$store.state.player.playing"
:track="currentTrack">
</audio-track>
</keep-alive>
<audio-track
ref="currentAudio"
v-if="currentTrack"
:is-current="true"
:start-time="$store.state.player.currentTime"
:autoplay="$store.state.player.playing"
:key="audioKey"
:track="currentTrack">
</audio-track>
<div v-if="currentTrack" class="track-area ui unstackable items">
<div class="ui inverted item">
<div class="ui tiny image">
......@@ -160,13 +159,13 @@
import {mapState, mapGetters, mapActions} from 'vuex'
import GlobalEvents from '@/components/utils/global-events'
import ColorThief from '@/vendor/color-thief'
import {Howl} from 'howler'
import AudioTrack from '@/components/audio/Track'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
export default {
name: 'player',
components: {
TrackFavoriteIcon,
TrackPlaylistIcon,
......@@ -177,16 +176,28 @@ export default {
let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]]
return {
isShuffling: false,
renderAudio: true,
sliderVolume: this.volume,
defaultAmbiantColors: defaultAmbiantColors,
showVolume: false,
ambiantColors: defaultAmbiantColors
ambiantColors: defaultAmbiantColors,
audioKey: String(new Date()),
dummyAudio: null
}
},
mounted () {
// we trigger the watcher explicitely it does not work otherwise
this.sliderVolume = this.volume
// this is needed to unlock audio playing under some browsers,
// cf https://github.com/goldfire/howler.js#mobilechrome-playback
// but we never actually load those audio files
this.dummyAudio = new Howl({
preload: false,
autoplay: false,
src: ['noop.webm', 'noop.mp3']
})
},
destroyed () {
this.dummyAudio.unload()
},
methods: {
...mapActions({
......@@ -305,21 +316,13 @@ export default {
},
watch: {
currentTrack (newValue) {
if (!this.isShuffling) {
this.audioKey = String(new Date())
}
if (!newValue || !newValue.album.cover) {
this.ambiantColors = this.defaultAmbiantColors
}
},
currentIndex (newValue, oldValue) {
if (newValue !== oldValue) {
// why this? to ensure the audio tag is deleted and fully
// rerendered, so we don't have any issues with cached position
// or whatever
this.renderAudio = false
this.$nextTick(() => {
this.renderAudio = true
})
}
},
volume (newValue) {
this.sliderVolume = newValue
},
......@@ -385,9 +388,6 @@ export default {
.volume-control {
position: relative;
width: 12.5% !important;
.icon {
// margin: 0;
}
[type="range"] {
max-width: 70%;
position: absolute;
......@@ -395,16 +395,11 @@ export default {
left: 25%;
cursor: pointer;
}
input[type=range] {
-webkit-appearance: none;
}
input[type=range]:focus {
outline: none;
}
input[type=range]::-webkit-slider-runnable-track {
cursor: pointer;
background: white;
opacity: 0.3;
}
input[type=range]::-webkit-slider-thumb {
background: white;
......@@ -413,10 +408,6 @@ export default {
border-radius: 3px;
width: 10px;
}
input[type=range]:focus::-webkit-slider-runnable-track {
background: #white;
opacity: 0.3;
}
input[type=range]::-moz-range-track {
cursor: pointer;
background: white;
......@@ -455,7 +446,7 @@ export default {
background: white;
}
input[type=range]:focus::-ms-fill-upper {
background: #white;
background: white;
}
}
......
<template>
<audio
ref="audio"
@error="errored"
@loadeddata="loaded"
@durationchange="updateDuration"
@timeupdate="updateProgressThrottled"
@ended="ended"
preload>
<source
@error="sourceErrored"
v-for="src in srcs"
:src="src.url"
:type="src.type">
</audio>
<i />
</template>
<script>
import {mapState} from 'vuex'
import url from '@/utils/url'
import _ from 'lodash'
import url from '@/utils/url'
import {Howl} from 'howler'
// import logger from '@/logging'
export default {
......@@ -30,11 +19,44 @@ export default {
},
data () {
return {
realTrack: this.track,
sourceErrors: 0,
isUpdatingTime: false
sound: null,
isUpdatingTime: false,
progressInterval: null
}
},
mounted () {
let self = this
this.sound = new Howl({
src: this.srcs.map((s) => { return s.url }),
autoplay: false,
loop: false,
html5: true,
preload: true,
volume: this.volume,
onend: function () {
self.ended()
},
onunlock: function () {
if (this.$store.state.player.playing) {
self.sound.play()
}
},
onload: function () {
self.$store.commit('player/resetErrorCount')
self.$store.commit('player/duration', self.sound.duration())
}
})
if (this.autoplay) {
this.sound.play()
this.$store.commit('player/playing', true)
this.observeProgress(true)
}
},
destroyed () {
this.observeProgress(false)
this.sound.unload()
},
computed: {
...mapState({
playing: state => state.player.playing,
......@@ -44,7 +66,7 @@ export default {
looping: state => state.player.looping
}),
srcs: function () {
let file = this.realTrack.files[0]
let file = this.track.files[0]
if (!file) {
this.$store.dispatch('player/trackErrored')
return []
......@@ -68,90 +90,58 @@ export default {
}
},
methods: {
errored: function () {
let self = this
setTimeout(
() => { self.$store.dispatch('player/trackErrored') }
, 1000)
},
sourceErrored: function () {
this.sourceErrors += 1
if (this.sourceErrors >= this.srcs.length) {
// all sources failed
this.errored()
}
},
updateDuration: function (e) {
if (!this.$refs.audio) {
return
}
this.$store.commit('player/duration', this.$refs.audio.duration)
},
loaded: function () {
if (!this.$refs.audio) {
return
}
this.$refs.audio.volume = this.volume
this.$store.commit('player/resetErrorCount')
if (this.isCurrent) {
this.$store.commit('player/duration', this.$refs.audio.duration)
if (this.startTime) {
this.setCurrentTime(this.startTime)
}
if (this.autoplay) {
this.$store.commit('player/playing', true)
this.$refs.audio.play()
}
}
},
updateProgress: function () {
this.isUpdatingTime = true
if (this.$refs.audio) {
this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime)
if (this.sound && this.sound.state() === 'loaded') {
this.$store.dispatch('player/updateProgress', this.sound.seek())
}
},
ended: function () {
let onlyTrack = this.$store.state.queue.tracks.length === 1
if (this.looping === 1 || (onlyTrack && this.looping === 2)) {
this.setCurrentTime(0)
this.$refs.audio.play()
observeProgress: function (enable) {
let self = this
if (enable) {
if (self.progressInterval) {
clearInterval(self.progressInterval)
}
self.progressInterval = setInterval(() => {
self.updateProgress()
}, 1000)
} else {
this.$store.dispatch('player/trackEnded', this.realTrack)
clearInterval(self.progressInterval)
}
},
setCurrentTime (t) {
if (t < 0 | t > this.duration) {
return
}
if (t === this.$refs.audio.currentTime) {
if (t === this.sound.seek()) {
return
}
if (t === 0) {
this.updateProgressThrottled.cancel()
}
this.$refs.audio.currentTime = t
this.sound.seek(t)
},
ended: function () {
let onlyTrack = this.$store.state.queue.tracks.length === 1
if (this.looping === 1 || (onlyTrack && this.looping === 2)) {
this.sound.seek(0)
this.sound.play()
} else {
this.$store.dispatch('player/trackEnded', this.track)
}
}
},
watch: {
track: _.debounce(function (newValue) {
this.realTrack = newValue
this.setCurrentTime(0)
this.$refs.audio.load()
}, 1000, {leading: true, trailing: true}),
playing: function (newValue) {
if (newValue === true) {
this.$refs.audio.play()
this.sound.play()
} else {
this.$refs.audio.pause()
}
},
'$store.state.queue.currentIndex' () {
if (this.$store.state.player.playing) {
this.$refs.audio.play()
this.sound.pause()
}
this.observeProgress(newValue)
},
volume: function (newValue) {
this.$refs.audio.volume = newValue
this.sound.volume(newValue)
},
currentTime (newValue) {
if (!this.isUpdatingTime) {
......
......@@ -56,7 +56,8 @@ export default {
fetch ({dispatch, state, commit, rootState}, url) {
// will fetch favorites by batches from API to have them locally
let params = {
user: rootState.auth.profile.id
user: rootState.auth.profile.id,
page_size: 50
}
url = url || 'favorites/tracks/'
return axios.get(url, {params: params}).then((response) => {
......
......@@ -3493,6 +3493,10 @@ hosted-git-info@^2.1.4:
version "2.6.0"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.6.0.tgz#23235b29ab230c576aab0d4f13fc046b0b038222"
howler@^2.0.14:
version "2.0.14"
resolved "https://registry.yarnpkg.com/howler/-/howler-2.0.14.tgz#28e37800fea002fea147a3ca033660c4f1288a99"
html-comment-regex@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment