<template>
  <div
    class="aplayer"
    :class="{
      'aplayer-narrow': mini,
      'aplayer-withlist' : !mini && list.length > 0,
      'aplayer-withlrc': !mini && (!!$slots.display || showLrc),
      'aplayer-float': isFloatMode,
      'aplayer-loading': isPlaying && isLoading
    }"
    :style="floatStyleObj"
  >
    <div class="aplayer-body">
      <thumbnail
        :pic="currentMusic.pic"
        :playing="isPlaying"
        :enable-drag="isFloatMode"
        :theme="currentMusic.theme || theme"
        @toggleplay="toggle"
        @dragbegin="onDragBegin"
        @dragging="onDragAround"
        @adaptingTheme="setSelfAdaptingTheme"
      />
      <div class="aplayer-info" v-show="!mini">
        <div class="aplayer-music">
          <span class="aplayer-title">{{ currentMusic.title || 'Untitled' }}</span>
          <span class="aplayer-author">{{ currentMusic.artist || currentMusic.author || 'Unknown' }}</span>
        </div>
        <slot name="display" :current-music="currentMusic" :play-stat="playStat">
          <lyrics :lrc-source="currentMusic.lrc" :play-stat="playStat" v-if="showLrc && currentMusic.lrc" />
        </slot>
        <controls
          :shuffle="shouldShuffle"
          :repeat="repeatMode"
          :stat="playStat"
          :volume="audioVolume"
          :muted="isAudioMuted"
          :theme="currentTheme"
          :showList="showList"
          :isMobile="isMobile"
          @toggleshuffle="shouldShuffle = !shouldShuffle"
          @togglelist="showList = !showList"
          @togglemute="toggleMute"
          @setvolume="setAudioVolume"
          @dragbegin="onProgressDragBegin"
          @dragend="onProgressDragEnd"
          @dragging="onProgressDragging"
          @nextmode="setNextMode"
        />
      </div>
    </div>
    <audio ref="audio"></audio>
    <music-list
      :show="showList && !mini"
      :current-music="currentMusic"
      :music-list="list"
      :play-index="playIndex"
      :listMaxHeight="listMaxHeight"
      :theme="currentTheme"
      @selectsong="onSelectSong"
    />
  </div>
