有一段时间没有写文章了,实在是懒的,另外一个原因也是工作中比较忙,有点闲时间就补充写写新功能,给网站上加一个浮窗播放器,想法也是看的微信上列表的音乐播放的功能,正好这边也是需要的,自己琢磨的整了一个。使用浮窗播放之前,最好切换页面是能做到不刷新的效果,也就是 pjax 实现,这样的话在进入的别的页面时刷新音乐就会暂停,不过本文也不是说 pjax 的,等有机会补上。

先说功能需求

简单的说一下需要实现的功能;

  1. 点击音乐列表时出现播放浮窗的播放器,播放时有等待,暂停和播放中的按钮 icon 的变换。
  2. 点击按钮播放暂停等切换状态时,列表内和浮窗同步切换。
  3. 按住拖拽浮窗播放器时,会自动跟随鼠标移动,释放时播放器会自动吸顶左边或者右边。
  4. 播放器拖出屏幕时自动收回,拖拽跟随侧边时出现渐隐效果,并吸附到侧边收缩。
  5. 保存拖拽之后的状态和位置,以便下次播放直接在之前的位置显示。

大致上面的功能就是微信上的了,可以先看一下做出来的效果图,当然有些临界的条件并没有跟微信做的一致,但是不影响使用,我觉得现在的效果够用了。

player

我这里可以分两个部分说一下,HTML 部分可以略过,都是常用的简单布局,不过要注意的是背景的模糊效果需要图片blur处理。

播放器的拖拽部分

拖拽的时候需要考虑的几点是,手势的判断和临界点的处理,还有拖拽之后的动画效果,这三个拆分出来其实不难。
我们在使用拖拽使用的最多的监听鼠标的按下和移动以及释放的事件,针对每个事件去做每一步,实现起来就轻松多了,拆分一下;

  1. 鼠标按下时:记录鼠标按下的位置和当前的播放器的位置坐标,可以用getBoundingClientRect() 方法获取当前元素的位置,顺便在监听windowmousemovemouseup事件。

(为什么要监听window而不是播放器元素的mousemove,是因为当鼠标移动过快,鼠标超出播放器之后而停止移动的尴尬。)

  1. 鼠标移动时:拿到开始按下记录的坐标和移动时的坐标clientX,clientY相减,这样会得到鼠标具体移动的距离,当然还别忘记加上播放器元素本身的left或者top值。在这个过程中就需要做临界点检测,当超出的时候重置就可以了,然后就是做播放器的移动,设置css的transform: translate3d开启3d硬件加速,最好的话不要设置元素的topleft值。最后顺便实时保存一下移动的坐标值,这里保存最好就放在播放器的元素上,方便后面取到就用。
  2. 鼠标释放时:获取当前释放时播放器元素的位置,通过屏幕的宽度计算释放是在左边还是右边释放,这样可以拿到变化的值,用上一步移动过程中实时保存的坐标值和变化的值做一个动画吸附效果。这里用到张鑫旭的tween动画算法,具体文章可以看这里

最后,动画完成是再次记录元素的坐标位置,保证下次刷新或者第二次拖拽时可以计算正确。
(记得拖拽完成时需要解绑拖拽事件。)

上述流程步骤思路其实理清了实现起来就得心应手,下面开始代码实操。

鼠标按下时

根据上面说明,具体代码片段如下。

  // 先全局定义一个参数合集
  const data = {
    ele: null,
    clientX: 0,
    clientY: 0,
    bound: null,
    touching: false, // 拖拽的状态
    distanceX: 0,
    distanceY: 0,
    duration: 30,
    type: false
  }
  function playerStart(event) {
    if (event.type === 'mousedown' && event.which !== 1) return
    if (event.type === 'touchstart' && event.touches.length > 1) return
    data.type = event.type === 'touchstart'
    const events = data.type ? event.touches[0] || event : event
  
    // 边界处理,防止元素贴边之后距离为0,计算出错
    // 第二次拖拽时需要加上之前的坐标
    if (data.ele.distanceX !== '' && data.ele.distanceX !== null && data.ele.distanceX !== undefined) {
      data.distanceX = data.ele.distanceX
    }
    if (data.ele.distanceY !== '' && data.ele.distanceY !== null && data.ele.distanceY !== undefined) {
      data.distanceY = data.ele.distanceY
    }
    // 记录是否在拖拽中
    data.touching = true
    data.clientX = events.clientX
    data.clientY = events.clientY
    data.bound = data.ele.getBoundingClientRect()
  
    window.addEventListener(data.type ? 'touchmove' : 'mousemove', playerMove, false)
    window.addEventListener(data.type ? 'touchend' : 'mouseup', playerEnd, false)
  }

鼠标移动时

playerMove(event) {
  event.preventDefault()
  if (!data.touching) return
  const events = data.type ? event.touches[0] || event : event

  const winWidth = window.innerWidth

  let distanceX = events.clientX - data.clientX
  let distanceY = events.clientY - data.clientY

  // 此时元素的位置
  let absLeft = data.bound.left + distanceX
  let absRight = absLeft + data.bound.width

  // 边缘检测,不能超出左右两边多少距离
  if (absLeft < -data.eleWidthScale) {
    distanceX = distanceX - absLeft - data.eleWidthScale
  }
  if (absRight > winWidth) {
    distanceX = distanceX - (absRight - winWidth)
  }

  let x = data.distanceX + distanceX
  let y = data.distanceY + distanceY

  data.ele.distanceX = x
  data.ele.distanceY = y

  fnTranslate(x, y)
}

