271 lines
9 KiB
JavaScript
271 lines
9 KiB
JavaScript
import utils from "../plugins/utils";
|
|
import emitter from 'tiny-emitter/instance';
|
|
|
|
/**
|
|
* This plugin (available in all vue components as $audioPlayer) handles
|
|
* access to the shared audio player, and events related to loading and
|
|
* playback of audio attachments.
|
|
*
|
|
* Components use this by calling "addListener" (and corresponding removeListener) with
|
|
* an audio matrix event and a unique component id (for example the ._uid property).
|
|
*/
|
|
export default {
|
|
install(app) {
|
|
class SharedAudioPlayer {
|
|
constructor() {
|
|
this.player = new Audio();
|
|
this.currentEvent = null;
|
|
this.currentClapReactions = [];
|
|
this.infoMap = new Map();
|
|
this.player.addEventListener("durationchange", this.onDurationChange.bind(this));
|
|
this.player.addEventListener("timeupdate", this.onTimeUpdate.bind(this));
|
|
this.player.addEventListener("play", this.onPlay.bind(this));
|
|
this.player.addEventListener("pause", this.onPause.bind(this));
|
|
this.player.addEventListener("ended", this.onEnded.bind(this));
|
|
}
|
|
|
|
getPlayerElement() {
|
|
return this.player;
|
|
}
|
|
|
|
addListener(uid, event) {
|
|
const eventId = event.getId();
|
|
var entry = this.infoMap.get(eventId);
|
|
if (!entry) {
|
|
// Listeners is just a Set of component "uid" entries for now.
|
|
entry = { url: null, listeners: new Set() };
|
|
// Make these reactive, so AudioPlayer (and others) can listen to them
|
|
entry["loading"] = false;
|
|
entry["loadPercent"] = 0;
|
|
entry["duration"] = 0;
|
|
entry["currentTime"] = 0;
|
|
entry["playPercent"] = 0;
|
|
entry["playing"] = false;
|
|
this.infoMap.set(eventId, entry);
|
|
|
|
// Get duration information
|
|
utils
|
|
.getAttachmentUrlAndDuration(event)
|
|
.then(([ignoredurl, duration]) => {
|
|
entry.duration = duration;
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to fetch attachment duration: ", err);
|
|
});
|
|
}
|
|
entry.listeners.add(uid);
|
|
return entry;
|
|
}
|
|
removeListener(uid) {
|
|
[...this.infoMap].forEach(([ignoredeventid, info]) => {
|
|
info.listeners.delete(uid);
|
|
if (info.listeners.size == 0 && info.url) {
|
|
// No more listeners, release audio blob
|
|
URL.revokeObjectURL(info.url);
|
|
info.url = null;
|
|
}
|
|
});
|
|
this.infoMap = new Map(
|
|
[...this.infoMap].filter(([ignoredeventid, info]) => {
|
|
return info.listeners.size > 0;
|
|
})
|
|
);
|
|
}
|
|
|
|
play(event, timelineSet) {
|
|
this.play_(event, timelineSet, false);
|
|
}
|
|
|
|
load(event, timelineSet) {
|
|
this.play_(event, timelineSet, true);
|
|
}
|
|
|
|
play_(event, timelineSet, onlyLoad) {
|
|
const eventId = event.getId();
|
|
if (this.currentEvent != eventId) {
|
|
// Media change, pause the one currently playing.
|
|
this.player.pause();
|
|
var entry = this.infoMap.get(this.currentEvent);
|
|
if (entry) {
|
|
entry.playing = false;
|
|
}
|
|
}
|
|
this.currentEvent = eventId;
|
|
const info = this.infoMap.get(eventId);
|
|
if (info) {
|
|
|
|
// Get all clap reactions
|
|
this.initializeClapEvents(event, timelineSet);
|
|
|
|
if (info.url) {
|
|
// Restart from beginning?
|
|
if (info.currentTime == info.duration) {
|
|
info.currentTime = 0;
|
|
info.playPercent = 0;
|
|
}
|
|
if (this.player.src != info.url) {
|
|
this.player.src = info.url;
|
|
this.player.currentTime = (info.currentTime || 0) / 1000;
|
|
}
|
|
if (onlyLoad) {
|
|
this.player.load();
|
|
} else {
|
|
this.player.play();
|
|
}
|
|
} else {
|
|
// Download it!
|
|
info.loadPercent = 0;
|
|
info.loading = true;
|
|
info.abortController = new AbortController();
|
|
utils
|
|
.getAttachment(this.$root.$matrix.matrixClient, this.$root.$matrix.useAuthedMedia, event, (progress) => {
|
|
info.loadPercent = progress;
|
|
}, false, info.abortController)
|
|
.then((url) => {
|
|
info.url = url;
|
|
|
|
// Still on this item? Call ourselves recursively.
|
|
if (this.currentEvent == eventId) {
|
|
if (onlyLoad) {
|
|
this.load(event, timelineSet);
|
|
} else {
|
|
this.play(event, timelineSet);
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to fetch attachment: ", err);
|
|
})
|
|
.finally(() => {
|
|
info.loading = false;
|
|
info.abortController = undefined;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the "autoplay" property on the underlying player object.
|
|
* @param {} autoplay
|
|
*/
|
|
setAutoplay(autoplay) {
|
|
this.player.autoplay = autoplay;
|
|
}
|
|
|
|
pause(event) {
|
|
if (!event || this.currentEvent == event.getId()) {
|
|
this.player.pause();
|
|
}
|
|
|
|
if (event) {
|
|
// If downloading, abort that!
|
|
var entry = this.infoMap.get(event.getId());
|
|
if (entry && entry.abortController) {
|
|
entry.abortController.abort();
|
|
}
|
|
}
|
|
}
|
|
seek(event, percent) {
|
|
var entry = this.infoMap.get(event.getId());
|
|
if (entry) {
|
|
entry.currentTime = ((percent / 100) * (entry.duration || 0));
|
|
this.updatePlayPercent(entry);
|
|
if (this.currentEvent == event.getId()) {
|
|
this.player.currentTime = entry.currentTime / 1000;
|
|
}
|
|
}
|
|
}
|
|
seekRelative(event, milliseconds) {
|
|
var entry = this.infoMap.get(event.getId());
|
|
if (entry) {
|
|
entry.currentTime = Math.max(0, Math.min(entry.currentTime + milliseconds, entry.duration));
|
|
this.updatePlayPercent(entry);
|
|
if (this.currentEvent == event.getId()) {
|
|
this.player.currentTime = entry.currentTime / 1000;
|
|
}
|
|
}
|
|
}
|
|
onPlay() {
|
|
var entry = this.infoMap.get(this.currentEvent);
|
|
if (entry) {
|
|
entry.playing = true;
|
|
}
|
|
emitter.emit("audio-playback-started", this.currentEvent);
|
|
}
|
|
onPause() {
|
|
var entry = this.infoMap.get(this.currentEvent);
|
|
if (entry) {
|
|
entry.playing = false;
|
|
}
|
|
emitter.emit("audio-playback-paused", this.currentEvent);
|
|
}
|
|
onEnded() {
|
|
var entry = this.infoMap.get(this.currentEvent);
|
|
if (entry) {
|
|
entry.playing = false;
|
|
entry.currentTime = entry.duration; // Next time restart
|
|
}
|
|
emitter.emit("audio-playback-ended", this.currentEvent);
|
|
}
|
|
onTimeUpdate() {
|
|
var entry = this.infoMap.get(this.currentEvent);
|
|
if (entry) {
|
|
const oldTime = entry.currentTime;
|
|
entry.currentTime = 1000 * this.player.currentTime;
|
|
this.updatePlayPercent(entry);
|
|
this.maybePlayClapEvent(oldTime, entry.currentTime);
|
|
}
|
|
}
|
|
onDurationChange() {
|
|
const duration =
|
|
this.player.duration && isFinite(this.player.duration) && !isNaN(this.player.duration)
|
|
? 1000 * this.player.duration
|
|
: 0;
|
|
var entry = this.infoMap.get(this.currentEvent);
|
|
if (entry) {
|
|
entry.duration = duration;
|
|
this.updatePlayPercent(entry);
|
|
}
|
|
}
|
|
updatePlayPercent(entry) {
|
|
if (entry.duration > 0) {
|
|
entry.playPercent = Math.floor((100 / entry.duration) * entry.currentTime);
|
|
} else {
|
|
entry.playPercent = 0;
|
|
}
|
|
}
|
|
|
|
initializeClapEvents(event, timelineSet) {
|
|
if (event) {
|
|
const reactions = timelineSet.relations.getChildEventsForEvent(event.getId(), 'm.annotation', 'm.reaction');
|
|
if (reactions) {
|
|
this.currentClapReactions = reactions.getRelations()
|
|
.filter(r => r.getRelation().key == "👏" && r.getRelation().timeOffset && parseInt(r.getRelation().timeOffset) > 0)
|
|
.map(r => {
|
|
return {
|
|
sender: r.getSender(),
|
|
emoji: r.getRelation().key,
|
|
timeOffset: parseInt(r.getRelation().timeOffset)
|
|
}
|
|
})
|
|
.sort((a,b) => a.timeOffset - b.timeOffset);
|
|
}
|
|
} else {
|
|
this.currentClapReactions = [];
|
|
}
|
|
}
|
|
|
|
maybePlayClapEvent(previousTimeMs, timeNowMs) {
|
|
(this.currentClapReactions || []).forEach(reaction => {
|
|
if (previousTimeMs < reaction.timeOffset && timeNowMs >= reaction.timeOffset) {
|
|
emitter.emit("audio-playback-reaction", reaction);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const audioPlayer = new SharedAudioPlayer();
|
|
app.$audioPlayer = audioPlayer;
|
|
app.config.globalProperties.$audioPlayer = audioPlayer;
|
|
},
|
|
};
|