tweb/src/components/audio.ts

484 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import appDocsManager, {MyDocument} from "../lib/appManagers/appDocsManager";
import { RichTextProcessor } from "../lib/richtextprocessor";
import { formatDate } from "./wrappers";
import ProgressivePreloader from "./preloader";
import { MediaProgressLine } from "../lib/mediaPlayer";
import appMediaPlaybackController from "./appMediaPlaybackController";
import { DocumentAttribute } from "../layer";
import { Download } from "../lib/appManagers/appDownloadManager";
import mediaSizes from "../helpers/mediaSizes";
import { isSafari } from "../helpers/userAgent";
// https://github.com/LonamiWebs/Telethon/blob/4393ec0b83d511b6a20d8a20334138730f084375/telethon/utils.py#L1285
export function decodeWaveform(waveform: Uint8Array | number[]) {
if(!(waveform instanceof Uint8Array)) {
waveform = new Uint8Array(waveform);
}
var bitCount = waveform.length * 8;
var valueCount = bitCount / 5 | 0;
if(!valueCount) {
return new Uint8Array([]);
}
var dataView = new DataView(waveform.buffer);
var result = new Uint8Array(valueCount);
for(var i = 0; i < valueCount; i++) {
var byteIndex = i * 5 / 8 | 0;
var bitShift = i * 5 % 8;
var value = dataView.getUint16(byteIndex, true);
result[i] = (value >> bitShift) & 0b00011111;
}
/* var byteIndex = (valueCount - 1) / 8 | 0;
var bitShift = (valueCount - 1) % 8;
if(byteIndex == waveform.length - 1) {
var value = waveform[byteIndex];
} else {
var value = dataView.getUint16(byteIndex, true);
}
console.log('decoded waveform, setting last value:', value, byteIndex, bitShift);
result[valueCount - 1] = (value >> bitShift) & 0b00011111; */
return result;
}
function wrapVoiceMessage(doc: MyDocument, audioEl: AudioElement) {
audioEl.classList.add('is-voice');
const barWidth = 2;
const barMargin = mediaSizes.isMobile ? 2 : 1;
const barHeightMin = mediaSizes.isMobile ? 3 : 2;
const barHeightMax = mediaSizes.isMobile ? 16 : 23;
const availW = mediaSizes.isMobile ? 152 : 190;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add('audio-waveform');
svg.setAttributeNS(null, 'width', '' + availW);
svg.setAttributeNS(null, 'height', '' + barHeightMax);
svg.setAttributeNS(null, 'viewBox', `0 0 ${availW} ${barHeightMax}`);
const timeDiv = document.createElement('div');
timeDiv.classList.add('audio-time');
audioEl.append(svg, timeDiv);
let waveform = (doc.attributes.find(attribute => attribute._ == 'documentAttributeAudio') as DocumentAttribute.documentAttributeAudio).waveform || [];
waveform = decodeWaveform(waveform.slice());
//console.log('decoded waveform:', waveform);
const normValue = Math.max(...waveform);
const wfSize = waveform.length ? waveform.length : 100;
const barCount = Math.min((availW / (barWidth + barMargin)) | 0, wfSize);
let maxValue = 0;
const maxDelta = barHeightMax - barHeightMin;
let html = '';
for(let i = 0, barX = 0, sumI = 0; i < wfSize; ++i) {
const value = waveform[i] || 0;
if((sumI + barCount) >= wfSize) { // draw bar
sumI = sumI + barCount - wfSize;
if(sumI < (barCount + 1) / 2) {
if(maxValue < value) maxValue = value;
}
const bar_value = Math.max(((maxValue * maxDelta) + ((normValue + 1) / 2)) / (normValue + 1), barHeightMin);
const h = `
<rect x="${barX}" y="${barHeightMax - bar_value}" width="2" height="${bar_value}" rx="1" ry="1"></rect>
`;
html += h;
barX += barWidth + barMargin;
if(sumI < (barCount + 1) / 2) {
maxValue = 0;
} else {
maxValue = value;
}
} else {
if(maxValue < value) maxValue = value;
sumI += barCount;
}
}
svg.insertAdjacentHTML('beforeend', html);
const rects = Array.from(svg.children) as HTMLElement[];
let progress = audioEl.querySelector('.audio-waveform') as HTMLDivElement;
const onLoad = () => {
let interval = 0;
let lastIndex = 0;
let audio = audioEl.audio;
if(!audio.paused || (audio.currentTime > 0 && audio.currentTime != audio.duration)) {
lastIndex = Math.round(audio.currentTime / audio.duration * barCount);
rects.slice(0, lastIndex + 1).forEach(node => node.classList.add('active'));
}
let start = () => {
clearInterval(interval);
interval = window.setInterval(() => {
if(lastIndex > svg.childElementCount || isNaN(audio.duration) || audio.paused) {
clearInterval(interval);
return;
}
lastIndex = Math.round(audio.currentTime / audio.duration * barCount);
//svg.children[lastIndex].setAttributeNS(null, 'fill', '#000');
//svg.children[lastIndex].classList.add('active'); #Иногда пропускает полоски..
rects.slice(0, lastIndex + 1).forEach(node => node.classList.add('active'));
//++lastIndex;
//console.log('lastIndex:', lastIndex, audio.currentTime);
//}, duration * 1000 / svg.childElementCount | 0/* 63 * duration / 10 */);
}, 20);
};
if(!audio.paused) {
start();
}
audioEl.addAudioListener('playing', () => {
//rects.forEach(node => node.classList.remove('active'));
start();
});
audioEl.addAudioListener('pause', () => {
clearInterval(interval);
});
audioEl.addAudioListener('ended', () => {
clearInterval(interval);
rects.forEach(node => node.classList.remove('active'));
});
let mousedown = false, mousemove = false;
progress.addEventListener('mouseleave', (e) => {
if(mousedown) {
audio.play();
mousedown = false;
}
mousemove = false;
})
progress.addEventListener('mousemove', (e) => {
mousemove = true;
if(mousedown) scrub(e);
});
progress.addEventListener('mousedown', (e) => {
e.preventDefault();
if(!audio.paused) {
audio.pause();
scrub(e);
mousedown = true;
}
});
progress.addEventListener('mouseup', (e) => {
if (mousemove && mousedown) {
audio.play();
mousedown = false;
}
});
progress.addEventListener('click', (e) => {
if(!audio.paused) scrub(e);
});
function scrub(e: MouseEvent) {
const scrubTime = e.offsetX / availW /* width */ * audio.duration;
lastIndex = Math.round(scrubTime / audio.duration * barCount);
rects.slice(0, lastIndex + 1).forEach(node => node.classList.add('active'));
for(let i = lastIndex + 1; i < rects.length; ++i) {
rects[i].classList.remove('active')
}
audio.currentTime = scrubTime;
}
return () => {
clearInterval(interval);
progress.remove();
progress = null;
audio = null;
};
};
return onLoad;
}
function wrapAudio(doc: MyDocument, audioEl: AudioElement) {
const withTime = !!+audioEl.getAttribute('with-time');
const title = doc.audioTitle || doc.file_name;
let subtitle = doc.audioPerformer ? RichTextProcessor.wrapPlainText(doc.audioPerformer) : '';
if(withTime) {
subtitle += (subtitle ? ' · ' : '') + formatDate(doc.date);
} else if(!subtitle) {
subtitle = 'Unknown Artist';
}
const html = `
<div class="audio-details">
<div class="audio-title">${title}</div>
<div class="audio-subtitle">${subtitle}</div>
<div class="audio-time"></div>
</div>`;
audioEl.insertAdjacentHTML('beforeend', html);
const onLoad = () => {
const subtitleDiv = audioEl.querySelector('.audio-subtitle') as HTMLDivElement;
let launched = false;
let progressLine = new MediaProgressLine(audioEl.audio, doc.supportsStreaming);
audioEl.addAudioListener('ended', () => {
audioEl.classList.remove('audio-show-progress');
// Reset subtitle
subtitleDiv.innerHTML = subtitle;
launched = false;
});
const onPlaying = () => {
if(!launched) {
audioEl.classList.add('audio-show-progress');
launched = true;
subtitleDiv.innerHTML = '';
if(progressLine) {
subtitleDiv.append(progressLine.container);
}
}
};
audioEl.addAudioListener('playing', onPlaying);
if(!audioEl.audio.paused || audioEl.audio.currentTime > 0) {
onPlaying();
}
return () => {
progressLine.removeListeners();
progressLine.container.remove();
progressLine = null;
};
};
return onLoad;
}
export default class AudioElement extends HTMLElement {
public audio: HTMLAudioElement;
public preloader: ProgressivePreloader;
private attachedHandlers: {[name: string]: any[]} = {};
private onTypeDisconnect: () => void;
constructor() {
super();
// элемент создан
}
connectedCallback() {
// браузер вызывает этот метод при добавлении элемента в документ
// (может вызываться много раз, если элемент многократно добавляется/удаляется)
this.classList.add('audio');
const mid = +this.getAttribute('message-id');
const docID = this.getAttribute('doc-id');
const doc = appDocsManager.getDoc(docID);
const uploading = +doc.id < 0;
const durationStr = String(doc.duration | 0).toHHMMSS(true);
this.innerHTML = `<div class="audio-toggle audio-ico tgico-largeplay"></div>`;
const downloadDiv = document.createElement('div');
downloadDiv.classList.add('audio-download');
if(!uploading && doc.type != 'audio') {
downloadDiv.innerHTML = '<div class="tgico-download"></div>';
}
if(doc.type != 'audio' || uploading) {
this.append(downloadDiv);
}
const onTypeLoad = doc.type == 'voice' ? wrapVoiceMessage(doc, this) : wrapAudio(doc, this);
const audioTimeDiv = this.querySelector('.audio-time') as HTMLDivElement;
audioTimeDiv.innerHTML = durationStr;
const onLoad = (autoload = true) => {
const audio = this.audio = appMediaPlaybackController.addMedia(doc, mid, autoload);
this.onTypeDisconnect = onTypeLoad();
const toggle = this.querySelector('.audio-toggle') as HTMLDivElement;
const onPlaying = () => {
audioTimeDiv.innerText = String(audio.currentTime | 0).toHHMMSS(true) + ' / ' + durationStr;
if(!audio.paused) {
toggle.classList.remove('tgico-largeplay');
toggle.classList.add('tgico-largepause');
}
};
if(!audio.paused || (audio.currentTime > 0 && audio.currentTime != audio.duration)) {
onPlaying();
audioTimeDiv.innerText = String(audio.currentTime | 0).toHHMMSS(true) + ' / ' + durationStr;
}
toggle.addEventListener('click', () => {
if(audio.paused) audio.play().catch(() => {});
else audio.pause();
});
this.addAudioListener('ended', () => {
toggle.classList.add('tgico-largeplay');
toggle.classList.remove('tgico-largepause');
});
this.addAudioListener('timeupdate', () => {
if(appMediaPlaybackController.isSafariBuffering(audio)) return;
audioTimeDiv.innerText = String(audio.currentTime | 0).toHHMMSS(true) + ' / ' + durationStr;
});
this.addAudioListener('pause', () => {
toggle.classList.add('tgico-largeplay');
toggle.classList.remove('tgico-largepause');
});
this.addAudioListener('playing', onPlaying);
};
if(!uploading) {
let preloader: ProgressivePreloader = this.preloader;
if(doc.type == 'voice') {
let download: Download;
const onClick = () => {
if(!download) {
if(!preloader) {
preloader = new ProgressivePreloader(null, true);
}
download = appDocsManager.downloadDocNew(doc);
preloader.attach(downloadDiv, true, download);
download.then(() => {
downloadDiv.remove();
this.removeEventListener('click', onClick);
onLoad();
}).catch(err => {
if(err.name === 'AbortError') {
download = null;
}
}).finally(() => {
downloadDiv.classList.remove('downloading');
});
downloadDiv.classList.add('downloading');
} else {
download.cancel();
}
};
this.addEventListener('click', onClick);
this.click();
} else {
onLoad(false);
//if(appMediaPlaybackController.mediaExists(mid)) { // чтобы показать прогресс, если аудио уже было скачано
//onLoad();
//} else {
const r = () => {
//onLoad();
appMediaPlaybackController.resolveWaitingForLoadMedia(mid);
appMediaPlaybackController.willBePlayed(this.audio); // prepare for loading audio
if(!preloader) {
preloader = new ProgressivePreloader(null, false);
}
if(isSafari) {
this.audio.autoplay = true;
this.audio.play().catch(() => {});
}
preloader.attach(downloadDiv);
this.append(downloadDiv);
new Promise((resolve) => {
if(this.audio.readyState >= 2) resolve();
else this.addAudioListener('canplay', resolve);
}).then(() => {
downloadDiv.remove();
//setTimeout(() => {
// release loaded audio
if(appMediaPlaybackController.willBePlayedMedia == this.audio) {
this.audio.play();
appMediaPlaybackController.willBePlayedMedia = null;
}
//}, 10e3);
});
};
this.addEventListener('click', r, {once: true});
//}
}
} else {
this.preloader.attach(downloadDiv, false);
//onLoad();
}
}
public addAudioListener(name: string, callback: any) {
if(!this.attachedHandlers[name]) this.attachedHandlers[name] = [];
this.attachedHandlers[name].push(callback);
this.audio.addEventListener(name, callback);
}
disconnectedCallback() {
// браузер вызывает этот метод при удалении элемента из документа
// (может вызываться много раз, если элемент многократно добавляется/удаляется)
if(this.onTypeDisconnect) {
this.onTypeDisconnect();
this.onTypeDisconnect = null;
}
for(let name in this.attachedHandlers) {
for(let callback of this.attachedHandlers[name]) {
this.audio.removeEventListener(name, callback);
}
delete this.attachedHandlers[name];
}
this.preloader = null;
}
static get observedAttributes(): string[] {
return [/* массив имён атрибутов для отслеживания их изменений */];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
// вызывается при изменении одного из перечисленных выше атрибутов
}
adoptedCallback() {
// вызывается, когда элемент перемещается в новый документ
// (происходит в document.adoptNode, используется очень редко)
}
// у элемента могут быть ещё другие методы и свойства
}
customElements.define("audio-element", AudioElement);