不知不觉埋头于业务已许久,距离上一篇分享应该很久很久以前,具体何时,已无从知晓。慢慢的开始觉得锅有点热,感觉呼吸有点困难,温水里面的青蛙趁着腿还没完全麻木的时候,也想着开始重拾旧梦,稍微往上蹬蹬,好了,废话就不多提了,时不时的低头总结某段时间的成果大过于做10个新项目,下面就开始慢慢的总结之路吧!(ps:其实是往事不堪回首)
前置背景介绍:公司前期比较倾向于以小程序作为开始,打开市场,于是就开始十分漫长的开发之路(ps: 其实一开始是拒绝的,因为个人原因不太喜欢这种跟所有的东西都搭点边,但又有各种枷锁限制的东西,开发起来不爽,总感觉跟有些东西神似或型似,优化起来更不爽,因为有很多根本就没给你开这种权限),不知不觉有开始废话连篇了,哈哈,完结。
解决的问题:避免同一个页面内,多个音视频同时在播放,造成多个重音的bug。解决记录当前播放音频进度,当组件没有销毁时,可以随意拖动进度条进行播放,也可以来回切换,进度条不会丢失。
框架:uniapp,一种跨平台的解决方案,跨着跨着到最后又走向了一方,尝试过打包成app,甚至还上个应用市场,总体来说对于一些小应用来说,其实是完全够用的,这一点可以肯定,节省了很多的开发成本,感兴趣的同学可以去了解下,但是坑也是有点滴。所以后面源码的结构可能有点怪,用其他框架开发的,可以稍许转化下。
音频api: 由于小程序把原有的音频组件删除了,音频是通过api实例化出来,所以音视频多媒体管理器,主要是对音频的再封装。阅读微信文档可以发现主要有两个:createInnerAudioContext和getBackgroundAudioManager。
createInnerAudioContext:不用多提,用过的同学都知道这可能是史前巨坑,各种问题,具体有多坑,请查阅微信开发社区,满满皆是。
getBackgroundAudioManager:背景音乐播放器,支持后台播放,最大的区别就是播放后会有个状态,显示在你手机的系统状态里面,微信的右边会有的小的播放状态icon,播放微信聊天语音时会中断,完毕之后又会继续,是一个非常强大的api,坑位比较少,目前采用的是这种,因为上者安卓和ios两端的兼容性问题,简直是层出不穷,眼火缭乱,不得已,弃之。
multi-media-manager.ts 多媒体订阅类,所有的多媒体订阅这个类,就可以统一处理,不会存在视音频等一起播放,造成多重背景声音的问题,也就是整个小程序只有一个声音在播放。key:这个属性其实可以删除,因为就用url作为订阅的key就可以了,但是会有个问题,就是你从列表页播放了个音频,进入详情页其实那个音频的进度跟列表页的是一样的,但是反过来想,也没什么问题,毕竟是同一个音频,有进度条的记录没什么关系。callback:由于使用的是uniapp,又是基于vue的,所以我底下所牵涉到的类都过了一遍vue.observe的,也就是继承了Store这个类,数据是响应式的,没必要传入callback,没用到的同学把继承Store去掉,可以传入callback。
/**
* 全局多媒体订阅
*/
import { Store } from '../vuex';
import { BackgroundAudio } from './background-audio';
/**
* 音频类
*/
export interface SubscribeItem {
key: string; // 订阅key
src: string; // 资源地址
type: 'video' | 'audio'; // 媒体类型
el?: VideoContext; // 当前视频实例
duration?: number; // 媒体时长
isPlaying?: boolean;
callback?: () => void; // 回调函数
seekTime?: number; // 设置播放开始时间
}
export interface RealSubscribeItem {
key: string; // 订阅key
src: string; // 资源地址
type: 'video' | 'audio'; // 媒体类型
el?: VideoContext; // 当前视频实例
duration: number; // 媒体时长
isPlaying: boolean;
count?: number;
callback?: () => void; // 回调函数
seekTime: number; // 设置播放开始时间
}
/**
* 多媒体类
*/
export default class MultiMediaManager extends Store {
// 音频
public audio = new BackgroundAudio((time, duration, type) =>
this.audioCallback(time, duration, type)
);
// 视频
public subscribeList: RealSubscribeItem[] = [];
// 获取正在播放的资源
public get currentItem() {
const arr = this.subscribeList.filter((i) => i.isPlaying);
if (!arr.length) return null;
return arr[0];
}
// 检测key
private checkKey(key: string) {
if (!key) throw Error('请传入订阅key值');
if (this.subscribeList.findIndex((i) => i.key === key) === -1)
throw Error(`此key:${key}没有进行订阅`);
}
// 获取自身参数
public getSelfParams(key: string): RealSubscribeItem | null {
const arr = this.subscribeList.filter((item) => {
return item.key === key;
});
if (!arr.length) return null;
return arr[0];
}
// 音频播放回调
public audioCallback(time: number, duration: number, type?: string) {
this.subscribeList = this.subscribeList.map((item) => {
if (item.isPlaying) {
// 播放结束则停止
if (type === 'end') {
item.seekTime = item.duration;
setTimeout(() => {
item.isPlaying = false;
item.seekTime = 0;
}, 300);
} else if (type === 'stop') {
item.isPlaying = false;
} else {
item.seekTime = time;
}
item.callback && item.callback(item);
// 时长校验 - 如果接口返回,或者已知的时长不正确,可以开启重新校验赋值
// item.duration !== duration &&
// duration &&
// (item.duration = duration);
}
return item;
});
}
// 播放
public $play(key: string, time?: number) {
this.checkKey(key);
this.subscribeList = this.subscribeList.map((item) => {
item.isPlaying = item.key === key;
if (item.key === key && item.type === 'audio') {
typeof time !== 'undefined' && (item.seekTime = time);
// 有播放记录的,则seek,无则play
if (typeof item.seekTime !== 'undefined') {
this.audio.$seek(item.src, item.seekTime);
} else {
this.audio.$play(item.src);
}
}
// 未选中视频,则暂停所有视频播放
if (item.key !== key && item.type === 'video') {
item.el && item.el.pause();
}
// 选中视频时,清除音频监听
if (item.key === key && item.type === 'video') {
this.audio.$pause();
}
return item;
});
console.log('play订阅列表', this.subscribeList);
}
// 暂停
public $pause(key: string) {
this.checkKey(key);
this.subscribeList = this.subscribeList.map((item) => {
if (item.key === key && item.isPlaying && item.type === 'audio') {
this.audio.$pause();
item.seekTime = this.audio.bgAudioMannager.currentTime || 0;
item.isPlaying = false;
}
if (item.key === key && item.isPlaying && item.type === 'video') {
item.el && item.el.pause();
item.isPlaying = false;
}
return item;
});
console.log('pause订阅列表', this.subscribeList);
}
// 暂停所有
public $pauseAll() {
this.subscribeList = this.subscribeList.map((item) => {
if (item.isPlaying) {
if (item.type === 'audio') {
this.audio.$pause();
item.seekTime = this.audio.bgAudioMannager.currentTime || 0;
} else {
item.el && item.el.pause();
}
}
item.isPlaying = false;
return item;
});
}
// 切换
public $toggle(key: string) {
this.checkKey(key);
this.subscribeList = this.subscribeList.map((item) => {
if (item.key === key && item.type === 'audio' && item.isPlaying) {
this.$pause(key);
} else if (
item.key === key &&
item.type === 'audio' &&
!item.isPlaying
) {
this.$play(item.key);
}
return item;
});
}
// 订阅
public $subscribeManager(item: SubscribeItem) {
const { key = '' } = item;
if (!key) throw Error('请传入订阅key值');
const index = this.subscribeList.findIndex((i) => i.key === key);
if (index !== -1) {
(this as any).subscribeList[index].count++;
return;
}
const realItem = Object.assign(
{
isPlaying: false,
seekTime: 0,
duration: 0,
count: 1,
},
item
);
this.subscribeList.push(realItem);
}
// 取消
public cancel(key: string) {
const index = this.subscribeList.findIndex((i) => i.key === key);
if (index === -1) return;
if (this.subscribeList[index].type === 'audio') {
this.audio.$stop();
(this as any).subscribeList[index].count--;
if (!this.subscribeList[index].count) {
this.subscribeList.splice(index, 1);
}
return;
}
this.subscribeList.splice(index, 1);
// if (!this.subscribeList.length) {
// this.destory();
// }
}
// 销毁
public destory() {
this.subscribeList = [];
}
}
audioManager.ts
import { Store } from '../vuex';
export type AudioCallback = (
time: number,
duration: number,
type?: string
) => void;
export class BackgroundAudio extends Store {
// 背景音乐管理器
public bgAudioMannager: BackgroundAudioManager = uni.getBackgroundAudioManager();
public callback?: AudioCallback;
public timer: any = 0;
public constructor(callback?: AudioCallback) {
super();
this.callback = callback;
this.addListener();
}
public addListener() {
this.bgAudioMannager.onPlay(() => {
console.log('=========== onPlay ===============');
});
this.bgAudioMannager.onWaiting(() => {
console.log('=========== onWaiting ===============');
});
this.bgAudioMannager.onCanplay(() => {
console.log('=========== onCanplay ===============');
this.bgAudioMannager.play();
});
(this as any).bgAudioMannager.onSeeking(() => {
console.log('=========== onSeeking ===============');
});
(this as any).bgAudioMannager.onSeeked(() => {
console.log('=========== onSeeked ===============');
this.bgAudioMannager.play();
});
this.bgAudioMannager.onTimeUpdate((res) => {
if (this.timer) return;
this.timer = setTimeout(() => {
clearTimeout(this.timer);
this.timer = 0;
}, 300);
const { currentTime = 0, duration = 0 } = this.bgAudioMannager;
this.callback && this.callback(currentTime, duration);
});
this.bgAudioMannager.onPause(() => {
console.log('=========== onPause ===============');
});
this.bgAudioMannager.onStop(() => {
console.log('=========== onStop ===============');
const { duration = 0 } = this.bgAudioMannager;
this.callback && this.callback(0, duration, 'stop');
});
this.bgAudioMannager.onEnded(() => {
console.log('=========== onEnded ===============');
const { duration = 0 } = this.bgAudioMannager;
this.callback && this.callback(duration, duration, 'end');
});
}
public $play(src: string) {
console.log('正常play');
this.callback && this.callback(0, 0);
this.bgAudioMannager.title = '标题';
this.bgAudioMannager.startTime = 0;
this.bgAudioMannager.src = src;
this.bgAudioMannager.play();
}
public $pause() {
this.bgAudioMannager.pause();
}
public $stop() {
this.bgAudioMannager.stop();
}
public $seek(src: string, time: number) {
console.log('正常seek');
if (src !== this.bgAudioMannager.src) {
console.log('正常seek,且url不相等');
this.bgAudioMannager.title = '标题';
this.bgAudioMannager.startTime = time;
this.bgAudioMannager.src = src;
}
(this as any).bgAudioMannager.seek(time);
}
}
音频.vue 换成对应框架的文件,最好把音视频相关的封装封装成一个基本组件统一处理。进度条控件推荐使用自带组件slide,具体怎么转化,同学们可以集思广益,大部分的源码就不全贴出来了,可以底下评论交流
public async mounted() {
this.mediaStore.$subscribeManager({
key: this.mediaKey,
src: this.url,
type: 'audio',
duration: this.sourceDuration,
});
await this.$nextTick();
// 自动播放
if (this.autoplay && this.mediaKey)
this.mediaStore.$toggle(this.mediaKey);
}
// 页面销毁的时候,解除订阅
public beforeDestroy() {
this.mediaStore.cancel(this.mediaKey);
}
视频.vue
<video
class="model-video"
id="myVideo"
:poster="firstPic.poster"
:src="url"
:controls="true"
:show-center-play-btn="false"
@play="onVideoPlay"
@pause="onVideoPause"
></video>
public async mounted() {
await this.$nextTick();
this.videoEl = uni.createVideoContext('myVideo', this);
this.mediaStore.$subscribeManager({
key: this.mediaKey,
src: this.url,
type: 'video',
el: this.videoEl
});
}
public beforeDestroy() {
this.mediaStore.cancel(this.mediaKey);
}
同学们可以完全不按照这种来,因为也可能存在一些缺陷和漏洞,提供一种管理的思路,也有可能有些小伙伴根本就不会存在这么复杂的情况,我们的情况比较特殊,整个feed流比较复杂,导致不仅这些媒体难管理,性能瓶颈也是一个极大的问题。
以上只贴出了一些关键性的代码,还有问题的小伙伴可以底下评论我,目前并没有单独的github demo库,但是亲测两端的兼容性还是挺好的。
基于vue的h5音频播放器:https://github.com/Vitaminaq/vue-audios
支持上述的一切功能,开发h5的同学可以用下,欢迎fork加功能。
演示图片: