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