tweb-i2p/src/components/appMediaPlaybackController.ts
2021-09-25 11:47:05 +04:00

504 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.

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import rootScope from "../lib/rootScope";
import appMessagesManager from "../lib/appManagers/appMessagesManager";
import appDocsManager, {MyDocument} from "../lib/appManagers/appDocsManager";
import { CancellablePromise, deferredPromise } from "../helpers/cancellablePromise";
import { isApple, isSafari } from "../helpers/userAgent";
import { MOUNT_CLASS_TO } from "../config/debug";
import appDownloadManager from "../lib/appManagers/appDownloadManager";
import simulateEvent from "../helpers/dom/dispatchEvent";
import type { SearchSuperContext } from "./appSearchSuper.";
import { copy, deepEqual } from "../helpers/object";
import { DocumentAttribute, Message, MessageMedia, PhotoSize } from "../layer";
import appPhotosManager from "../lib/appManagers/appPhotosManager";
import { isTouchSupported } from "../helpers/touchSupport";
import appAvatarsManager from "../lib/appManagers/appAvatarsManager";
import appPeersManager from "../lib/appManagers/appPeersManager";
import I18n from "../lib/langPack";
import { SearchListLoader } from "./appMediaViewer";
// TODO: если удалить сообщение, и при этом аудио будет играть - оно не остановится, и можно будет по нему перейти вникуда
// TODO: Safari: проверить стрим, включить его и сразу попробовать включить видео или другую песню
// TODO: Safari: попробовать замаскировать подгрузку последнего чанка
// TODO: Safari: пофиксить момент, когда заканчивается песня и пытаешься включить её заново - прогресс сразу в конце
export type MediaItem = {mid: number, peerId: number};
type HTMLMediaElement = HTMLAudioElement | HTMLVideoElement;
const SHOULD_USE_SAFARI_FIX = (() => {
try {
return isSafari && +navigator.userAgent.match(/ Version\/(\d+)/)[1] < 14;
} catch(err) {
return false;
}
})();
const SEEK_OFFSET = 10;
class AppMediaPlaybackController {
private container: HTMLElement;
private media: {
[peerId: string]: {
[mid: string]: HTMLMediaElement
}
} = {};
private playingMedia: HTMLMediaElement;
private waitingMediaForLoad: {
[peerId: string]: {
[mid: string]: CancellablePromise<void>
}
} = {};
public willBePlayedMedia: HTMLMediaElement;
private searchContext: SearchSuperContext;
private listLoader: SearchListLoader<MediaItem>;
constructor() {
this.container = document.createElement('div');
//this.container.style.cssText = 'position: absolute; top: -10000px; left: -10000px;';
this.container.style.cssText = 'display: none;';
document.body.append(this.container);
if(navigator.mediaSession) {
const actions: {[action in MediaSessionAction]?: MediaSessionActionHandler} = {
play: this.play,
pause: this.pause,
stop: this.stop,
seekbackward: this.seekBackward,
seekforward: this.seekForward,
seekto: this.seekTo,
previoustrack: this.previous,
nexttrack: this.next
};
for(const action in actions) {
try {
navigator.mediaSession.setActionHandler(action as MediaSessionAction, actions[action as MediaSessionAction]);
} catch(err) {
console.warn('MediaSession action is not supported:', action);
}
}
}
}
public seekBackward = (details: MediaSessionActionDetails) => {
const media = this.playingMedia
if(media) {
media.currentTime = Math.max(0, media.currentTime - (details.seekOffset || SEEK_OFFSET));
}
};
public seekForward = (details: MediaSessionActionDetails) => {
const media = this.playingMedia
if(media) {
media.currentTime = Math.min(media.duration, media.currentTime + (details.seekOffset || SEEK_OFFSET));
}
};
public seekTo = (details: MediaSessionActionDetails) => {
const media = this.playingMedia
if(media) {
media.currentTime = details.seekTime;
}
};
public addMedia(peerId: number, doc: MyDocument, mid: number, autoload = true): HTMLMediaElement {
const storage = this.media[peerId] ?? (this.media[peerId] = {});
if(storage[mid]) return storage[mid];
const media = document.createElement(doc.type === 'round' ? 'video' : 'audio');
//const source = document.createElement('source');
//source.type = doc.type === 'voice' && !opusDecodeController.isPlaySupported() ? 'audio/wav' : doc.mime_type;
if(doc.type === 'round') {
media.setAttribute('playsinline', 'true');
//media.muted = true;
}
media.dataset.peerId = '' + peerId;
media.dataset.mid = '' + mid;
media.dataset.type = doc.type;
//media.autoplay = true;
media.volume = 1;
//media.append(source);
this.container.append(media);
media.addEventListener('play', this.onPlay);
media.addEventListener('pause', this.onPause);
media.addEventListener('ended', this.onEnded);
/* const onError = (e: Event) => {
//console.log('appMediaPlaybackController: video onError', e);
if(this.nextMid === mid) {
this.loadSiblingsMedia(peerId, doc.type as MediaType, mid).then(() => {
if(this.nextMid && storage[this.nextMid]) {
storage[this.nextMid].play();
}
});
}
};
media.addEventListener('error', onError); */
const deferred = deferredPromise<void>();
if(autoload) {
deferred.resolve();
} else {
const waitingStorage = this.waitingMediaForLoad[peerId] ?? (this.waitingMediaForLoad[peerId] = {});
waitingStorage[mid] = deferred;
}
deferred.then(() => {
//media.autoplay = true;
//console.log('will set media url:', media, doc, doc.type, doc.url);
((!doc.supportsStreaming ? appDocsManager.downloadDoc(doc) : Promise.resolve()) as Promise<any>).then(() => {
if(doc.type === 'audio' && doc.supportsStreaming && SHOULD_USE_SAFARI_FIX) {
this.handleSafariStreamable(media);
}
// setTimeout(() => {
const cacheContext = appDownloadManager.getCacheContext(doc);
media.src = cacheContext.url;
// }, doc.supportsStreaming ? 500e3 : 0);
});
}/* , onError */);
return storage[mid] = media;
}
// safari подгрузит последний чанк и песня включится,
// при этом этот чанк нельзя руками отдать из SW, потому что браузер тогда теряется
private handleSafariStreamable(media: HTMLMediaElement) {
media.addEventListener('play', () => {
/* if(media.readyState === 4) { // https://developer.mozilla.org/ru/docs/Web/API/XMLHttpRequest/readyState
return;
} */
//media.volume = 0;
const currentTime = media.currentTime;
//this.setSafariBuffering(media, true);
media.addEventListener('progress', () => {
media.currentTime = media.duration - 1;
media.addEventListener('progress', () => {
media.currentTime = currentTime;
//media.volume = 1;
//this.setSafariBuffering(media, false);
if(!media.paused) {
media.play()/* .catch(() => {}) */;
}
}, {once: true});
}, {once: true});
}/* , {once: true} */);
}
public resolveWaitingForLoadMedia(peerId: number, mid: number) {
const storage = this.waitingMediaForLoad[peerId];
if(!storage) {
return;
}
const promise = storage[mid];
if(promise) {
promise.resolve();
delete storage[mid];
}
}
/**
* Only for audio
*/
public isSafariBuffering(media: HTMLMediaElement) {
/// @ts-ignore
return !!media.safariBuffering;
}
private setSafariBuffering(media: HTMLMediaElement, value: boolean) {
// @ts-ignore
media.safariBuffering = value;
}
private async setNewMediadata(message: Message.message) {
const playingMedia = this.playingMedia;
const doc = (message.media as MessageMedia.messageMediaDocument).document as MyDocument;
const artwork: MediaImage[] = [];
const isVoice = doc.type === 'voice' || doc.type === 'round';
let title = '', artist = '';
if(doc.thumbs?.length) {
const size = doc.thumbs[doc.thumbs.length - 1];
if(!(size as PhotoSize.photoStrippedSize).bytes) {
const cacheContext = appDownloadManager.getCacheContext(doc, size.type);
if(cacheContext.url) {
artwork.push({
src: cacheContext.url,
sizes: `${(size as PhotoSize.photoSize).w}x${(size as PhotoSize.photoSize).h}`,
type: 'image/jpeg'
});
} else {
const download = appPhotosManager.preloadPhoto(doc, size);
download.then(() => {
if(this.playingMedia !== playingMedia || !cacheContext.url) {
return;
}
this.setNewMediadata(message);
});
}
}
} else if(isVoice) {
const peerId = message.fromId || message.peerId;
const peerPhoto = appPeersManager.getPeerPhoto(peerId);
const result = appAvatarsManager.loadAvatar(peerId, peerPhoto, 'photo_small');
if(result.cached) {
const url = await result.loadPromise;
artwork.push({
src: url,
sizes: '160x160',
type: 'image/jpeg'
});
} else {
result.loadPromise.then((url) => {
if(this.playingMedia !== playingMedia || !url) {
return;
}
this.setNewMediadata(message);
});
}
title = appPeersManager.getPeerTitle(peerId, true, false);
artist = I18n.format(doc.type === 'voice' ? 'AttachAudio' : 'AttachRound', true);
}
if(!isVoice) {
const attribute = doc.attributes.find(attribute => attribute._ === 'documentAttributeAudio') as DocumentAttribute.documentAttributeAudio;
title = attribute && attribute.title || doc.file_name;
artist = attribute && attribute.performer;
}
if(!artwork.length) {
if(isApple) {
if(isTouchSupported) {
artwork.push({
src: `assets/img/apple-touch-icon-precomposed.png`,
sizes: '180x180',
type: 'image/png'
});
} else {
artwork.push({
src: `assets/img/apple-touch-icon.png`,
sizes: '180x180',
type: 'image/png'
});
}
} else {
[72, 96, 144, 192, 256, 384, 512].forEach(size => {
const sizes = `${size}x${size}`;
artwork.push({
src: `assets/img/android-chrome-${sizes}.png`,
sizes,
type: 'image/png'
});
});
}
}
const metadata = new MediaMetadata({
title,
artist,
artwork
});
navigator.mediaSession.metadata = metadata;
}
onPlay = (e?: Event) => {
const media = e.target as HTMLMediaElement;
const peerId = +media.dataset.peerId;
const mid = +media.dataset.mid;
//console.log('appMediaPlaybackController: video playing', this.currentPeerId, this.playingMedia, media);
const message = appMessagesManager.getMessageByPeer(peerId, mid);
const previousMedia = this.playingMedia;
if(previousMedia !== media) {
this.stop();
this.playingMedia = media;
if('mediaSession' in navigator) {
this.setNewMediadata(message);
}
}
// audio_pause не успеет сработать без таймаута
setTimeout(() => {
rootScope.dispatchEvent('audio_play', {peerId, doc: message.media.document, mid});
}, 0);
};
onPause = (e?: Event) => {
/* const target = e.target as HTMLMediaElement;
if(!isInDOM(target)) {
this.container.append(target);
target.play();
return;
} */
rootScope.dispatchEvent('audio_pause');
};
onEnded = (e?: Event) => {
if(!e.isTrusted) {
return;
}
this.onPause(e);
//console.log('on media end');
this.next();
};
public toggle(play?: boolean) {
if(!this.playingMedia) {
return;
}
if(play === undefined) {
play = this.playingMedia.paused;
}
if(this.playingMedia.paused !== play) {
return;
}
if(play) {
this.playingMedia.play();
} else {
this.playingMedia.pause();
}
}
public play = () => {
return this.toggle(true);
};
public pause = () => {
return this.toggle(false);
};
public stop = () => {
const media = this.playingMedia;
if(media) {
if(!media.paused) {
media.pause();
}
media.currentTime = 0;
simulateEvent(media, 'ended');
// this.playingMedia = undefined;
}
};
public playItem = (item: MediaItem) => {
const {peerId, mid} = item;
const media = this.media[peerId][mid];
/* if(isSafari) {
media.autoplay = true;
} */
this.resolveWaitingForLoadMedia(peerId, mid);
setTimeout(() => {
media.play()//.catch(() => {});
}, 0);
};
public next = () => {
this.listLoader.go(1);
};
public previous = () => {
const media = this.playingMedia;
if(media && media.currentTime > 5) {
media.currentTime = 0;
this.toggle(true);
return;
}
this.listLoader.go(-1);
};
public willBePlayed(media: HTMLMediaElement) {
this.willBePlayedMedia = media;
}
public setSearchContext(context: SearchSuperContext) {
if(deepEqual(this.searchContext, context)) {
return false;
}
this.searchContext = copy(context); // {_: type === 'audio' ? 'inputMessagesFilterMusic' : 'inputMessagesFilterRoundVoice'}
return true;
}
public setTargets(current: MediaItem, prev?: MediaItem[], next?: MediaItem[]) {
if(!this.listLoader) {
this.listLoader = new SearchListLoader({
loadCount: 10,
loadWhenLeft: 5,
processItem: (item: Message.message) => {
const {peerId, mid} = item;
this.addMedia(peerId, (item.media as MessageMedia.messageMediaDocument).document as MyDocument, mid, false);
return {peerId, mid};
},
onJump: (item, older) => {
this.playItem(item);
}
});
} else {
this.listLoader.reset();
}
const reverse = this.searchContext.folderId !== undefined ? false : true;
if(prev) {
this.listLoader.setTargets(prev, next, reverse);
} else {
this.listLoader.reverse = reverse;
}
this.listLoader.setSearchContext(this.searchContext);
this.listLoader.current = current;
this.listLoader.load(true);
this.listLoader.load(false);
}
}
const appMediaPlaybackController = new AppMediaPlaybackController();
MOUNT_CLASS_TO.appMediaPlaybackController = appMediaPlaybackController;
export default appMediaPlaybackController;