Telegram Web K with changes to work inside I2P
https://web.telegram.i2p/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
505 lines
15 KiB
505 lines
15 KiB
/* |
|
* 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 { IS_APPLE, IS_SAFARI } from "../environment/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 { IS_TOUCH_SUPPORTED } from "../environment/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 IS_SAFARI && +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); |
|
if(peerPhoto) { |
|
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(IS_APPLE) { |
|
if(IS_TOUCH_SUPPORTED) { |
|
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;
|
|
|