前言
只是一个常规的播放组件,需要考虑微信,微博这类环境的播放
微信和微博,若没有用其官方的js-sdk
初始化,没法播放。
我的文章从来都不推崇copy
,仅供参考学习..具体业务具体分析定制才是最合理的
前置基础
vue && vuex
ES5+
Nuxt
的基本用法
这篇文章的内容需基于上篇内容的,要用到一些设备信息
效果图
这是当前服务端版本的效果,因为还没上线,LOGO
已经马赛克
实现思路
之前老的客户端实现思路
- 在主入口实现一个单例,绑定到
vue.prototype
上 - 在音频组件的
beforeMount
创建script
标签,引入对应js
,然后用promise
拿到成功加入head
的状态 - 用
vuex
来维护播放状态 - 在对应的函数初始化音频的加载,之后就可以正常使用了
服务端的思路也差不多
考虑的东西多些,在之前客户端实现的基础上加以完善
用中间件这些来动态注入js-sdk
代码实现
客户端渲染实现的版本
版本1
全部耦合到组件内,虽然可以正常播放(包括微信和微博) 且不是单例模式,对于多音频页面,有毒
<template> <div class="play-voice-area"> <div class="cover-player"> <div :class="playState?'active':''" class="cover-pic"> <img :src="coverUrl ? coverUrl:defaultAvatar"> <i :class="playState? 'sx-mobile-icon-':'sx-mobile-bofang'" class="sx-mobile cover-icon" @click="playAudio" /> </div> </div> <div class="sound-desrc"> <p class="username">{{ userName }}的声兮</p> <p class="timeline">{{ currentPlayTime }}/{{ voiceTime }}</p> </div> </div> </template> <script> export default { props: { userName: { type: String, default: 'Super Hero' }, duration: { type: [String, Number], default: '' }, autoplay: { type: [Boolean, String], default: false }, sourceUrl: { type: String, default: '' }, coverpic: { type: String, default: '' } }, data() { return { defaultAvatar: require('@/assets/share/yourAppIcon@2x.png'), // 默认头像 audioElm: '', // 音频播放器 DOM soundCurrentStopTime: '', // 当前声音暂停的时间戳 playState: false, // 播放状态的图标控制 timeStepState: '', // 时间迭代 voicePlayMessage: '', // 音频资源的状况 currentPlayTime: '00:00', // 当前播放的时间,默认为0 cacheCurrentTime: 0 // 缓存播放时间 }; }, computed: { coverUrl() { if (!this.coverpic) { return this.defaultAvatar; } return this.coverpic; }, voiceTime() { if (this.duration) { return this.second2time(Number(this.duration)); } } }, watch: { sourceUrl(newVal, oldVal) { if (newVal) { this.playAudio(); } } }, created() { this.$store.commit('OPEN_LOADING'); }, beforeMount() { // 初始化音频播放器 this.initAudioElm(); }, mounted() { // 检测微博微信平台 this.checkWeiBo_WeiChat(); this.audioElm.addEventListener('stalled', this.stalled); this.audioElm.addEventListener('loadstart', this.loadstart); this.audioElm.addEventListener('loadeddata', this.loadeddata); this.audioElm.addEventListener('canplay', this.canplay); this.audioElm.addEventListener('ended', this.ended); this.audioElm.addEventListener('pause', this.pause); this.audioElm.addEventListener('timeupdate', this.timeupdate); this.audioElm.addEventListener('error', this.error); this.audioElm.addEventListener('abort', this.abort); }, beforeDestroy() { this.audioElm.removeEventListener('loadstart', this.loadstart); this.audioElm.removeEventListener('stalled', this.stalled); this.audioElm.removeEventListener('canplay', this.canplay); this.audioElm.removeEventListener('timeupdate', this.timeupdate); this.audioElm.removeEventListener('pause', this.pause); this.audioElm.removeEventListener('error', this.error); this.audioElm.removeEventListener('ended', this.ended); }, methods: { initAudioElm() { let audio = new Audio(); audio.autobuffer = true; // 自动缓存 audio.preload = 'metadata'; audio.src = this.sourceUrl; audio.load(); this.audioElm = audio; }, checkWeiBo_WeiChat() { let ua = navigator.userAgent.toLowerCase(); // 获取判断用的对象 const script = document.createElement('script'); if (/micromessenger/.test(ua)) { // 返回一个独立的promise script.src = 'https://res.wx.qq.com/open/js/jweixin-1.2.0.js'; new Promise((resolve, reject) => { let done = false; script.onload = script.onreadystatechange = () => { if ( !done && (!script.readyState || script.readyState === 'loaded' || script.readyState === 'complete') ) { done = true; // 避免内存泄漏 script.onload = script.onreadystatechange = null; resolve(script); } }; script.onerror = reject; document .getElementsByTagName('head')[0] .appendChild(script); }).then(res => { this.initWeixinSource(); }); } if (/WeiBo|weibo/i.test(ua)) { script.src = 'https://tjs.sjs.sinajs.cn/open/thirdpart/js/jsapi/mobile.js'; new Promise((resolve, reject) => { let done = false; script.onload = script.onreadystatechange = () => { if ( !done && (!script.readyState || script.readyState === 'loaded' || script.readyState === 'complete') ) { done = true; // 避免内存泄漏 script.onload = script.onreadystatechange = null; resolve(script); } }; script.onerror = reject; document .getElementsByTagName('head')[0] .appendChild(script); }).then(res => { this.initWeiboSource(); }); } }, canplay() { this.$store.commit('CLOSE_LOADING'); }, initWeixinSource() { wx.config({ // 配置信息, 即使不正确也能使用 wx.ready debug: false, appId: '', timestamp: 1, nonceStr: '', signature: '', jsApiList: [] }); wx.ready(() => { let st = setTimeout(() => { clearTimeout(st); this.audioElm.load(); }, 50); }); }, initWeiboSource() { window.WeiboJS.init( { appkey: '3779229073', debug: false, timestamp: 1429258653, noncestr: '8505b6ef40', scope: [ 'getNetworkType', 'networkTypeChanged', 'getBrowserInfo', 'checkAvailability', 'setBrowserTitle', 'openMenu', 'setMenuItems', 'menuItemSelected', 'setSharingContent', 'openImage', 'scanQRCode', 'pickImage', 'getLocation', 'pickContact', 'apiFromTheFuture' ] }, ret => { this.audioElm.load(); } ); }, playAudio() { // 播放暂停音频 if (this.audioElm.readyState > 2) { // 当资源可以播放的时候 if (this.audioElm.paused) { this.cacheCurrentTime === 0 ? (this.audioElm.currentTime = 0) : (this.audioElm.currentTime = this.cacheCurrentTime); this.playState = true; this.audioElm.play(); } else { this.audioElm.pause(); } } }, second2time(currentTime) { // 秒数化为分钟 let min = Math.floor(currentTime / 60); // 向下取整分钟 let second = Math.floor(currentTime % 60); // 取模得到剩余秒数 if (min < 10) { min = '0' + min; } if (second < 10) { second = '0' + second; } return `${min}:${second}`; }, stalled() { // 资源需要缓存的时候暂停 this.audioElm.pause(); // 缓存加载待播的时候,若是当前播放时间已经走动则触发播放 if (this.audioElm.currentTime !== 0) { // 判断当前播放的时间是否到达结束,否则则继续播放 if (this.audioElm.currentTime !== this.audioElm.duration) { this.playAudio(); } else { this.ended(); } } }, timeupdate() { if ( this.audioElm.readyState > 2 && this.audioElm.currentTime > 0.2 ) { this.cacheCurrentTime = this.audioElm.currentTime; this.currentPlayTime = this.second2time( Number(this.audioElm.currentTime) ); if ( this.audioElm.ended || this.audioElm.currentTime === this.audioElm.duration ) { this.ended(); } } }, ended() { this.audioElm.pause(); // 清除缓存的时间 this.cacheCurrentTime = 0; this.voicePlayMessage = ''; }, pause() { // 当音频/视频已暂停时 this.playState = false; }, error(err) { // 当在音频/视频加载期间发生错误时 this.audioElm.pause(); this.voicePlayMessage = '音频加载资源错误!'; console.log('我报错了:' + err); }, abort() { this.audioElm.pause(); } } }; </script> <style lang="scss" scoped> .play-voice-area { display: flex; align-items: center; flex-direction: column; justify-content: center; } .cover-player { position: relative; display: flex; align-items: center; flex-direction: column; flex-shrink: 0; justify-content: center; .cover-pic { display: block; overflow: hidden; width: 446px; height: 446px; transition: animation 0.28s; border: 15px solid hsla(0, 0%, 100%, 0.1); border-radius: 223px; img { display: inline-block; width: 446px; height: 446px; } &.active { animation: rotation 8s 0.1s linear infinite; } } .cover-icon { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-size: 100px; } a, button, input, textarea { -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } } .sound-desrc { display: flex; overflow: hidden; align-items: center; flex-direction: column; justify-content: center; padding: 40px 0 0 0; .username { min-width: 243px; height: 38px; margin: 22px 0; text-align: center; letter-spacing: 0px; text-overflow: ellipsis; color: #c4c9e2; font-size: 36px; font-weight: normal; font-weight: 700; font-stretch: normal; line-height: 38px; } .timeline { width: 243px; height: 38px; text-align: center; color: #c4c9e2; font-size: 36px; font-weight: normal; font-stretch: normal; line-height: 38px; line-height: 38px; } } @keyframes rotation { from { -webkit-transform: rotate(0deg); } to { -webkit-transform: rotate(360deg); } } </style>
版本2
这个版本考虑了多音频播放,所以在主入口直接单例挂载了一个播放器
其次考虑音频的切换播放,所以必须依赖Vuex
来共享状态
main.js
-主入口
// 创建全局播放器 const music = new Audio(); Vue.prototype.player = music;
- 状态
状态很简单,就一些基础信息,module
的方式,state
通过getters
暴露
export default { state: { index: '', playState: false, curTime: '00:00' }, mutations: { CURRENT_PLAY: (state, index) => { state.index = index; }, CURRENT_TIME: (state, time) => { state.curTime = time; }, SetPlayState(state, status) { state.playState = status; } } };
播放组件组件
<template> <div @click="playstop" class="icon-wrap" :class="iconSize" :style="{color:iconColor}"> <i class="sx-mobile" :class="playState ? iconShow.stop : iconShow.play" /> </div> </template> <script> export default { props: { iconShow: { type: Object, default: function() { return { play: 'sx-mobile-bofang', stop: 'sx-mobile-icon-' }; } }, iconSize: { type: String, default: 'normal' }, iconColor: { type: String, default: '#FFF' }, playState: { type: Boolean, default: false }, sourceUrl: { type: String, default: '' }, mode: { type: String, default: 'self' } }, created() { // 检测微博微信平台 this.checkWeiBo_WeiChat(); console.log(this.sourceUrl); }, mounted() { this.player.addEventListener('end', this.voiceEnd); }, methods: { checkWeiBo_WeiChat() { let ua = navigator.userAgent.toLowerCase(); // 获取判断用的对象 const script = document.createElement('script'); if (/micromessenger/.test(ua)) { // 返回一个独立的promise script.src = 'https://res.wx.qq.com/open/js/jweixin-1.2.0.js'; new Promise((resolve, reject) => { let done = false; script.onload = script.onreadystatechange = () => { if ( !done && (!script.readyState || script.readyState === 'loaded' || script.readyState === 'complete') ) { done = true; // 避免内存泄漏 script.onload = script.onreadystatechange = null; resolve(script); } }; script.onerror = reject; document .getElementsByTagName('head')[0] .appendChild(script); }).then(res => { this.initWeixinSource(); }); } if (/WeiBo|weibo/i.test(ua)) { script.src = 'https://tjs.sjs.sinajs.cn/open/thirdpart/js/jsapi/mobile.js'; new Promise((resolve, reject) => { let done = false; script.onload = script.onreadystatechange = () => { if ( !done && (!script.readyState || script.readyState === 'loaded' || script.readyState === 'complete') ) { done = true; // 避免内存泄漏 script.onload = script.onreadystatechange = null; resolve(script); } }; script.onerror = reject; document .getElementsByTagName('head')[0] .appendChild(script); }).then(res => { this.initWeiboSource(); }); } }, initWeixinSource() { wx.config({ // 配置信息, 即使不正确也能使用 wx.ready debug: false, appId: '', timestamp: 1, nonceStr: '', signature: '', jsApiList: [] }); wx.ready(() => { let st = setTimeout(() => { clearTimeout(st); this.player.load(); }, 50); }); }, initWeiboSource() { window.WeiboJS.init( { appkey: '3779229073', debug: false, timestamp: 1429258653, noncestr: '8505b6ef40', scope: [ 'getNetworkType', 'networkTypeChanged', 'getBrowserInfo', 'checkAvailability', 'setBrowserTitle', 'openMenu', 'setMenuItems', 'menuItemSelected', 'setSharingContent', 'openImage', 'scanQRCode', 'pickImage', 'getLocation', 'pickContact', 'apiFromTheFuture' ] }, ret => { this.player.load(); } ); }, second2time(currentTime) { /* 秒数化为分钟 */ let min = parseInt(currentTime / 60, 10); // 向下取整分钟 let second = parseInt(currentTime % 60, 10); // 取模得到剩余秒数 if (min < 10) { min = '0' + min; } if (second < 10) { second = '0' + second; } return `${min}:${second}`; }, playstop() { if (this.mode === 'self') { this.player.paused ? this.playVoice() : this.pauseVoice(); } else { if (this.$store.getters.vindex === this.index) { this.player.paused ? this.playVoice() : this.pauseVoice(); } else { this.player.src = this.sourceUrl; this.player.play(); if (!this.player.paused) { this.$store.commit('SetPlayState', true); this.$store.commit('CURRENT_PLAY', this.index); } } } }, playVoice() { if (this.player.src !== '') { this.player.play(); if (!this.player.paused) { this.$store.commit('SetPlayState', true); this.$store.commit('CURRENT_PLAY', this.index); if (this.mode === 'self') { this.playState = true; } } } else { this.player.src = this.sourceUrl; this.playVoice(); } }, pauseVoice() { this.player.pause(); this.$store.commit('SetPlayState', false); if (this.mode === 'self') { this.playState = false; } }, voiceEnd() { if (this.mode === 'self') { this.$emit('update:playState', false); } } }, }; </script> <style lang="scss" scoped> .icon-wrap { &.small { font-size: 16px; } &.normal { font-size: 32px; } &.large { font-size: 64px; } &.huge { font-size: 96px; } &.big { font-size: 128px; } i { font-size: inherit; } } </style>
服务端渲染实现的版本(Nuxt)
audio_browser_inject_head.js件(middleware目录)
// 这里给标签加了spec标记,是为了防止多次访问同一个页面的时候, // 无限的插入新增的js // 这次就不再nuxt.config.js引入中间件了.因为不是面向全局,直接在对应的页面引入即可 export default context => { const { env } = context.deviceType; const HeadScript = context.app.head.script; if (env === "wechat") { if (!HeadScript[HeadScript.length - 1].spec) { HeadScript.push({ src: "https://res.wx.qq.com/open/js/jweixin-1.3.2.js", type: "text/javascript", charset: "utf-8", spec: true, }); } } if (env === "weibo") { if (!HeadScript[HeadScript.length - 1].spec) { HeadScript.push({ src: "http://tjs.sjs.sinajs.cn/open/thirdpart/js/jsapi/mobile.js", type: "text/javascript", charset: "utf-8", spec: true, }); } } };
单例播放器(plugins目录)
- plugins/player.js
import Vue from "vue"; export default ({ app, store }) => { let player = new Audio(); player.preload = "auto"; // 把单例的播放器提交到vuex去管控 store.commit("voice/SetPlayer", player); };
- nuxt.config.js
因为audio
对象只有客户端才有,所以不能服务端初始化 设置ssr:false
就代表在客户端的时候才注入,默认不写ssr
是true
module.exports = { plugins: [ { src: "~plugins/player.js", ssr: false }] };
Vuex(store目录)
- 默认的
index.js
是根状态,其他再改目录下的js
文件均默认当做vuex
的module
// index.js import Vuex from "vuex"; export const state = () => ({ deviceType: {}, }); export const mutations = { SetDeviceType(state, payload) { state.deviceType = payload; }, }; export const getters = { deviceType(state) { return state.deviceType; }, player(state) { return state.voice.player; }, playState(state) { return state.voice.playState; }, playUrl(state) { return state.voice.playUrl; }, playIndex(state) { return state.voice.playIndex; }, playTime(state) { return state.voice.playTime; }, voiceTotalTime(state) { return state.voice.voiceTotalTime; }, }; // voice.js import Vuex from "Vuex"; export const state = () => ({ player: "", // 播放器 playState: false, // 当前播放的状态 playUrl: "", // 播放的链接 playIndex: 0, // 当前播放的索引 playTime: "00:00", // 当前的播放时间 voiceTotalTime: "00:00", // 曲目总时长 }); export const mutations = { SetPlayer(state, payload) { state.player = payload; }, SetPlayState(state, payload) { state.playState = payload; }, SetPlayUrl(state, payload) { state.playUrl = payload; state.player.src = payload; }, SetPlayIndex(state, payload) { state.playIndex = payload; }, SetPlayTime(state, payload) { state.playTime = payload; }, SetVoiceTotalTime(state, payload) { state.voiceTotalTime = payload; }, ResetVoice(state) { state.playState = false; state.playUrl = ""; state.playTime = "00:00"; state.voiceTotalTime = "00:00"; }, };
播放器组件
- VoicePlayer.vue
播放状态均由vuex
来管理,这样对于多音频或者跨组件控制播放非常有帮助
<template> <div class="player" :class="$store.getters.playState ? 'animation-roate':''" :style="{background:`url(${CoverImg}) center center no-repeat`,backgroundSize: 'cover'}"> <div class="icon-wrap"> <img :src="playstate? StopIcon:PlayIcon" alt="播放器操作按钮" @click="changePlayState(playstate)"> </div> </div> </template> <script> const CoverImg = require('./images/cover@2x.png'); const PlayIcon = require('./images/play@2x.png'); const StopIcon = require('./images/stop@2x.png'); export default { data() { return { CoverImg, PlayIcon, StopIcon, } }, props: { playstate: { type: Boolean, default: false }, playurl: { type: String, default: 'http://www.ytmp3.cn/down/51013.mp3' } }, mounted() { this.$store.getters.player.addEventListener('loadedmetadata', () => { // 缓存播放总时长 this.$store.commit('voice/SetVoiceTotalTime', this.second2time(this.$store.getters.player.duration)); }) this.$store.getters.player.addEventListener('stalled', () => { // 重置播放状态 this.$store.commit('voice/ResetVoice'); }) this.$store.getters.player.addEventListener('abort', () => { // 重置播放状态 this.$store.commit('voice/ResetVoice'); }) this.$store.getters.player.addEventListener('play', () => { this.$store.commit('voice/SetPlayState', true); }) this.$store.getters.player.addEventListener('pause', () => { this.$store.commit('voice/SetPlayState', false); }) this.$store.getters.player.addEventListener('timeupdate', () => { this.$store.commit('voice/SetPlayTime', this.second2time(this.$store.getters.player.currentTime)); }) this.$store.getters.player.addEventListener('ended', () => { this.$store.commit('voice/ResetVoice'); }) }, beforeDestroy() { this.$store.getters.player.removeEventListener('loadedmetadata', () => { this.$store.commit('voice/SetVoiceTotalTime', this.second2time(this.$store.getters.player.duration)); }) this.$store.getters.player.removeEventListener('stalled', () => { this.$store.commit('voice/ResetVoice'); }) this.$store.getters.player.removeEventListener('abort', () => { this.$store.commit('voice/ResetVoice'); }) this.$store.getters.player.removeEventListener('play', () => { this.$store.commit('voice/SetPlayState', true); }) this.$store.getters.player.removeEventListener('pause', () => { this.$store.commit('voice/SetPlayState', false); }) this.$store.getters.player.removeEventListener('timeupdate', () => { this.$store.commit('voice/SetPlayTime', this.second2time(this.$store.getters.player.currentTime)); console.log(this.$store.getters.player.currentTime) }) this.$store.getters.player.removeEventListener('ended', () => { this.$store.commit('voice/ResetVoice'); }) }, methods: { changePlayState(playstate) { // 设置播放源 if (!this.$store.getters.playUrl) { this.$store.commit('voice/SetPlayUrl', this.playurl) } // 设置播放状态 if (playstate) { this.$store.getters.player.pause(); } else { this.$store.getters.player.play(); } playstate = !playstate; }, initWeixinSource() { wx.config({ // 配置信息, 即使不正确也能使用 wx.ready debug: false, appId: '', timestamp: 1, nonceStr: '', signature: '', jsApiList: [] }); wx.ready(() => { let st = setTimeout(() => { clearTimeout(st); this.player.load(); }, 50); }); }, initWeiboSource() { window.WeiboJS.init( { appkey: '3779229073', debug: false, timestamp: 1429258653, noncestr: '8505b6ef40', scope: [ 'getNetworkType', 'networkTypeChanged', 'getBrowserInfo', 'checkAvailability', 'setBrowserTitle', 'openMenu', 'setMenuItems', 'menuItemSelected', 'setSharingContent', 'openImage', 'scanQRCode', 'pickImage', 'getLocation', 'pickContact', 'apiFromTheFuture' ] }, ret => { this.audioElm.load(); } ); }, second2time(currentTime) { // 秒数化为分钟 let min = Math.floor(currentTime / 60); // 向下取整分钟 let second = Math.floor(currentTime % 60); // 取模得到剩余秒数 if (min < 10) { min = '0' + min; } if (second < 10) { second = '0' + second; } return `${min}:${second}`; }, } } </script> <style lang="scss" scoped> .player { height: 100%; width: 100%; border-radius: 100%; position: relative; .icon-wrap { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); img { display: block; height: 94px; width: 94px; } } } @keyframes fade-rotate { from { opacity: 0.8; transform: rotate(0) scale(1); } to { opacity: 1; transform: rotate(360deg) scale(1.1); } } .animation-roate { transform: translate3d(0, 0, 0); animation: fade-rotate 18s ease-in-out infinite alternate; } </style>