keanu-weblite/src/services/audio.service.js

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;
},
};