// 写一个辅助函数移动元素
fnTranslate(x, y, o) {
  const audioPlayer = document.querySelector('.audio')
  x = Math.round(1000 * x) / 1000
  y = Math.round(1000 * y) / 1000

  // 因为这边需要用xy属性,所以用自定义样式处理,也可以直接写transform
  audioPlayer.style.setProperty('--x', `${x}px`)
  audioPlayer.style.setProperty('--y', `${y}px`)
},

鼠标释放时

释放的过程中需要处理的还是有点多的,比较麻烦的地方是判断是否是左边或者右边释放,然后自动吸附到浏览器的边缘,具体效果可以看微信上播放音乐的效果,另外需要注意的是在释放边缘超出浏览器时会吸附到浏览器边缘内侧。

  function playerEnd() {
    if (!data.touching) return
    data.touching = false
    // 获取浏览器的宽高
    const winWidth = window.innerWidth
    const winHeight = window.innerHeight
    // 当前元素的位置信息
    const bound = data.ele.getBoundingClientRect()
    // 拖动时记录的位置信息
    const _x = data.ele.distanceX
    const _y = data.ele.distanceY
    // 变化的值
    let xChange = 0
    let yChange = 0
    // 判断释放是在左边还是在右边
    if (bound.left + bound.width / 2 < winWidth / 2) {
      // 在左边释放时,如果没有超出浏览器就回弹到边缘,超出就回弹到内侧
      if (bound.left > data.left || (0 < bound.left && bound.left < data.left)) {
        xChange = -1 * bound.left + data.left
      } else {
        xChange = -data.eleWidthScale - bound.left
      }
    } else {
      xChange = winWidth - bound.right - data.right
    }
    // 处理一下超出的上边和底部
    if (bound.top < 0) {
      yChange = -1 * bound.top + data.top
    } else if (bound.bottom > winHeight) {
      yChange = winHeight - bound.bottom - data.bottom
    }
  }

需要用到tween的动画思路,引用原文的介绍;

  /*
  * t: current time(当前时间);
  * b: beginning value(初始值);
  * c: change in value(变化量);
  * d: duration(持续时间)。
  */
  // 只看上面字面意思其实不好理解,我们套用最简单的线性匀速运动来解释下:
  Tween.Linear = function(t, b, c, d) { 
      return c * t / d + b
  }

比方说我们要从位置0的地方运动到100,时间是10秒钟,此时,b, c, d三个参数就已经确认了,b初始值就是0,变化值c就是100-0就是100,最终的时间就是10,此时,只要给一个小于最终时间10的值,Tween.Linear就会返回当前时间应该的坐标,例如,假设此时动画进行到第5秒,也就是t为5,用requestAnimationFrame来操作写一下简单的代码;

  var t = 0, b = 0, c = 100, d = 10;
  var step = function () {
    // value就是当前的位置值
    // 例如我们可以设置DOM.style.left = value + 'px'实现定位
    var value = Tween.Linear(t, b, c, d);
    t++;
    if (t <= d) {
      // 继续运动
      requestAnimationFrame(step);
    } else {
      // 动画结束
    }
  };

了解的动画思路和基本用法,接下来就照葫芦画瓢搬上去就是了,因为上面已经拿到了xChange,yChange的变化值,下面就可以直接用;

  const animate = (t, b, c, d) => {
    if ((t /= d / 2) < 1) return (-c / 2) * (Math.sqrt(1 - t * t) - 1) + b
    return (c / 2) * (Math.sqrt(1 - (t -= 2) * t) + 1) + b
  }
  const run = () => {
    times++

    const x = animate(times, _x, xChange, data.duration)
    const y = animate(times, _y, yChange, data.duration)

    fnTranslate(x, y)

    // 如果不满足条件就继续执行动画获取变化值
    if (times < data.duration) {
      requestAnimationFrame(run)
    } else {
      // 保存吸附之后的坐标值,以便下次拖拽
      data.ele.distanceX = x
      data.ele.distanceY = y
      // 保存之前的位置
      localStorage.setItem('luszy_audio', [x, y].join())
      window.removeEventListener(data.type ? 'touchmove' : 'mousemove', playerMove, false)
      window.removeEventListener(data.type ? 'touchend' : 'mouseup', playerEnd, false)
    }
  }
  run()

上面拆分的操作其实没啥,具体的还是思路和边界的处理,有了前面的分析,代码其实随手就写出来了。拖拽的部分处理完了,下面就开始写播放音乐的部分了。

播放器的播放处理

播放器的操作其实比拖拽来的简单,实际上就只有监听几个播放事件就可,具体事件内做什么事,我这边就用五个事件的处理。具体更多的大家可以自行看文档操作。

  1. waiting事件:主要是用音频播放的时候缓冲暂停时触发。比如说开始播放时,到可以播放的那短暂的时间就是这个触发的,再比如播放时选中后面的进度卡住了缓冲时,也会触发。
  2. playing事件:不用多讲了,就是播放时触发。
  3. pause事件:暂停时触发。
  4. timeupdate事件:音频播放实时触发。这个为了处理音频进度操作。
  5. ended事件:播放完成时触发。
  6. error事件:播放错误时触发。

通过上面的事件,然后再做对应的处理播放时机和停止,用起来其实挺简单的,后续代码等新站更新之后就可以看到了。