</template>
<script>
  import Thumbnail from './components/aplayer-thumbnail.vue'
  import MusicList from './components/aplayer-list.vue'
  import Controls from './components/aplayer-controller.vue'
  import Lyrics from './components/aplayer-lrc.vue'
  import Hls from 'hls.js'
  import { warn } from './utils'

  // mutex playing instance
  let activeMutex = null


  const REPEAT = {
    NONE: 'none',
    MUSIC: 'music',
    LIST: 'list',
    NO_REPEAT: 'no_repeat',
    REPEAT_ONE: 'repeat_one',
    REPEAT_ALL: 'repeat_all',
  };

  const VueAPlayer = {
    name: 'APlayer',
    disableVersionBadge: false,
    components: {
      Thumbnail,
      Controls,
      MusicList,
      Lyrics,
    },
    props: {
      music: {
        type: Object,
        required: true,
        validator (song) {
          return song.src
        },
      },
      list: {
        type: Array,
        default () {
          return []
        },
      },
      mini: {
        type: Boolean,
        default: false,
      },
      showLrc: {
        type: Boolean,
        default: false,
      },
      mutex: {
        type: Boolean,
        default: true,
      },
      theme: {
        type: String,
        default: '#41b883',
      },

      listMaxHeight: String,
      /**
       * @since 1.4.1
       * Fold playlist initially
       */
      listFolded: {
        type: Boolean,
        default: false,
      },

      /**
       * @since 1.2.0 Float mode
       */
      float: {
        type: Boolean,
        default: false,
      },

      // Audio attributes as props
      // since 1.4.0
      // autoplay muted preload volume
      // autoplay is not observable

      /**
       * @since 1.4.0
       * not observable
       */
      autoplay: {
        type: Boolean,
        default: false,
      },

      /**
       * @since 1.4.0
       * observable, sync
       */
      muted: {
        type: Boolean,
        default: false,
      },
      /**
       * @since 1.4.0
       * observable
       */
      preload: {
        type: String,
        default: 'none'
      },

      /**
       * @since 1.4.0
       * observable, sync
       */
      volume: {
        type: Number,
        default: 0.5,
        validator (value) {
          return value >= 0 && value <= 1
        },
      },

      // play order control
      // since 1.5.0

      /**
       * @since 1.5.0
       * @see https://support.apple.com/en-us/HT207230
       * twoWay
       */
      shuffle: {
        type: Boolean,
        default: false,
      },
      /**
       * @since 1.5.0
       * @see https://support.apple.com/en-us/HT207230
       * twoWay
       */
      repeat: {
        type: String,
        default: REPEAT.NO_REPEAT,
      }
    },
    data () {
      return {
        internalMusic: this.music,
        isPlaying: false,
        isSeeking: false,
        wasPlayingBeforeSeeking: false,
        isMobile: /mobile/i.test(window.navigator.userAgent),
        playStat: {
          duration: 0,
          loadedTime: 0,
          playedTime: 0,
        },
        showList: !this.listFolded,

        // handle Promise returned from audio.play()
        // @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
        audioPlayPromise: Promise.resolve(),
        rejectPlayPromise: Promise.reject(),

        // @since 1.2.0 float mode

        floatOriginX: 0,
        floatOriginY: 0,
        floatOffsetLeft: 0,
        floatOffsetTop: 0,


        // @since 1.3.0 self adapting theme
        selfAdaptingTheme: null,


        // @since 1.4.0
        // sync muted, volume

        internalMuted: this.muted,
        internalVolume: this.volume,

        // @since 1.4.1
        // Loading indicator
        isLoading: false,


        // @since 1.5.1
        // sync shuffle, repeat
        internalShuffle: this.shuffle,
        internalRepeat: this.repeat,
        // for shuffling
        shuffledList: [],
        hls: null
      }
    },
    computed: {
      // alias for $refs.audio
      audio () {
        return this.$refs.audio
      },

      // sync music
      currentMusic: {
        get () {
          return this.internalMusic
        },
        set (val) {
          this.$emit('update:music', val)
          this.internalMusic = val
        },
      },
      // props wrappers

      currentTheme () {
        return this.selfAdaptingTheme || this.currentMusic.theme || this.theme
      },
      isFloatMode () {
        return this.float && !this.isMobile
      },
      shouldAutoplay () {
        if (this.isMobile) return false
        return this.autoplay
      },

      // useful

      floatStyleObj () {
        // transform: translate(floatOffsetLeft, floatOffsetY)
        return {
          transform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
          webkitTransform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
        }
      },
      currentPicStyleObj () {
        if (this.currentMusic && this.currentMusic.pic) {
          return {
            backgroundImage: `url(${this.currentMusic.pic})`,
          }
        }
        return {}
      },
      loadProgress () {
        if (this.playStat.duration === 0) return 0
        return this.playStat.loadedTime / this.playStat.duration
      },
      playProgress () {
        if (this.playStat.duration === 0) return 0
        return this.playStat.playedTime / this.playStat.duration
      },
      playIndex: {
        get () {
          return this.shuffledList.indexOf(this.currentMusic)
        },
        set (val) {
          this.currentMusic = this.shuffledList[val % this.shuffledList.length]
        },
      },

      // since 1.4.0
      // sync muted, volume

      isAudioMuted: {
        get () {
          return this.internalMuted
        },
        set (val) {
          this.$emit('update:muted', val)
          this.internalMuted = val
        },
      },
      audioVolume: {
        get () {
          return this.internalVolume
        },
        set (val) {
          this.$emit('update:volume', val)
          this.internalVolume = val
        },
      },


      // since 1.5.0
      // sync shuffle, repeat
      shouldShuffle: {
        get () {
          return this.internalShuffle
        },
        set (val) {
          this.$emit('update:shuffle', val)
          this.internalShuffle = val
        },
      },
      repeatMode: {
        get () {
          switch (this.internalRepeat) {
            case REPEAT.NONE:
            case REPEAT.NO_REPEAT:
              return REPEAT.NO_REPEAT
            case REPEAT.MUSIC:
            case REPEAT.REPEAT_ONE:
              return REPEAT.REPEAT_ONE
            default:
              return REPEAT.REPEAT_ALL
          }
        },
        set (val) {
          this.$emit('update:repeat', val)
          this.internalRepeat = val
        },
      },
    },
    methods: {
      // Float mode

      onDragBegin () {
        this.floatOriginX = this.floatOffsetLeft
        this.floatOriginY = this.floatOffsetTop
      },
      onDragAround ({ offsetLeft, offsetTop }) {
        this.floatOffsetLeft = this.floatOriginX + offsetLeft
        this.floatOffsetTop = this.floatOriginY + offsetTop
      },

      // functions

      setNextMode () {
        if (this.repeatMode === REPEAT.REPEAT_ALL) {
          this.repeatMode = REPEAT.REPEAT_ONE
        } else if (this.repeatMode === REPEAT.REPEAT_ONE) {
          this.repeatMode = REPEAT.NO_REPEAT
        } else {
          this.repeatMode = REPEAT.REPEAT_ALL
        }
      },
      thenPlay () {
        this.$nextTick(() => {
          this.play()
        })
      },

      toggle () {
        if (!this.audio.paused) {
          this.pause()
        } else {
          this.play()
        }
      },
      play () {
        if (this.mutex) {
          if (activeMutex && activeMutex !== this) {
            activeMutex.pause()
          }
          activeMutex = this
        }
        // handle .play() Promise
        const audioPlayPromise = this.audio.play()
        if (audioPlayPromise) {
          return this.audioPlayPromise = new Promise((resolve, reject) => {
            // rejectPlayPromise is to force reject audioPlayPromise if it's still pending when pause() is called
            this.rejectPlayPromise = reject
            audioPlayPromise.then((res) => {
              this.rejectPlayPromise = null
              resolve(res)
            }).catch(warn)
          })
        }
      },
      pause () {
        this.audioPlayPromise
          .then(() => {
            this.audio.pause()
          })
          // Avoid force rejection throws Uncaught
          .catch(() => {
            this.audio.pause()
          })

        // audioPlayPromise is still pending
        if (this.rejectPlayPromise) {
          // force reject playPromise
          this.rejectPlayPromise()
          this.rejectPlayPromise = null
        }
      },
      switch(index) {
        this.playIndex = index
        this.thenPlay()
      },
      // progress bar

      onProgressDragBegin (val) {
        this.wasPlayingBeforeSeeking = this.isPlaying
        this.pause()
        this.isSeeking = true

        // handle load failures
        if (!isNaN(this.audio.duration)) {
          this.audio.currentTime = this.audio.duration * val
        }
      },
      onProgressDragging (val) {
        if (isNaN(this.audio.duration)) {
          this.playStat.playedTime = 0
        } else {
          this.audio.currentTime = this.audio.duration * val
        }
      },
      onProgressDragEnd (val) {
        this.isSeeking = false
        if (this.wasPlayingBeforeSeeking) {
          this.thenPlay()
        }
      },

      // volume

      toggleMute () {
        this.setAudioMuted(!this.audio.muted)
      },
      setAudioMuted (val) {
        this.audio.muted = val
      },
      setAudioVolume (val) {
        this.audio.volume = val
        if (val > 0) {
          this.setAudioMuted(false)
        }
      },

      // playlist

      getShuffledList () {
        if (!this.list.length) {
          return [this.internalMusic]
        }
        let unshuffled = [...this.list]
        if (!this.internalShuffle || unshuffled.length <= 1) {
          return unshuffled
        }

        let indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
        if (unshuffled.length === 2 && indexOfCurrentMusic !== -1) {
          if (indexOfCurrentMusic === 0) {
            return unshuffled
          } else {
            return [this.internalMusic, unshuffled[0]]
          }
        }
        // shuffle list
        // @see https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
        for (let i = unshuffled.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1))
          const tmp = unshuffled[i]
          unshuffled[i] = unshuffled[j]
          unshuffled[j] = tmp
        }

        // take currentMusic to first
        if (indexOfCurrentMusic !== -1) {
          indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
          if (indexOfCurrentMusic !== 0) {
            [unshuffled[0], unshuffled[indexOfCurrentMusic]] = [unshuffled[indexOfCurrentMusic], unshuffled[0]]
          }
        }

        return unshuffled
      },

      onSelectSong (song) {
        if (this.currentMusic === song) {
          this.toggle()
        } else {
          this.currentMusic = song
          this.thenPlay()
        }
      },

      // event handlers
      // for keeping up with audio states

      onAudioPlay () {
        this.isPlaying = true
      },
      onAudioPause () {
        this.isPlaying = false
      },
      onAudioWaiting () {
        this.isLoading = true
      },
      onAudioCanplay () {
        this.isLoading = false
      },
      onAudioDurationChange () {
        if (this.audio.duration !== 1) {
          this.playStat.duration = this.audio.duration
        }
      },
      onAudioProgress () {
        if (this.audio.buffered.length) {
          this.playStat.loadedTime = this.audio.buffered.end(this.audio.buffered.length - 1)
        } else {
          this.playStat.loadedTime = 0
        }
      },
      onAudioTimeUpdate () {
        this.playStat.playedTime = this.audio.currentTime
      },
      onAudioSeeking () {
        this.playStat.playedTime = this.audio.currentTime
      },
      onAudioSeeked () {
        this.playStat.playedTime = this.audio.currentTime
      },
      onAudioVolumeChange () {
        this.audioVolume = this.audio.volume
        this.isAudioMuted = this.audio.muted
      },
      onAudioEnded () {
        // determine next song according to shuffle and repeat
        if (this.repeatMode === REPEAT.REPEAT_ALL) {
          if (this.shouldShuffle && this.playIndex === this.shuffledList.length - 1) {
            this.shuffledList = this.getShuffledList()
          }
          this.playIndex++
          this.thenPlay()
        } else if (this.repeatMode === REPEAT.REPEAT_ONE) {
          this.thenPlay()
        } else {
          this.playIndex++
          if (this.playIndex !== 0) {
            this.thenPlay()
          } else if (this.shuffledList.length === 1) {
            this.audio.currentTime = 0
          }
        }
      },

      initAudio () {

        // since 1.4.0 Audio attributes as props

        this.audio.muted = this.muted
        this.audio.preload = this.preload
        this.audio.volume = this.volume


        // since 1.4.0 Emit as many native audio events
        // @see https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events

        const mediaEvents = [
          'abort',
          'canplay', 'canplaythrough',
          'durationchange',
          'emptied', 'encrypted', 'ended', 'error',
          'interruptbegin', 'interruptend',
          'loadeddata', 'loadedmetadata', 'loadstart',
          'mozaudioavailable',
          'pause', 'play', 'playing', 'progress',
          'ratechange',
          'seeked', 'seeking', 'stalled', 'suspend',
          'timeupdate',
          'volumechange',
          'waiting',
        ]
        mediaEvents.forEach(event => {
          this.audio.addEventListener(event, e => this.$emit(event, e))
        })


        // event handlers
        // they don't emit native media events

        this.audio.addEventListener('play', this.onAudioPlay)
        this.audio.addEventListener('pause', this.onAudioPause)
        this.audio.addEventListener('abort', this.onAudioPause)
        this.audio.addEventListener('waiting', this.onAudioWaiting)
        this.audio.addEventListener('canplay', this.onAudioCanplay)
        this.audio.addEventListener('progress', this.onAudioProgress)
        this.audio.addEventListener('durationchange', this.onAudioDurationChange)
        this.audio.addEventListener('seeking', this.onAudioSeeking)
        this.audio.addEventListener('seeked', this.onAudioSeeked)
        this.audio.addEventListener('timeupdate', this.onAudioTimeUpdate)
        this.audio.addEventListener('volumechange', this.onAudioVolumeChange)
        this.audio.addEventListener('ended', this.onAudioEnded)


        if (this.currentMusic) {
          this.audio.src = this.currentMusic.src || this.currentMusic.url
        }
      },
      setSelfAdaptingTheme (color) {
        this.selfAdaptingTheme = color
      }
    },
    watch: {
      music (music) {
        this.internalMusic = music
      },

      currentMusic: {
        handler (music) {
          const src = music.src || music.url
          // HLS support
          if (/\.m3u8(?=(#|\?|$))/.test(src)) {
            if (this.audio.canPlayType('application/x-mpegURL') || this.audio.canPlayType('application/vnd.apple.mpegURL')) {
              this.audio.src = src
            } else {
              if (Hls.isSupported()) {
                if (!this.hls) {
                  this.hls = new Hls()
                }
                this.hls.loadSource(src)
                this.hls.attachMedia(this.audio)
              } else {
                warn('HLS is not supported on your browser')
                this.audio.src = src
              }
            }
          } else {
            this.audio.src = src
          }
          // self-adapting theme color
        },
      },

      // since 1.4.0
      // observe muted, preload, volume
      isAudioMuted (val) {
        this.audio.muted = val
      },
      preload (val) {
        this.audio.preload = val
      },
      audioVolume (val) {
        this.audio.volume = val
      },

      // sync muted, volume

      muted (val) {
        this.internalMuted = val
      },
      volume (val) {
        this.internalVolume = val
      },


      // sync shuffle, repeat
      shuffle (val) {
        this.internalShuffle = val
      },
      repeat (val) {
        this.internalRepeat = val
      },
    },
    created () {
      this.shuffledList = this.getShuffledList()
    },
    mounted () {
      this.initAudio()
      if (this.autoplay) this.play()
    },
    beforeDestroy () {
      if (activeMutex === this) {
        activeMutex = null
      }
      if (this.hls) {
        this.hls.destroy()
      }
    },
  }

  export default VueAPlayer

</script>

<style lang="less" scoped>
  @import "./less/variables";

  .aplayer {
    font-family: Arial, Helvetica, sans-serif;
    color: #000;
    background-color: #fff;
    margin: 5px;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.07), 0 1px 5px 0 rgba(0, 0, 0, 0.1);
    border-radius: 2px;
    overflow: hidden;
    user-select: none;
    line-height: initial;

    * {
      box-sizing: content-box;
    }

    .aplayer-lrc-content {
      display: none;
    }

    .aplayer-body {
      display: flex;

      position: relative;
      .aplayer-info {
        flex-grow: 1;
        display: flex;
        flex-direction: column;

        text-align: start;
        padding: 14px 7px 0 10px;
        height: @aplayer-height;
        box-sizing: border-box;
        overflow: hidden;

        .aplayer-music {
          flex-grow: 1;

          overflow: hidden;
          white-space: nowrap;
          text-overflow: ellipsis;
          margin-left: 5px;
          user-select: text;
          cursor: default;
          padding-bottom: 2px;

          .aplayer-title {
            font-size: 14px;
          }

          .aplayer-author {
            font-size: 12px;
            color: #666;
            margin-left: 10px;
          }
        }

        .aplayer-lrc {
          z-index: 0;
        }
      }
    }

    // Mini mode
    &.aplayer-narrow {
      width: @aplayer-height;
    }

    &.aplayer-withlrc {
      .aplayer-body {
        .aplayer-pic {
          height: @aplayer-height-lrc;
          width: @aplayer-height-lrc;
        }

        .aplayer-info {
          height: @aplayer-height-lrc;
        }

        .aplayer-info {
          padding: 10px 7px 0 7px;
        }
      }
    }

    &.aplayer-withlist {
      .aplayer-body {
        .aplayer-info {
          border-bottom: 1px solid #e9e9e9;
        }

        .aplayer-controller .aplayer-time .aplayer-icon.aplayer-icon-menu {
          display: block;
        }
      }
    }

    /* floating player on top */
    position: relative;
    &.aplayer-float {
      z-index: 1;
    }
  }

  @keyframes aplayer-roll {
    0% {
      left: 0
    }
    100% {
      left: -100%
    }
  }
</style>