From 1f08475017f6edb47d0506247ad66d515956309f Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Mon, 23 Jul 2018 23:21:08 +0200
Subject: [PATCH] Fix #390 and #392: rewritten audio player internal logic,
 broken volume control under Chrome

---
 changes/changelog.d/390.bugfix        |   1 +
 changes/changelog.d/392.enhancement   |   1 +
 front/package.json                    |   1 +
 front/src/components/audio/Player.vue |  65 +++++-------
 front/src/components/audio/Track.vue  | 144 ++++++++++++--------------
 front/yarn.lock                       |   4 +
 6 files changed, 102 insertions(+), 114 deletions(-)
 create mode 100644 changes/changelog.d/390.bugfix
 create mode 100644 changes/changelog.d/392.enhancement

diff --git a/changes/changelog.d/390.bugfix b/changes/changelog.d/390.bugfix
new file mode 100644
index 00000000..df80a8b1
--- /dev/null
+++ b/changes/changelog.d/390.bugfix
@@ -0,0 +1 @@
+Fixed broken audio playback on Chrome and invisible volume control (#390)
diff --git a/changes/changelog.d/392.enhancement b/changes/changelog.d/392.enhancement
new file mode 100644
index 00000000..11ca09ac
--- /dev/null
+++ b/changes/changelog.d/392.enhancement
@@ -0,0 +1 @@
+Use Howler to manage audio instead of our own dirty/untested code (#392)
diff --git a/front/package.json b/front/package.json
index 9837479b..80d9d81a 100644
--- a/front/package.json
+++ b/front/package.json
@@ -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",
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 704121d9..8e4185c0 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -1,16 +1,15 @@
 <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;
   }
 }
 
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
index 9be38337..e22cb62c 100644
--- a/front/src/components/audio/Track.vue
+++ b/front/src/components/audio/Track.vue
@@ -1,24 +1,13 @@
 <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) {
diff --git a/front/yarn.lock b/front/yarn.lock
index 5c69d788..6bc14175 100644
--- a/front/yarn.lock
+++ b/front/yarn.lock
@@ -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"
-- 
GitLab