Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,75 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
load(
"@build_bazel_rules_apple//apple:resources.bzl",
"apple_resource_bundle",
"apple_resource_group",
)
load("//build-system/bazel-utils:plist_fragment.bzl",
"plist_fragment",
)
filegroup(
name = "HlsBundleContents",
srcs = glob([
"HlsBundle/**",
]),
visibility = ["//visibility:public"],
)
plist_fragment(
name = "HlsBundleInfoPlist",
extension = "plist",
template =
"""
<key>CFBundleIdentifier</key>
<string>org.telegram.TelegramUniversalVideoContent</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleName</key>
<string>TelegramUniversalVideoContent</string>
"""
)
apple_resource_bundle(
name = "HlsBundle",
infoplists = [
":HlsBundleInfoPlist",
],
resources = [
":HlsBundleContents",
],
)
swift_library(
name = "TelegramUniversalVideoContent",
module_name = "TelegramUniversalVideoContent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
data = [
":HlsBundle",
],
deps = [
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//submodules/AccountContext:AccountContext",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/PhotoResources:PhotoResources",
"//submodules/LegacyComponents:LegacyComponents",
"//submodules/RadialStatusNode:RadialStatusNode",
"//submodules/AppBundle:AppBundle",
"//submodules/Utils/RangeSet:RangeSet",
"//submodules/TelegramVoip",
"//submodules/ManagedFile",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,38 @@
class ConsolePolyfill {
constructor() {
}
log(...messageArgs) {
var string = "";
for (const arg of messageArgs) {
string += arg;
}
_JsCorePolyfills.consoleLog(string);
}
error(...messageArgs) {
var string = "";
for (const arg of messageArgs) {
string += arg;
}
_JsCorePolyfills.consoleLog(string);
}
}
class PerformancePolyfill {
constructor() {
}
now() {
return _JsCorePolyfills.performanceNow();
}
}
console = new ConsolePolyfill();
performance = new PerformancePolyfill();
self = {
console: console,
performance: performance
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
/*! https://mths.be/base64 v1.0.0 by @mathias | MIT license */
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="utf-8"><title>Production</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><script src="index.bundle.js"></script></body></html>
@@ -0,0 +1,28 @@
# Node modules
node_modules/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Webpack and build artifacts
/dist
/build
# Environment files
.env
.env.local
.env.*.local
# OS generated
.DS_Store
Thumbs.db
@@ -0,0 +1,8 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
]
}
@@ -0,0 +1,7 @@
#!/bin/sh
mkdir -p ../HlsBundle
rm -rf ../HlsBundle/index
mkdir ../HlsBundle/index
npm run build-$1
cp ./dist/* ../HlsBundle/index/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
{
"name": "myhls",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build-development": "webpack --config webpack.dev.js",
"build-release": "webpack --config webpack.prod.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"expose-loader": "^5.0.0",
"express": "^4.18.2",
"html-webpack-plugin": "^5.5.3",
"style-loader": "^3.3.3",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.1",
"webpack-dev-server": "^4.15.1",
"webpack-merge": "^6.0.1"
},
"dependencies": {
"base-64": "^1.0.0",
"event-target-polyfill": "^0.0.4",
"hls.js": "^1.5.15"
}
}
@@ -0,0 +1,20 @@
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);
// Tell express to use the webpack-dev-middleware and use the webpack.config.js
// configuration file as a base.
app.use(
webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
})
);
// Serve the files on port 3000.
app.listen(3000, function () {
console.log('Example app listening on port 3000!\n');
});
@@ -0,0 +1,240 @@
import { TimeRangesStub } from "./TimeRangesStub.js"
function bytesToBase64(bytes) {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte),
).join("");
return btoa(binString);
}
export class SourceBufferListStub extends EventTarget {
constructor() {
super();
this._buffers = [];
}
_add(buffer) {
this._buffers.push(buffer);
this.dispatchEvent(new Event('addsourcebuffer'));
}
_remove(buffer) {
const index = this._buffers.indexOf(buffer);
if (index === -1) {
return false;
}
this._buffers.splice(index, 1);
this.dispatchEvent(new Event('removesourcebuffer'));
return true;
}
get length() {
return this._buffers.length;
}
item(index) {
return this._buffers[index];
}
[Symbol.iterator]() {
return this._buffers[Symbol.iterator]();
}
}
export class SourceBufferStub extends EventTarget {
constructor(mediaSource, mimeType) {
super();
this.mediaSource = mediaSource;
this.mimeType = mimeType;
this.updating = false;
this.buffered = new TimeRangesStub();
this.timestampOffset = 0;
this.appendWindowStart = 0;
this.appendWindowEnd = Infinity;
this.bridgeId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeObjectMap[this.bridgeId] = this;
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "constructor", {
"mediaSourceId": this.mediaSource.bridgeId,
"mimeType": mimeType
});
}
appendBuffer(data) {
if (this.updating) {
throw new DOMException('SourceBuffer is updating', 'InvalidStateError');
}
this.updating = true;
this.dispatchEvent(new Event('updatestart'));
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "appendBuffer", {
"data": bytesToBase64(data)
}).then((result) => {
const updatedRanges = result["ranges"];
var ranges = [];
for (var i = 0; i < updatedRanges.length; i += 2) {
ranges.push({
start: updatedRanges[i],
end: updatedRanges[i + 1]
});
}
this.buffered._ranges = ranges;
this.mediaSource._reopen();
this.mediaSource.emitUpdatedBuffer();
this.updating = false;
this.dispatchEvent(new Event('update'));
this.dispatchEvent(new Event('updateend'));
});
}
abort() {
if (this.updating) {
this.updating = false;
this.dispatchEvent(new Event('abort'));
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "abort", {}).then((result) => {
});
}
}
remove(start, end) {
if (this.updating) {
throw new DOMException('SourceBuffer is updating', 'InvalidStateError');
}
this.updating = true;
this.dispatchEvent(new Event('updatestart'));
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "remove", {
"start": start,
"end": end
}).then((result) => {
const updatedRanges = result["ranges"];
var ranges = [];
for (var i = 0; i < updatedRanges.length; i += 2) {
ranges.push({
start: updatedRanges[i],
end: updatedRanges[i + 1]
});
}
this.buffered._ranges = ranges;
this.mediaSource._reopen();
this.mediaSource.emitUpdatedBuffer();
this.updating = false;
this.dispatchEvent(new Event('update'));
this.dispatchEvent(new Event('updateend'));
});
}
}
export class MediaSourceStub extends EventTarget {
constructor() {
super();
this.internalId = window.nextInternalId;
window.nextInternalId += 1;
this.bridgeId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeObjectMap[this.bridgeId] = this;
this.sourceBuffers = new SourceBufferListStub();
this.activeSourceBuffers = new SourceBufferListStub();
this.readyState = 'closed';
this._duration = NaN;
window.bridgeInvokeAsync(this.bridgeId, "MediaSource", "constructor", {
"id": this.internalId
});
// Simulate asynchronous opening of MediaSource
setTimeout(() => {
this.readyState = 'open';
this.dispatchEvent(new Event('sourceopen'));
}, 0);
}
static isTypeSupported(mimeType) {
// Assume all MIME types are supported in this stub
return true;
}
emitUpdatedBuffer() {
this.dispatchEvent(new Event("bufferChanged"));
}
getBufferedRanges() {
if (this.sourceBuffers._buffers.length != 0) {
return this.sourceBuffers._buffers[0].buffered._ranges;
}
return [];
}
addSourceBuffer(mimeType) {
if (this.readyState !== 'open') {
throw new DOMException('MediaSource is not open', 'InvalidStateError');
}
const sourceBuffer = new SourceBufferStub(this, mimeType);
this.sourceBuffers._add(sourceBuffer);
this.activeSourceBuffers._add(sourceBuffer);
this.dispatchEvent(new Event("bufferChanged"));
window.bridgeInvokeAsync(this.bridgeId, "MediaSource", "updateSourceBuffers", {
"ids": this.sourceBuffers._buffers.map((sb) => sb.bridgeId)
}).then((result) => {
})
return sourceBuffer;
}
removeSourceBuffer(sourceBuffer) {
if (!this.sourceBuffers._remove(sourceBuffer)) {
throw new DOMException('SourceBuffer not found', 'NotFoundError');
}
this.activeSourceBuffers._remove(sourceBuffer);
this.dispatchEvent(new Event("bufferChanged"));
window.bridgeInvokeAsync(this.bridgeId, "MediaSource", "updateSourceBuffers", {
"ids": this.sourceBuffers._buffers.map((sb) => sb.bridgeId)
}).then((result) => {
})
}
endOfStream(error) {
if (this.readyState !== 'open') {
throw new DOMException('MediaSource is not open', 'InvalidStateError');
}
this.readyState = 'ended';
this.dispatchEvent(new Event('sourceended'));
}
_reopen() {
if (this.readyState !== 'open') {
this.readyState = 'open';
this.dispatchEvent(new Event('sourceopen'));
}
}
set duration(value) {
if (this.readyState === 'closed') {
throw new DOMException('MediaSource is closed', 'InvalidStateError');
}
this._duration = value;
window.bridgeInvokeAsync(this.bridgeId, "MediaSource", "setDuration", {
"duration": value
}).then((result) => {
})
}
get duration() {
return this._duration;
}
}
@@ -0,0 +1,85 @@
export class TextTrackStub extends EventTarget {
constructor(kind = '', label = '', language = '') {
super();
this.kind = kind;
this.label = label;
this.language = language;
this.mode = 'disabled'; // 'disabled', 'hidden', or 'showing'
this.cues = new TextTrackCueListStub();
this.activeCues = new TextTrackCueListStub();
}
addCue(cue) {
this.cues._add(cue);
}
removeCue(cue) {
this.cues._remove(cue);
}
}
export class TextTrackCueListStub {
constructor() {
this._cues = [];
}
get length() {
return this._cues.length;
}
item(index) {
return this._cues[index];
}
getCueById(id) {
return this._cues.find(cue => cue.id === id) || null;
}
_add(cue) {
this._cues.push(cue);
}
_remove(cue) {
const index = this._cues.indexOf(cue);
if (index !== -1) {
this._cues.splice(index, 1);
}
}
[Symbol.iterator]() {
return this._cues[Symbol.iterator]();
}
}
export class TextTrackListStub extends EventTarget {
constructor() {
super();
this._tracks = [];
}
get length() {
return this._tracks.length;
}
item(index) {
return this._tracks[index];
}
_add(track) {
this._tracks.push(track);
this.dispatchEvent(new Event('addtrack'));
}
_remove(track) {
const index = this._tracks.indexOf(track);
if (index !== -1) {
this._tracks.splice(index, 1);
this.dispatchEvent(new Event('removetrack'));
}
}
[Symbol.iterator]() {
return this._tracks[Symbol.iterator]();
}
}
@@ -0,0 +1,74 @@
export class TimeRangesStub {
constructor() {
this._ranges = [];
}
get length() {
return this._ranges.length;
}
start(index) {
if (index < 0 || index >= this._ranges.length) {
throw new DOMException('Invalid index', 'IndexSizeError');
}
return this._ranges[index].start;
}
end(index) {
if (index < 0 || index >= this._ranges.length) {
throw new DOMException('Invalid index', 'IndexSizeError');
}
return this._ranges[index].end;
}
// Helper method to add a range
_addRange(start, end) {
this._ranges.push({ start, end });
this._normalizeRanges();
}
// Helper method to remove ranges that overlap with a given range
_removeRange(start, end) {
let updatedRanges = [];
for (let range of this._ranges) {
if (range.end <= start || range.start >= end) {
// No overlap, keep the range as is
updatedRanges.push(range);
} else if (range.start < start && range.end > end) {
// The range fully covers the removal range, split into two ranges
updatedRanges.push({ start: range.start, end: start });
updatedRanges.push({ start: end, end: range.end });
} else if (range.start >= start && range.end <= end) {
// The range is entirely within the removal range, remove it
// Do not add to updatedRanges
} else if (range.start < start && range.end > start && range.end <= end) {
// The range overlaps with the removal range on the left
updatedRanges.push({ start: range.start, end: start });
} else if (range.start >= start && range.start < end && range.end > end) {
// The range overlaps with the removal range on the right
updatedRanges.push({ start: end, end: range.end });
}
}
this._ranges = updatedRanges;
}
// Normalize and merge overlapping ranges
_normalizeRanges() {
this._ranges.sort((a, b) => a.start - b.start);
let normalized = [];
for (let range of this._ranges) {
if (normalized.length === 0) {
normalized.push(range);
} else {
let last = normalized[normalized.length - 1];
if (range.start <= last.end) {
last.end = Math.max(last.end, range.end);
} else {
normalized.push(range);
}
}
}
this._ranges = normalized;
}
}
@@ -0,0 +1,203 @@
import { TimeRangesStub } from "./TimeRangesStub.js"
import { TextTrackStub, TextTrackListStub } from "./TextTrackStub.js"
export class VideoElementStub extends EventTarget {
constructor(id) {
super();
this.instanceId = id;
this.bridgeId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeObjectMap[this.bridgeId] = this;
this._currentTime = 0.0;
this.duration = NaN;
this.paused = true;
this._playbackRate = 1.0;
this.volume = 1.0;
this.muted = false;
this.readyState = 0;
this.networkState = 0;
this.buffered = new TimeRangesStub();
this.seeking = false;
this.loop = false;
this.autoplay = false;
this.controls = false;
this.error = null;
this._src = '';
this.videoWidth = 0;
this.videoHeight = 0;
this.textTracks = new TextTrackListStub();
this.isWaiting = false;
this.currentMedia = null;
window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "constructor", {
"instanceId": this.instanceId
});
setTimeout(() => {
this.readyState = 4; // HAVE_ENOUGH_DATA
this.dispatchEvent(new Event('loadedmetadata'));
this.dispatchEvent(new Event('loadeddata'));
this.dispatchEvent(new Event('canplay'));
this.dispatchEvent(new Event('canplaythrough'));
}, 0);
}
get currentTime() {
return this._currentTime;
}
set currentTime(value) {
if (this._currentTime != value) {
this._currentTime = value;
this.dispatchEvent(new Event('seeking'));
window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "setCurrentTime", {
"instanceId": this.instanceId,
"currentTime": value
}).then((result) => {
this.dispatchEvent(new Event('seeked'));
})
}
}
get playbackRate() {
return this._playbackRate;
}
set playbackRate(value) {
this._playbackRate = value;
window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "setPlaybackRate", {
"instanceId": this.instanceId,
"playbackRate": value
}).then((result) => {
})
}
get src() {
return this._src;
}
set src(value) {
if (this.currentMedia) {
this.currentMedia.removeEventListener("bufferChanged", false);
}
this._src = value;
var media = window.mediaSourceMap[this._src];
this.currentMedia = media;
if (media) {
media.addEventListener("bufferChanged", () => {
this.updateBufferedFromMediaSource();
}, false);
window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "setMediaSource", {
"instanceId": this.instanceId,
"mediaSourceId": media.bridgeId
}).then((result) => {
})
}
}
removeAttribute(name) {
if (name === "src") {
this._src = "";
}
}
querySelectorAll(name) {
if (global.isJsCore) {
return [];
} else {
const fragment = document.createDocumentFragment();
return fragment.querySelectorAll('*');
}
}
removeChild(child) {
}
updateBufferedFromMediaSource() {
var currentMedia = this.currentMedia;
if (currentMedia) {
this.buffered._ranges = currentMedia.getBufferedRanges();
} else {
this.buffered._ranges = [];
}
}
bridgeUpdateStatus(dict) {
var paused = !dict["isPlaying"];
var isWaiting = dict["isWaiting"];
var currentTime = dict["currentTime"];
if (this.paused != paused) {
this.paused = paused;
if (paused) {
this.dispatchEvent(new Event('pause'));
} else {
this.dispatchEvent(new Event('play'));
this.dispatchEvent(new Event('playing'));
}
}
if (this.isWaiting != isWaiting) {
this.isWaiting = isWaiting;
if (isWaiting) {
this.dispatchEvent(new Event('waiting'));
}
}
if (this._currentTime != currentTime) {
this._currentTime = currentTime;
this.dispatchEvent(new Event('timeupdate'));
}
}
play() {
if (this.paused) {
return window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "play", {
"instanceId": this.instanceId,
}).then((result) => {
this.dispatchEvent(new Event('play'));
this.dispatchEvent(new Event('playing'));
})
} else {
return Promise.resolve();
}
}
pause() {
if (!this.paused) {
this.paused = true;
this.dispatchEvent(new Event('pause'));
return window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "pause", {
"instanceId": this.instanceId,
}).then((result) => {
})
}
}
canPlayType(type) {
return 'probably';
}
addTextTrack(kind, label, language) {
const textTrack = new TextTrackStub(kind, label, language);
this.textTracks._add(textTrack);
return textTrack;
}
load() {
}
notifySeeked() {
this.dispatchEvent(new Event('seeking'));
this.dispatchEvent(new Event('seeked'));
}
}
@@ -0,0 +1,150 @@
function base64ToArrayBuffer(base64) {
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export class XMLHttpRequestStub extends EventTarget {
constructor() {
super();
this.bridgeId = window.nextInternalId;
window.nextInternalId += 1;
this.readyState = 0;
this.status = 0;
this.statusText = "";
this.responseText = "";
this.responseXML = null;
this._responseData = null;
this.onreadystatechange = null;
this._requestHeaders = {};
this._responseHeaders = {};
this._method = "";
this._url = "";
this._async = true;
this._user = null;
this._password = null;
this._responseType = "";
}
open(method, url, async = true, user = null, password = null) {
this._method = method;
this._url = url;
this._async = async;
this._user = user;
this._password = password;
this.readyState = 1; // Opened
this._triggerReadyStateChange();
}
setRequestHeader(header, value) {
this._requestHeaders[header] = value;
}
getResponseHeader(header) {
return this._responseHeaders[header.toLowerCase()] || null;
}
getAllResponseHeaders() {
return Object.entries(this._responseHeaders)
.map(([header, value]) => `${header}: ${value}`)
.join('\r\n');
}
send(body = null) {
this.readyState = 2;
this._triggerReadyStateChange();
this.readyState = 3; // Loading
this._triggerReadyStateChange();
this.dispatchEvent(new Event("loadstart"));
window.bridgeInvokeAsync(this.bridgeId, "XMLHttpRequest", "load", {
"id": this.bridgeId,
"url": this._url,
"requestHeaders": this._requestHeaders
}).then((result) => {
if (result["error"]) {
this.dispatchEvent(new Event("error"));
} else {
this.status = result["status"];
this.statusText = result["statusText"];
if (result["responseData"]) {
if (this._responseType === "arraybuffer") {
this._responseData = base64ToArrayBuffer(result["responseData"]);
} else {
this.responseText = atob(result["responseData"]);
}
this.responseXML = null;
} else {
this.response = null;
this.responseText = result["responseText"] || null;
this.responseXML = result["responseXML"] || null;
}
this._responseHeaders = result["responseHeaders"];
this.readyState = 4; // Done
this._triggerReadyStateChange();
this.dispatchEvent(new Event("load"));
}
this.dispatchEvent(new Event("loadend"));
});
}
abort() {
this.dispatchEvent(new Event("abort"));
window.bridgeInvokeAsync(this.bridgeId, "XMLHttpRequest", "abort", {
"id": this.bridgeId
});
this.readyState = 0;
this.status = 0;
this.statusText = '';
this.responseText = '';
this.responseXML = null;
this._responseHeaders = {};
this._triggerReadyStateChange();
}
overrideMimeType(mime) {
}
set responseType(type) {
this._responseType = type;
}
get responseType() {
return this._responseType;
}
get response() {
if (this._responseType === '' || this._responseType === 'text') {
return this.responseText;
}
return this._responseData;
}
_triggerReadyStateChange() {
this.dispatchEvent(new Event('readystatechange'));
if (typeof this.onreadystatechange === 'function') {
this.onreadystatechange();
}
}
// Additional methods to simulate responses
_setResponse(status, statusText, responseText, responseHeaders = {}) {
this.status = status;
this.statusText = statusText;
this.responseText = responseText;
this._responseHeaders = responseHeaders;
}
}
@@ -0,0 +1,316 @@
import "event-target-polyfill";
import {decode, encode} from "base-64";
import { VideoElementStub } from "./VideoElementStub.js"
import { MediaSourceStub, SourceBufferStub } from "./MediaSourceStub.js"
import { XMLHttpRequestStub } from "./XMLHttpRequestStub.js"
global.isJsCore = false;
if (!global.btoa) {
global.btoa = encode;
}
if (!global.atob) {
global.atob = decode;
}
if (typeof window === 'undefined') {
global.isJsCore = true;
global.navigator = {
userAgent: "Telegram"
};
global.now = function() {
return _JsCorePolyfills.performanceNow();
};
global.window = {
};
global.URL = {
};
window.webkit = {
};
window.webkit.messageHandlers = {
};
window.webkit.messageHandlers.performAction = {
};
window.webkit.messageHandlers.performAction.postMessage = function(dict) {
_JsCorePolyfills.postMessage(dict);
};
global.self.location = {
href: "http://127.0.0.1"
};
global.self.setTimeout = global.setTimeout;
global.self.setInterval = global.setInterval;
global.self.clearTimeout = global.clearTimeout;
global.self.clearInterval = global.clearTimeout;
global.self.URL = global.URL;
global.self.Date = global.Date;
}
import Hls from "hls.js";
window.bridgeObjectMap = {};
window.bridgeCallbackMap = {};
function bridgeInvokeAsync(bridgeId, className, methodName, params) {
var promiseResolve;
var promiseReject;
var result = new Promise(function(resolve, reject) {
promiseResolve = resolve;
promiseReject = reject;
});
const callbackId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeCallbackMap[callbackId] = promiseResolve;
if (window.webkit.messageHandlers) {
window.webkit.messageHandlers.performAction.postMessage({
'event': 'bridgeInvoke',
'data': {
'bridgeId': bridgeId,
'className': className,
'methodName': methodName,
'params': params,
'callbackId': callbackId
}
});
}
return result;
}
window.bridgeInvokeAsync = bridgeInvokeAsync
export function bridgeInvokeCallback(callbackId, result) {
const callback = window.bridgeCallbackMap[callbackId];
if (callback) {
callback(result);
}
}
window.nextInternalId = 0;
window.mediaSourceMap = {};
// Replace the global MediaSource with our stub
if (typeof window !== 'undefined') {
window.MediaSource = MediaSourceStub;
window.ManagedMediaSource = MediaSourceStub;
window.SourceBuffer = SourceBufferStub;
window.XMLHttpRequest = XMLHttpRequestStub;
URL.createObjectURL = function(ms) {
const url = "blob:mock-media-source:" + ms.internalId;
window.mediaSourceMap[url] = ms;
return url;
};
URL.revokeObjectURL = function(url) {
};
if (global.isJsCore) {
global.HTMLVideoElement = VideoElementStub;
global.self.MediaSource = window.MediaSource;
global.self.ManagedMediaSource = window.ManagedMediaSource;
global.self.SourceBuffer = window.SourceBuffer;
global.self.XMLHttpRequest = window.XMLHttpRequest;
global.self.HTMLVideoElement = VideoElementStub;
}
}
function postPlayerEvent(id, eventName, eventData) {
if (window.webkit && window.webkit.messageHandlers) {
window.webkit.messageHandlers.performAction.postMessage({'instanceId': id, 'event': eventName, 'data': eventData});
}
}
export class HlsPlayerInstance {
constructor(id) {
this.id = id;
this.isManifestParsed = false;
this.currentTimeUpdateTimeout = null;
this.notifySeekedOnNextStatusUpdate = false;
this.video = new VideoElementStub(this.id);
}
playerInitialize(params) {
this.video.addEventListener("playing", () => {
this.refreshPlayerStatus();
});
this.video.addEventListener("pause", () => {
this.refreshPlayerStatus();
});
this.video.addEventListener("seeking", () => {
this.refreshPlayerStatus();
});
this.video.addEventListener("waiting", () => {
this.refreshPlayerStatus();
});
this.hls = new Hls({
startLevel: 0,
testBandwidth: false,
debug: params['debug'] || true,
autoStartLoad: false,
backBufferLength: 30,
maxBufferLength: 60,
maxMaxBufferLength: 60,
maxFragLookUpTolerance: 0.001,
nudgeMaxRetry: 10000
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.isManifestParsed = true;
this.refreshPlayerStatus();
});
this.hls.on(Hls.Events.LEVEL_SWITCHED, () => {
this.refreshPlayerStatus();
});
this.hls.on(Hls.Events.LEVELS_UPDATED, () => {
this.refreshPlayerStatus();
});
this.hls.loadSource(params["urlPrefix"] + "master.m3u8");
this.hls.attachMedia(this.video);
}
playerLoad(initialLevelIndex) {
this.hls.startLevel = initialLevelIndex;
this.hls.startLoad(-1, false);
}
playerPlay() {
this.video.play();
}
playerPause() {
this.video.pause();
}
playerSetBaseRate(value) {
this.video.playbackRate = value;
}
playerSetLevel(level) {
if (level >= 0) {
this.hls.currentLevel = level;
} else {
this.hls.currentLevel = -1;
}
}
playerSetCapAutoLevel(level) {
if (level >= 0) {
this.hls.autoLevelCapping = level;
} else {
this.hls.autoLevelCapping = -1;
//this.hls.currentLevel = -1;
}
}
playerSeek(value) {
this.video.currentTime = value;
}
playerSetIsMuted(value) {
this.video.muted = value;
}
getLevels() {
var levels = [];
for (var i = 0; i < this.hls.levels.length; i++) {
var level = this.hls.levels[i];
levels.push({
'index': i,
'bitrate': level.bitrate || 0,
'width': level.width || 0,
'height': level.height || 0
});
}
return levels;
}
refreshPlayerStatus() {
var isPlaying = false;
if (!this.video.paused && !this.video.ended && this.video.readyState > 2) {
isPlaying = true;
}
postPlayerEvent(this.id, 'playerStatus', {
'isReady': this.isManifestParsed,
'isPlaying': !this.video.paused,
'rate': isPlaying ? this.video.playbackRate : 0.0,
'defaultRate': this.video.playbackRate,
'levels': this.getLevels(),
'currentLevel': this.hls.currentLevel
});
this.refreshPlayerCurrentTime();
if (isPlaying) {
if (this.currentTimeUpdateTimeout == null) {
this.currentTimeUpdateTimeout = setTimeout(() => {
this.refreshPlayerCurrentTime();
}, 200);
}
} else {
if(this.currentTimeUpdateTimeout != null){
clearTimeout(this.currentTimeUpdateTimeout);
this.currentTimeUpdateTimeout = null;
}
}
if (this.notifySeekedOnNextStatusUpdate) {
this.notifySeekedOnNextStatusUpdate = false;
this.video.notifySeeked();
}
}
playerNotifySeekedOnNextStatusUpdate() {
this.notifySeekedOnNextStatusUpdate = true;
}
refreshPlayerCurrentTime() {
postPlayerEvent(this.id, 'playerCurrentTime', {
'value': this.video.currentTime
});
this.currentTimeUpdateTimeout = setTimeout(() => {
this.refreshPlayerCurrentTime()
}, 200);
}
}
window.invokeOnLoad = function() {
postPlayerEvent(this.id, 'windowOnLoad', {
});
}
window.onload = () => {
window.invokeOnLoad();
};
window.hlsPlayer_instances = {};
window.hlsPlayer_makeInstance = function(id) {
window.hlsPlayer_instances[id] = new HlsPlayerInstance(id);
}
window.hlsPlayer_destroyInstance = function(id) {
const instance = window.hlsPlayer_instances[id];
if (instance) {
delete window.hlsPlayer_instances[id];
instance.video.pause();
instance.hls.destroy();
}
}
window.bridgeInvokeCallback = bridgeInvokeCallback;
if (global.isJsCore) {
window.onload();
}
@@ -0,0 +1,15 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
video {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
object-fit: fill;
}
@@ -0,0 +1,35 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
index: './src/index.js',
},
plugins: [
new HtmlWebpackPlugin({
title: 'Production',
scriptLoading: 'blocking',
})
],
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
publicPath: '',
},
module: {
rules: [
{
test: /\.js$/,
include: path.resolve(__dirname, 'src/index.js'),
},
{
test: /\.css$/i,
use: [
'style-loader',
'css-loader'
],
},
],
},
};
@@ -0,0 +1,10 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
devtool: 'inline-source-map',
devServer: {
static: './dist',
},
});
@@ -0,0 +1,15 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
terserOptions: {
compress: true,
},
})],
},
});
@@ -0,0 +1,99 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import UniversalMediaPlayer
import AccountContext
public final class ChatBubbleInstantVideoDecoration: UniversalVideoDecoration {
public let backgroundNode: ASDisplayNode?
public let contentContainerNode: ASDisplayNode
public let foregroundNode: ASDisplayNode?
private let tapped: () -> Void
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
private let inset: CGFloat
private var validLayout: (size: CGSize, actualSize: CGSize)?
public init(inset: CGFloat, backgroundImage: UIImage?, tapped: @escaping () -> Void) {
self.inset = inset
self.tapped = tapped
let backgroundNode = ASImageNode()
backgroundNode.isLayerBacked = true
backgroundNode.displaysAsynchronously = false
backgroundNode.displayWithoutProcessing = true
backgroundNode.image = backgroundImage
self.backgroundNode = backgroundNode
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.clipsToBounds = true
let foregroundNode = ASDisplayNode()
self.foregroundNode = foregroundNode
//foregroundNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
public func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?) {
if self.contentNode !== contentNode {
let previous = self.contentNode
self.contentNode = contentNode
if let previous = previous {
if previous.supernode === self.contentContainerNode {
previous.removeFromSupernode()
}
}
if let contentNode = contentNode {
if contentNode.supernode !== self.contentContainerNode {
self.contentContainerNode.addSubnode(contentNode)
if let validLayout = self.validLayout {
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
}
}
}
}
}
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
}
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, actualSize)
let diameter = size.width + inset
self.contentContainerNode.cornerRadius = (diameter - 3.0) / 2.0
if let backgroundNode = self.backgroundNode {
transition.updateFrame(node: backgroundNode, frame: CGRect(origin: CGPoint(), size: size))
}
if let foregroundNode = self.foregroundNode {
transition.updateFrame(node: foregroundNode, frame: CGRect(origin: CGPoint(), size: size))
}
let contentFrame = CGRect(origin: CGPoint(x: 1.5, y: 1.5), size: CGSize(width: size.width - 3.0, height: size.height - 3.0))
transition.updateFrame(node: self.contentContainerNode, frame: contentFrame)
self.contentContainerNode.subnodeTransform = CATransform3DMakeScale((contentFrame.width + 2.0) / contentFrame.width, (contentFrame.width + 2.0) / contentFrame.width, 1.0)
if let contentNode = self.contentNode {
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
}
}
public func setStatus(_ status: Signal<MediaPlayerStatus?, NoError>) {
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
//self.tapped()
}
}
public func tap() {
self.tapped()
}
}
@@ -0,0 +1,150 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import UniversalMediaPlayer
import AccountContext
import PhotoResources
public enum ChatBubbleVideoDecorationContentMode {
case aspectFit
case aspectFill
}
public final class ChatBubbleVideoDecoration: UniversalVideoDecoration {
private let nativeSize: CGSize
private let contentMode: ChatBubbleVideoDecorationContentMode
public let corners: ImageCorners
public let backgroundNode: ASDisplayNode? = nil
public let contentContainerNode: ASDisplayNode
public let foregroundNode: ASDisplayNode? = nil
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
private var validLayout: (size: CGSize, actualSize: CGSize)?
public init(corners: ImageCorners, nativeSize: CGSize, contentMode: ChatBubbleVideoDecorationContentMode, backgroundColor: UIColor) {
self.corners = corners
self.nativeSize = nativeSize
self.contentMode = contentMode
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.backgroundColor = backgroundColor
self.contentContainerNode.clipsToBounds = true
self.updateCorners(corners)
}
public func updateCorners(_ corners: ImageCorners) {
if isRoundEqualCorners(corners) {
self.contentContainerNode.cornerRadius = corners.topLeft.radius
self.contentContainerNode.layer.mask = nil
} else {
self.contentContainerNode.cornerRadius = 0
let boundingSize: CGSize = CGSize(width: max(corners.topLeft.radius, corners.bottomLeft.radius) + max(corners.topRight.radius, corners.bottomRight.radius), height: max(corners.topLeft.radius, corners.topRight.radius) + max(corners.bottomLeft.radius, corners.bottomRight.radius))
let size: CGSize = CGSize(width: boundingSize.width + corners.extendedEdges.left + corners.extendedEdges.right, height: boundingSize.height + corners.extendedEdges.top + corners.extendedEdges.bottom)
let arguments = TransformImageArguments(corners: corners, imageSize: size, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())
guard let context = DrawingContext(size: size, clear: true) else {
return
}
context.withContext { ctx in
ctx.setFillColor(UIColor.black.cgColor)
ctx.fill(arguments.drawingRect)
}
addCorners(context, arguments: arguments)
if let maskImage = context.generateImage() {
let mask = CALayer()
mask.contents = maskImage.cgImage
mask.contentsScale = maskImage.scale
mask.contentsCenter = CGRect(x: max(corners.topLeft.radius, corners.bottomLeft.radius) / maskImage.size.width, y: max(corners.topLeft.radius, corners.topRight.radius) / maskImage.size.height, width: (maskImage.size.width - max(corners.topLeft.radius, corners.bottomLeft.radius) - max(corners.topRight.radius, corners.bottomRight.radius)) / maskImage.size.width, height: (maskImage.size.height - max(corners.topLeft.radius, corners.topRight.radius) - max(corners.bottomLeft.radius, corners.bottomRight.radius)) / maskImage.size.height)
self.contentContainerNode.layer.mask = mask
self.contentContainerNode.layer.mask?.frame = self.contentContainerNode.bounds
}
}
}
public func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?) {
if self.contentNode !== contentNode {
let previous = self.contentNode
self.contentNode = contentNode
if let previous = previous {
if previous.supernode === self.contentContainerNode {
previous.removeFromSupernode()
}
}
if let contentNode = contentNode {
if contentNode.supernode !== self.contentContainerNode {
self.contentContainerNode.addSubnode(contentNode)
if let validLayout = self.validLayout {
var scaledSize: CGSize
switch self.contentMode {
case .aspectFit:
scaledSize = self.nativeSize.aspectFitted(validLayout.size)
case .aspectFill:
scaledSize = self.nativeSize.aspectFilled(validLayout.size)
}
if abs(scaledSize.width - validLayout.size.width) < 2.0 {
scaledSize.width = validLayout.size.width
}
if abs(scaledSize.height - validLayout.size.height) < 2.0 {
scaledSize.height = validLayout.size.height
}
contentNode.frame = CGRect(origin: CGPoint(x: floor((validLayout.size.width - scaledSize.width) / 2.0), y: floor((validLayout.size.height - scaledSize.height) / 2.0)), size: scaledSize)
contentNode.updateLayout(size: scaledSize, actualSize: scaledSize, transition: .immediate)
}
}
}
}
}
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
}
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, actualSize)
let bounds = CGRect(origin: CGPoint(), size: size)
if let backgroundNode = self.backgroundNode {
transition.updateFrame(node: backgroundNode, frame: bounds)
}
if let foregroundNode = self.foregroundNode {
transition.updateFrame(node: foregroundNode, frame: bounds)
}
transition.updateFrame(node: self.contentContainerNode, frame: bounds)
if let maskLayer = self.contentContainerNode.layer.mask {
transition.updateFrame(layer: maskLayer, frame: bounds)
}
if let contentNode = self.contentNode {
var scaledSize: CGSize
switch self.contentMode {
case .aspectFit:
scaledSize = self.nativeSize.aspectFitted(size)
case .aspectFill:
scaledSize = self.nativeSize.aspectFilled(size)
}
if abs(scaledSize.width - size.width) < 2.0 {
scaledSize.width = size.width
}
if abs(scaledSize.height - size.height) < 2.0 {
scaledSize.height = size.height
}
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: floor((size.width - scaledSize.width) / 2.0), y: floor((size.height - scaledSize.height) / 2.0)), size: scaledSize))
contentNode.updateLayout(size: scaledSize, actualSize: scaledSize, transition: transition)
}
}
public func setStatus(_ status: Signal<MediaPlayerStatus?, NoError>) {
}
public func tap() {
}
}
@@ -0,0 +1,121 @@
import Foundation
import AVFoundation
import SwiftSignalKit
import UniversalMediaPlayer
import AccountContext
import AVKit
public class ExternalVideoPlayer: NSObject, AVRoutePickerViewDelegate {
private let context: AccountContext
let content: NativeVideoContent
let player: AVPlayer?
private var didPlayToEndTimeObserver: NSObjectProtocol?
private var timeObserver: Any?
private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: false, progress: 0.0, display: true), soundEnabled: true)
private let _status = ValuePromise<MediaPlayerStatus>()
var status: Signal<MediaPlayerStatus, NoError> {
return self._status.get()
}
private var seekId: Int = 0
private weak var routePickerView: UIView?
public var isActiveUpdated: (Bool) -> Void = { _ in }
public init(context: AccountContext, content: NativeVideoContent) {
self.context = context
self.content = content
if let path = context.account.postbox.mediaBox.completedResourcePath(content.fileReference.media.resource, pathExtension: "mp4") {
let player = AVPlayer(url: URL(fileURLWithPath: path))
self.player = player
} else {
self.player = nil
}
super.init()
self.startObservingForAirPlayStatusChanges()
self.isActiveUpdated(self.player?.isExternalPlaybackActive ?? false)
if let player = self.player {
self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: nil, using: { [weak self] notification in
if let strongSelf = self {
strongSelf.player?.seek(to: CMTime(seconds: 0.0, preferredTimescale: 30))
strongSelf.play()
}
})
self.timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 10), queue: DispatchQueue.main) { [weak self] time in
guard let strongSelf = self else {
return
}
strongSelf.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: strongSelf.statusValue.duration, dimensions: CGSize(), timestamp: CMTimeGetSeconds(time), baseRate: 1.0, seekId: strongSelf.seekId, status: strongSelf.statusValue.status, soundEnabled: true)
strongSelf._status.set(strongSelf.statusValue)
}
}
self._status.set(self.statusValue)
}
deinit {
if let timeObserver = self.timeObserver {
self.player?.removeTimeObserver(timeObserver)
}
if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver {
NotificationCenter.default.removeObserver(didPlayToEndTimeObserver)
}
self.stopObservingForAirPlayStatusChanges()
}
public func play() {
self.player?.play()
}
public func openRouteSelection() {
if #available(iOS 11.0, *) {
let routePickerView = AVRoutePickerView()
routePickerView.delegate = self
if #available(iOS 13.0, *) {
routePickerView.prioritizesVideoDevices = true
}
self.context.sharedContext.mainWindow?.viewController?.view.addSubview(routePickerView)
if let routePickerButton = routePickerView.subviews.first(where: { $0 is UIButton }) as? UIButton {
routePickerButton.sendActions(for: .touchUpInside)
}
}
}
@available(iOS 11.0, *)
public func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {
routePickerView.removeFromSuperview()
self.play()
}
private var observerContextAirplay = 1
func startObservingForAirPlayStatusChanges()
{
self.player?.addObserver(self, forKeyPath: #keyPath(AVPlayer.isExternalPlaybackActive), options: .new, context: &observerContextAirplay)
}
func stopObservingForAirPlayStatusChanges()
{
self.player?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.isExternalPlaybackActive))
}
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &observerContextAirplay {
self.isActiveUpdated(self.player?.isExternalPlaybackActive ?? false)
}
else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
}
@@ -0,0 +1,88 @@
import Foundation
import WebKit
import SwiftSignalKit
import UniversalMediaPlayer
import AppBundle
final class GenericEmbedImplementation: WebEmbedImplementation {
private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)?
private var updateStatus: ((MediaPlayerStatus) -> Void)?
private var onPlaybackStarted: (() -> Void)?
private var status : MediaPlayerStatus
private let url: String
init(url: String) {
self.url = url
self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)
}
func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) {
let bundle = getAppBundle()
guard let userScriptPath = bundle.path(forResource: "GenericUserScript", ofType: "js") else {
return
}
guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else {
return
}
guard let userScript = String(data: userScriptData, encoding: .utf8) else {
return
}
guard let htmlTemplatePath = bundle.path(forResource: "Generic", ofType: "html") else {
return
}
guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else {
return
}
guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else {
return
}
self.evalImpl = evaluateJavaScript
self.updateStatus = updateStatus
self.onPlaybackStarted = onPlaybackStarted
updateStatus(self.status)
if self.url.contains(".twitch.tv/"), let url = URL(string: self.url) {
webView.load(URLRequest(url: url))
} else {
let html = String(format: htmlTemplate, self.url)
webView.loadHTMLString(html, baseURL: URL(string: "about:blank"))
}
userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
if self.url.hasSuffix(".mp4") || self.url.hasSuffix(".mov") {
if let onPlaybackStarted = self.onPlaybackStarted {
onPlaybackStarted()
}
}
}
func play() {
}
func pause() {
}
func togglePlayPause() {
}
func seek(timestamp: Double) {
}
func setBaseRate(_ baseRate: Double) {
}
func pageReady() {
self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .playing, soundEnabled: true)
self.updateStatus?(self.status)
if let onPlaybackStarted = self.onPlaybackStarted {
onPlaybackStarted()
}
}
func callback(url: URL) {
}
}
@@ -0,0 +1,552 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import AVFoundation
import UniversalMediaPlayer
import TelegramAudio
import AccountContext
import PhotoResources
import RangeSet
import TelegramVoip
import ManagedFile
import AppBundle
public struct HLSCodecConfiguration {
public var isHardwareAv1Supported: Bool
public var isSoftwareAv1Supported: Bool
public init(isHardwareAv1Supported: Bool, isSoftwareAv1Supported: Bool) {
self.isHardwareAv1Supported = isHardwareAv1Supported
self.isSoftwareAv1Supported = isSoftwareAv1Supported
}
}
public extension HLSCodecConfiguration {
init(context: AccountContext) {
var isSoftwareAv1Supported = false
var isHardwareAv1Supported = false
var length: Int = 4
var cpuCount: UInt32 = 0
sysctlbyname("hw.ncpu", &cpuCount, &length, nil, 0)
if cpuCount >= 6 {
isSoftwareAv1Supported = true
}
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_enable_hardware_av1"] as? Double {
isHardwareAv1Supported = value != 0.0
}
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_enable_software_av1"] as? Double {
isSoftwareAv1Supported = value != 0.0
}
self.init(isHardwareAv1Supported: isHardwareAv1Supported, isSoftwareAv1Supported: isSoftwareAv1Supported)
}
}
public final class HLSQualitySet {
public let qualityFiles: [Int: FileMediaReference]
public let playlistFiles: [Int: FileMediaReference]
public let thumbnails: [Int: (file: FileMediaReference, fileMap: FileMediaReference)]
public init?(baseFile: FileMediaReference, codecConfiguration: HLSCodecConfiguration) {
var qualityFiles: [Int: FileMediaReference] = [:]
var thumbnailFiles: [FileMediaReference] = []
var thumbnailFileMaps: [Int: (mapFile: FileMediaReference, thumbnailFileId: Int64)] = [:]
for alternativeRepresentation in baseFile.media.alternativeRepresentations {
let alternativeFile = alternativeRepresentation
if alternativeFile.mimeType == "application/x-tgstoryboard" {
thumbnailFiles.append(baseFile.withMedia(alternativeFile))
} else if alternativeFile.mimeType == "application/x-tgstoryboardmap" {
var qualityId: Int?
for attribute in alternativeFile.attributes {
switch attribute {
case let .ImageSize(size):
qualityId = Int(min(size.width, size.height))
default:
break
}
}
if let qualityId, let fileName = alternativeFile.fileName {
if fileName.hasPrefix("mtproto:") {
if let fileId = Int64(fileName[fileName.index(fileName.startIndex, offsetBy: "mtproto:".count)...]) {
thumbnailFileMaps[qualityId] = (mapFile: baseFile.withMedia(alternativeFile), thumbnailFileId: fileId)
}
}
}
} else {
for attribute in alternativeFile.attributes {
if case let .Video(_, size, _, _, _, videoCodec) = attribute {
if let videoCodec, NativeVideoContent.isVideoCodecSupported(videoCodec: videoCodec, isHardwareAv1Supported: codecConfiguration.isHardwareAv1Supported, isSoftwareAv1Supported: codecConfiguration.isSoftwareAv1Supported) {
let key = Int(min(size.width, size.height))
if let currentFile = qualityFiles[key] {
var currentCodec: String?
for attribute in currentFile.media.attributes {
if case let .Video(_, _, _, _, _, videoCodec) = attribute {
currentCodec = videoCodec
}
}
if let currentCodec, (currentCodec == "av1" || currentCodec == "av01") {
} else {
qualityFiles[key] = baseFile.withMedia(alternativeFile)
}
} else {
qualityFiles[key] = baseFile.withMedia(alternativeFile)
}
}
}
}
}
}
var playlistFiles: [Int: FileMediaReference] = [:]
for alternativeRepresentation in baseFile.media.alternativeRepresentations {
let alternativeFile = alternativeRepresentation
if alternativeFile.mimeType == "application/x-mpegurl" {
if let fileName = alternativeFile.fileName {
if fileName.hasPrefix("mtproto:") {
let fileIdString = String(fileName[fileName.index(fileName.startIndex, offsetBy: "mtproto:".count)...])
if let fileId = Int64(fileIdString) {
for (quality, file) in qualityFiles {
if file.media.fileId.id == fileId {
playlistFiles[quality] = baseFile.withMedia(alternativeFile)
break
}
}
}
}
}
}
}
if !playlistFiles.isEmpty && playlistFiles.keys == qualityFiles.keys {
self.qualityFiles = qualityFiles
self.playlistFiles = playlistFiles
var thumbnails: [Int: (file: FileMediaReference, fileMap: FileMediaReference)] = [:]
for (quality, thubmailMap) in thumbnailFileMaps {
for file in thumbnailFiles {
if file.media.fileId.id == thubmailMap.thumbnailFileId {
thumbnails[quality] = (
file: file,
fileMap: thubmailMap.mapFile
)
}
}
}
self.thumbnails = thumbnails
} else {
return nil
}
}
}
public final class HLSVideoContent: UniversalVideoContent {
public static func minimizedHLSQuality(file: FileMediaReference, codecConfiguration: HLSCodecConfiguration) -> (playlist: FileMediaReference, file: FileMediaReference)? {
guard let qualitySet = HLSQualitySet(baseFile: file, codecConfiguration: codecConfiguration) else {
return nil
}
let sortedQualities = qualitySet.qualityFiles.sorted(by: { $0.key < $1.key })
for (quality, qualityFile) in sortedQualities {
if quality >= 600 {
guard let playlistFile = qualitySet.playlistFiles[quality] else {
return nil
}
return (playlistFile, qualityFile)
}
}
if let (quality, qualityFile) = sortedQualities.first {
guard let playlistFile = qualitySet.playlistFiles[quality] else {
return nil
}
return (playlistFile, qualityFile)
}
return nil
}
public static func minimizedHLSQualityPreloadData(postbox: Postbox, file: FileMediaReference, userLocation: MediaResourceUserLocation, prefixSeconds: Int, autofetchPlaylist: Bool, codecConfiguration: HLSCodecConfiguration) -> Signal<(FileMediaReference, Range<Int64>)?, NoError> {
guard let fileSet = minimizedHLSQuality(file: file, codecConfiguration: codecConfiguration) else {
return .single(nil)
}
let playlistData: Signal<Range<Int64>?, NoError> = Signal { subscriber in
var fetchDisposable: Disposable?
if autofetchPlaylist {
fetchDisposable = freeMediaFileResourceInteractiveFetched(postbox: postbox, userLocation: userLocation, fileReference: fileSet.playlist, resource: fileSet.playlist.media.resource).start()
}
let dataDisposable = postbox.mediaBox.resourceData(fileSet.playlist.media.resource).start(next: { data in
if !data.complete {
return
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: data.path)) else {
subscriber.putNext(nil)
subscriber.putCompletion()
return
}
guard let playlistString = String(data: data, encoding: .utf8) else {
subscriber.putNext(nil)
subscriber.putCompletion()
return
}
var durations: [Int] = []
var byteRanges: [Range<Int>] = []
let extinfRegex = try! NSRegularExpression(pattern: "EXTINF:(\\d+)", options: [])
let byteRangeRegex = try! NSRegularExpression(pattern: "EXT-X-BYTERANGE:(\\d+)@(\\d+)", options: [])
let extinfResults = extinfRegex.matches(in: playlistString, range: NSRange(playlistString.startIndex..., in: playlistString))
for result in extinfResults {
if let durationRange = Range(result.range(at: 1), in: playlistString) {
if let duration = Int(String(playlistString[durationRange])) {
durations.append(duration)
}
}
}
let byteRangeResults = byteRangeRegex.matches(in: playlistString, range: NSRange(playlistString.startIndex..., in: playlistString))
for result in byteRangeResults {
if let lengthRange = Range(result.range(at: 1), in: playlistString), let upperBoundRange = Range(result.range(at: 2), in: playlistString) {
if let length = Int(String(playlistString[lengthRange])), let lowerBound = Int(String(playlistString[upperBoundRange])) {
byteRanges.append(lowerBound ..< (lowerBound + length))
}
}
}
if durations.count != byteRanges.count {
subscriber.putNext(nil)
subscriber.putCompletion()
return
}
var rangeUpperBound: Int64 = 0
var remainingSeconds = prefixSeconds
for i in 0 ..< durations.count {
if remainingSeconds <= 0 {
break
}
let duration = durations[i]
let byteRange = byteRanges[i]
remainingSeconds -= duration
rangeUpperBound = max(rangeUpperBound, Int64(byteRange.upperBound))
}
if rangeUpperBound != 0 {
subscriber.putNext(0 ..< rangeUpperBound)
subscriber.putCompletion()
} else {
subscriber.putNext(nil)
subscriber.putCompletion()
}
return
})
return ActionDisposable {
fetchDisposable?.dispose()
dataDisposable.dispose()
}
}
return playlistData
|> map { range -> (FileMediaReference, Range<Int64>)? in
guard let range else {
return nil
}
return (fileSet.file, range)
}
}
public let id: AnyHashable
public let nativeId: NativeVideoContentId
public let userLocation: MediaResourceUserLocation
public let fileReference: FileMediaReference
public let dimensions: CGSize
public let duration: Double
let streamVideo: Bool
let loopVideo: Bool
let enableSound: Bool
let baseRate: Double
let fetchAutomatically: Bool
let onlyFullSizeThumbnail: Bool
let useLargeThumbnail: Bool
let autoFetchFullSizeThumbnail: Bool
let codecConfiguration: HLSCodecConfiguration
public init(id: NativeVideoContentId, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool = false, loopVideo: Bool = false, enableSound: Bool = true, baseRate: Double = 1.0, fetchAutomatically: Bool = true, onlyFullSizeThumbnail: Bool = false, useLargeThumbnail: Bool = false, autoFetchFullSizeThumbnail: Bool = false, codecConfiguration: HLSCodecConfiguration) {
self.id = id
self.userLocation = userLocation
self.nativeId = id
self.fileReference = fileReference
self.dimensions = self.fileReference.media.dimensions?.cgSize ?? CGSize(width: 480, height: 320)
self.duration = self.fileReference.media.duration ?? 0.0
self.streamVideo = streamVideo
self.loopVideo = loopVideo
self.enableSound = enableSound
self.baseRate = baseRate
self.fetchAutomatically = fetchAutomatically
self.onlyFullSizeThumbnail = onlyFullSizeThumbnail
self.useLargeThumbnail = useLargeThumbnail
self.autoFetchFullSizeThumbnail = autoFetchFullSizeThumbnail
self.codecConfiguration = codecConfiguration
}
public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
return HLSVideoJSNativeContentNode(context: context, postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically, onlyFullSizeThumbnail: self.onlyFullSizeThumbnail, useLargeThumbnail: self.useLargeThumbnail, autoFetchFullSizeThumbnail: self.autoFetchFullSizeThumbnail, codecConfiguration: self.codecConfiguration)
}
public func isEqual(to other: UniversalVideoContent) -> Bool {
if let other = other as? NativeVideoContent {
if case let .message(stableId, _) = self.nativeId {
if case .message(stableId, _) = other.nativeId {
if self.fileReference.media.isInstantVideo {
return true
}
}
}
}
return false
}
}
final class HLSServerSource: SharedHLSServer.Source {
let id: String
let postbox: Postbox
let userLocation: MediaResourceUserLocation
let playlistFiles: [Int: FileMediaReference]
let qualityFiles: [Int: FileMediaReference]
private var playlistFetchDisposables: [Int: Disposable] = [:]
init(accountId: Int64, fileId: Int64, postbox: Postbox, userLocation: MediaResourceUserLocation, playlistFiles: [Int: FileMediaReference], qualityFiles: [Int: FileMediaReference]) {
self.id = "\(UInt64(bitPattern: accountId))_\(fileId)"
self.postbox = postbox
self.userLocation = userLocation
self.playlistFiles = playlistFiles
self.qualityFiles = qualityFiles
}
deinit {
for (_, disposable) in self.playlistFetchDisposables {
disposable.dispose()
}
}
func arbitraryFileData(path: String) -> Signal<(data: Data, contentType: String)?, NoError> {
return Signal { subscriber in
if path == "index.html" {
if let path = getAppBundle().path(forResource: "HLSVideoPlayer", ofType: "html"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
subscriber.putNext((data, "text/html"))
} else {
subscriber.putNext(nil)
}
} else if path == "hls.js" {
if let path = getAppBundle().path(forResource: "hls", ofType: "js"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
subscriber.putNext((data, "application/javascript"))
} else {
subscriber.putNext(nil)
}
} else {
subscriber.putNext(nil)
}
subscriber.putCompletion()
return EmptyDisposable
}
}
func masterPlaylistData() -> Signal<String, NoError> {
var playlistString: String = ""
playlistString.append("#EXTM3U\n")
for (quality, file) in self.qualityFiles.sorted(by: { $0.key > $1.key }) {
let width = file.media.dimensions?.width ?? 1280
let height = file.media.dimensions?.height ?? 720
let bandwidth: Int
if let size = file.media.size, let duration = file.media.duration, duration != 0.0 {
bandwidth = Int(Double(size) / duration) * 8
} else {
bandwidth = 1000000
}
playlistString.append("#EXT-X-STREAM-INF:BANDWIDTH=\(bandwidth),RESOLUTION=\(width)x\(height)\n")
playlistString.append("hls_level_\(quality).m3u8\n")
}
return .single(playlistString)
}
func playlistData(quality: Int) -> Signal<String, NoError> {
guard let playlistFile = self.playlistFiles[quality] else {
return .never()
}
if self.playlistFetchDisposables[quality] == nil {
self.playlistFetchDisposables[quality] = freeMediaFileResourceInteractiveFetched(postbox: self.postbox, userLocation: self.userLocation, fileReference: playlistFile, resource: playlistFile.media.resource).startStrict()
}
return self.postbox.mediaBox.resourceData(playlistFile.media.resource)
|> filter { data in
return data.complete
}
|> map { data -> String in
guard data.complete else {
return ""
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: data.path)) else {
return ""
}
guard var playlistString = String(data: data, encoding: .utf8) else {
return ""
}
let partRegex = try! NSRegularExpression(pattern: "mtproto:([\\d]+)", options: [])
let results = partRegex.matches(in: playlistString, range: NSRange(playlistString.startIndex..., in: playlistString))
for result in results.reversed() {
if let range = Range(result.range, in: playlistString) {
if let fileIdRange = Range(result.range(at: 1), in: playlistString) {
let fileId = String(playlistString[fileIdRange])
playlistString.replaceSubrange(range, with: "partfile\(fileId).mp4")
}
}
}
return playlistString
}
}
func partData(index: Int, quality: Int) -> Signal<Data?, NoError> {
return .never()
}
func fileData(id: Int64, range: Range<Int>) -> Signal<(TempBoxFile, Range<Int>, Int)?, NoError> {
guard let (quality, file) = self.qualityFiles.first(where: { $0.value.media.fileId.id == id }) else {
return .single(nil)
}
let _ = quality
guard let size = file.media.size else {
return .single(nil)
}
let postbox = self.postbox
let userLocation = self.userLocation
let mappedRange: Range<Int64> = Int64(range.lowerBound) ..< Int64(range.upperBound)
let queue = postbox.mediaBox.dataQueue
let fetchFromRemote: Signal<(TempBoxFile, Range<Int>, Int)?, NoError> = Signal { subscriber in
let partialFile = TempBox.shared.tempFile(fileName: "data")
if let cachedData = postbox.mediaBox.internal_resourceData(id: file.media.resource.id, size: size, in: Int64(range.lowerBound) ..< Int64(range.upperBound)) {
#if DEBUG
print("Fetched \(quality)p part from cache")
#endif
let outputFile = ManagedFile(queue: nil, path: partialFile.path, mode: .readwrite)
if let outputFile {
let blockSize = 128 * 1024
var tempBuffer = Data(count: blockSize)
var blockOffset = 0
while blockOffset < cachedData.length {
let currentBlockSize = min(cachedData.length - blockOffset, blockSize)
tempBuffer.withUnsafeMutableBytes { bytes -> Void in
let _ = cachedData.file.read(bytes.baseAddress!, currentBlockSize)
let _ = outputFile.write(bytes.baseAddress!, count: currentBlockSize)
}
blockOffset += blockSize
}
outputFile._unsafeClose()
subscriber.putNext((partialFile, 0 ..< cachedData.length, Int(size)))
subscriber.putCompletion()
} else {
#if DEBUG
print("Error writing cached file to disk")
#endif
}
return EmptyDisposable
}
guard let fetchResource = postbox.mediaBox.fetchResource else {
return EmptyDisposable
}
let location = MediaResourceStorageLocation(userLocation: userLocation, reference: file.resourceReference(file.media.resource))
let params = MediaResourceFetchParameters(
tag: TelegramMediaResourceFetchTag(statsCategory: .video, userContentType: .video),
info: TelegramCloudMediaResourceFetchInfo(reference: file.resourceReference(file.media.resource), preferBackgroundReferenceRevalidation: true, continueInBackground: true),
location: location,
contentType: .video,
isRandomAccessAllowed: true
)
let completeFile = TempBox.shared.tempFile(fileName: "data")
let metaFile = TempBox.shared.tempFile(fileName: "data")
guard let fileContext = MediaBoxFileContextV2Impl(
queue: queue,
manager: postbox.mediaBox.dataFileManager,
storageBox: nil,
resourceId: file.media.resource.id.stringRepresentation.data(using: .utf8)!,
path: completeFile.path,
partialPath: partialFile.path,
metaPath: metaFile.path
) else {
return EmptyDisposable
}
let fetchDisposable = fileContext.fetched(
range: mappedRange,
priority: .default,
fetch: { intervals in
return fetchResource(file.media.resource, intervals, params)
},
error: { _ in
},
completed: {
}
)
#if DEBUG
let startTime = CFAbsoluteTimeGetCurrent()
#endif
let dataDisposable = fileContext.data(
range: mappedRange,
waitUntilAfterInitialFetch: true,
next: { result in
if result.complete {
#if DEBUG
let fetchTime = CFAbsoluteTimeGetCurrent() - startTime
print("Fetching \(quality)p part took \(fetchTime * 1000.0) ms")
#endif
subscriber.putNext((partialFile, Int(result.offset) ..< Int(result.offset + result.size), Int(size)))
subscriber.putCompletion()
}
}
)
return ActionDisposable {
queue.async {
fetchDisposable.dispose()
dataDisposable.dispose()
fileContext.cancelFullRangeFetches()
TempBox.shared.dispose(completeFile)
TempBox.shared.dispose(metaFile)
}
}
}
|> runOn(queue)
return fetchFromRemote
}
}
@@ -0,0 +1,930 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramAudio
import UniversalMediaPlayer
import AccountContext
import PhotoResources
import UIKitRuntimeUtils
import RangeSet
import VideoToolbox
private extension CGRect {
var center: CGPoint {
return CGPoint(x: self.midX, y: self.midY)
}
}
public enum NativeVideoContentId: Hashable {
case message(UInt32, MediaId)
case instantPage(MediaId, MediaId)
case contextResult(Int64, String)
case profileVideo(Int64, String?)
}
public final class NativeVideoContent: UniversalVideoContent {
public let id: AnyHashable
public let nativeId: NativeVideoContentId
public let userLocation: MediaResourceUserLocation
public let fileReference: FileMediaReference
public let previewSourceFileReference: FileMediaReference?
public let limitedFileRange: Range<Int64>?
let imageReference: ImageMediaReference?
public let dimensions: CGSize
public let duration: Double
public let streamVideo: MediaPlayerStreaming
public let loopVideo: Bool
public let enableSound: Bool
public let soundMuted: Bool
public let beginWithAmbientSound: Bool
public let mixWithOthers: Bool
public let baseRate: Double
let fetchAutomatically: Bool
let onlyFullSizeThumbnail: Bool
let useLargeThumbnail: Bool
let autoFetchFullSizeThumbnail: Bool
public let startTimestamp: Double?
let endTimestamp: Double?
let continuePlayingWithoutSoundOnLostAudioSession: Bool
let placeholderColor: UIColor
let tempFilePath: String?
let isAudioVideoMessage: Bool
let captureProtected: Bool
let hintDimensions: CGSize?
let storeAfterDownload: (() -> Void)?
let displayImage: Bool
let hasSentFramesToDisplay: (() -> Void)?
public static func isVideoCodecSupported(videoCodec: String, isHardwareAv1Supported: Bool, isSoftwareAv1Supported: Bool) -> Bool {
if videoCodec == "h264" || videoCodec == "h265" || videoCodec == "avc" || videoCodec == "hevc" {
return true
}
if videoCodec == "av1" || videoCodec == "av01" {
return isHardwareAv1Supported || isSoftwareAv1Supported
}
return false
}
public static func isHLSVideo(file: TelegramMediaFile) -> Bool {
for alternativeRepresentation in file.alternativeRepresentations {
if alternativeRepresentation.mimeType == "application/x-mpegurl" {
return true
}
}
return false
}
public init(id: NativeVideoContentId, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, previewSourceFileReference: FileMediaReference? = nil, limitedFileRange: Range<Int64>? = nil, imageReference: ImageMediaReference? = nil, streamVideo: MediaPlayerStreaming = .none, loopVideo: Bool = false, enableSound: Bool = true, soundMuted: Bool = false, beginWithAmbientSound: Bool = false, mixWithOthers: Bool = false, baseRate: Double = 1.0, fetchAutomatically: Bool = true, onlyFullSizeThumbnail: Bool = false, useLargeThumbnail: Bool = false, autoFetchFullSizeThumbnail: Bool = false, startTimestamp: Double? = nil, endTimestamp: Double? = nil, continuePlayingWithoutSoundOnLostAudioSession: Bool = false, placeholderColor: UIColor = .white, tempFilePath: String? = nil, isAudioVideoMessage: Bool = false, captureProtected: Bool = false, hintDimensions: CGSize? = nil, storeAfterDownload: (() -> Void)?, displayImage: Bool = true, hasSentFramesToDisplay: (() -> Void)? = nil) {
self.id = id
self.nativeId = id
self.userLocation = userLocation
self.fileReference = fileReference
self.previewSourceFileReference = previewSourceFileReference
self.limitedFileRange = limitedFileRange
self.imageReference = imageReference
if var dimensions = fileReference.media.dimensions {
if let thumbnail = fileReference.media.previewRepresentations.first {
let dimensionsVertical = dimensions.width < dimensions.height
let thumbnailVertical = thumbnail.dimensions.width < thumbnail.dimensions.height
if dimensionsVertical != thumbnailVertical {
dimensions = PixelDimensions(width: dimensions.height, height: dimensions.width)
}
}
self.dimensions = dimensions.cgSize
} else {
self.dimensions = CGSize(width: 128.0, height: 128.0)
}
self.duration = fileReference.media.duration ?? 0.0
self.streamVideo = streamVideo
self.loopVideo = loopVideo
self.enableSound = enableSound
self.soundMuted = soundMuted
self.beginWithAmbientSound = beginWithAmbientSound
self.mixWithOthers = mixWithOthers
self.baseRate = baseRate
self.fetchAutomatically = fetchAutomatically
self.onlyFullSizeThumbnail = onlyFullSizeThumbnail
self.useLargeThumbnail = useLargeThumbnail
self.autoFetchFullSizeThumbnail = autoFetchFullSizeThumbnail
self.startTimestamp = startTimestamp
self.endTimestamp = endTimestamp
self.continuePlayingWithoutSoundOnLostAudioSession = continuePlayingWithoutSoundOnLostAudioSession
self.placeholderColor = placeholderColor
self.tempFilePath = tempFilePath
self.captureProtected = captureProtected
self.isAudioVideoMessage = isAudioVideoMessage
self.hintDimensions = hintDimensions
self.storeAfterDownload = storeAfterDownload
self.displayImage = displayImage
self.hasSentFramesToDisplay = hasSentFramesToDisplay
}
public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
return NativeVideoContentNode(context: context, postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, previewSourceFileReference: self.previewSourceFileReference, limitedFileRange: self.limitedFileRange, imageReference: self.imageReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, soundMuted: self.soundMuted, beginWithAmbientSound: self.beginWithAmbientSound, mixWithOthers: self.mixWithOthers, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically, onlyFullSizeThumbnail: self.onlyFullSizeThumbnail, useLargeThumbnail: self.useLargeThumbnail, autoFetchFullSizeThumbnail: self.autoFetchFullSizeThumbnail, startTimestamp: self.startTimestamp, endTimestamp: self.endTimestamp, continuePlayingWithoutSoundOnLostAudioSession: self.continuePlayingWithoutSoundOnLostAudioSession, placeholderColor: self.placeholderColor, tempFilePath: self.tempFilePath, isAudioVideoMessage: self.isAudioVideoMessage, captureProtected: self.captureProtected, hintDimensions: self.hintDimensions, storeAfterDownload: self.storeAfterDownload, displayImage: self.displayImage, hasSentFramesToDisplay: self.hasSentFramesToDisplay)
}
public func isEqual(to other: UniversalVideoContent) -> Bool {
if let other = other as? NativeVideoContent {
if case let .message(stableId, _) = self.nativeId {
if case .message(stableId, _) = other.nativeId {
if self.fileReference.media.isInstantVideo {
return true
}
}
}
}
return false
}
}
private enum PlayerImpl {
case legacy(MediaPlayer)
case chunked(ChunkMediaPlayerV2)
var actionAtEnd: MediaPlayerActionAtEnd {
get {
switch self {
case let .legacy(player):
return player.actionAtEnd
case let .chunked(player):
return player.actionAtEnd
}
} set(value) {
switch self {
case let .legacy(player):
player.actionAtEnd = value
case let .chunked(player):
player.actionAtEnd = value
}
}
}
var status: Signal<MediaPlayerStatus, NoError> {
switch self {
case let .legacy(player):
return player.status
case let .chunked(player):
return player.status
}
}
func play() {
switch self {
case let .legacy(player):
player.play()
case let .chunked(player):
player.play()
}
}
func pause() {
switch self {
case let .legacy(player):
player.pause()
case let .chunked(player):
player.pause()
}
}
func togglePlayPause(faded: Bool = false) {
switch self {
case let .legacy(player):
player.togglePlayPause(faded: faded)
case let .chunked(player):
player.togglePlayPause(faded: faded)
}
}
func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek = .start) {
switch self {
case let .legacy(player):
player.playOnceWithSound(playAndRecord: playAndRecord, seek: seek)
case let .chunked(player):
player.playOnceWithSound(playAndRecord: playAndRecord, seek: seek)
}
}
func continueWithOverridingAmbientMode(isAmbient: Bool) {
switch self {
case let .legacy(player):
player.continueWithOverridingAmbientMode(isAmbient: isAmbient)
case let .chunked(player):
player.continueWithOverridingAmbientMode(isAmbient: isAmbient)
}
}
func continuePlayingWithoutSound(seek: MediaPlayerSeek = .start) {
switch self {
case let .legacy(player):
player.continuePlayingWithoutSound(seek: seek)
case let .chunked(player):
player.continuePlayingWithoutSound(seek: seek)
}
}
func seek(timestamp: Double, play: Bool? = nil) {
switch self {
case let .legacy(player):
player.seek(timestamp: timestamp, play: play)
case let .chunked(player):
player.seek(timestamp: timestamp, play: play)
}
}
func setForceAudioToSpeaker(_ value: Bool) {
switch self {
case let .legacy(player):
player.setForceAudioToSpeaker(value)
case let .chunked(player):
player.setForceAudioToSpeaker(value)
}
}
func setSoundMuted(soundMuted: Bool) {
switch self {
case let .legacy(player):
player.setSoundMuted(soundMuted: soundMuted)
case let .chunked(player):
player.setSoundMuted(soundMuted: soundMuted)
}
}
func setBaseRate(_ baseRate: Double) {
switch self {
case let .legacy(player):
player.setBaseRate(baseRate)
case let .chunked(player):
player.setBaseRate(baseRate)
}
}
func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) {
switch self {
case let .legacy(player):
player.setContinuePlayingWithoutSoundOnLostAudioSession(value)
case let .chunked(player):
player.setContinuePlayingWithoutSoundOnLostAudioSession(value)
}
}
}
public extension ChunkMediaPlayerV2.MediaDataReaderParams {
init(context: AccountContext) {
var useV2Reader = true
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_video_v2_reader2"] as? Double {
useV2Reader = value != 0.0
}
self.init(useV2Reader: useV2Reader)
}
}
private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
private let postbox: Postbox
private let userLocation: MediaResourceUserLocation
private let fileReference: FileMediaReference
private let previewSourceFileReference: FileMediaReference?
private let limitedFileRange: Range<Int64>?
private let streamVideo: MediaPlayerStreaming
private let enableSound: Bool
private let soundMuted: Bool
private let beginWithAmbientSound: Bool
private let mixWithOthers: Bool
private let loopVideo: Bool
private let baseRate: Double
private let audioSessionManager: ManagedAudioSession
private let isAudioVideoMessage: Bool
private let captureProtected: Bool
private let continuePlayingWithoutSoundOnLostAudioSession: Bool
private let displayImage: Bool
private var player: PlayerImpl?
private var thumbnailPlayer: MediaPlayer?
private let imageNode: TransformImageNode
private let playerNode: MediaPlayerNode
private var thumbnailNode: MediaPlayerNode?
private let playbackCompletedListeners = Bag<() -> Void>()
private let placeholderColor: UIColor
private var initializedStatus = false
private let _status = Promise<MediaPlayerStatus>()
private let _thumbnailStatus = Promise<MediaPlayerStatus?>(nil)
var status: Signal<MediaPlayerStatus, NoError> {
return combineLatest(self._thumbnailStatus.get(), self._status.get())
|> map { thumbnailStatus, status in
switch status.status {
case .buffering:
if let thumbnailStatus = thumbnailStatus {
return thumbnailStatus
} else {
return status
}
default:
return status
}
}
}
private let _bufferingStatus = Promise<(RangeSet<Int64>, Int64)?>()
var bufferingStatus: Signal<(RangeSet<Int64>, Int64)?, NoError> {
return self._bufferingStatus.get()
}
var isNativePictureInPictureActive: Signal<Bool, NoError> {
return .single(false)
}
private let _ready = Promise<Void>()
var ready: Signal<Void, NoError> {
return self._ready.get()
}
private var initializePlayerDisposable: Disposable?
private let fetchDisposable = MetaDisposable()
private let fetchStatusDisposable = MetaDisposable()
private var dimensions: CGSize?
private let dimensionsPromise = ValuePromise<CGSize>(CGSize())
private var validLayout: (size: CGSize, actualSize: CGSize)?
private var shouldPlay: Bool = false
private var pendingSetSoundEnabled: Bool?
private var pendingSeek: Double?
private var pendingPlayOnceWithSound: (playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd)?
private var pendingForceAudioToSpeaker: Bool?
private var pendingSetSoundMuted: Bool?
private var pendingContinueWithOverridingAmbientMode: Bool?
private var pendingSetBaseRate: Double?
private var pendingContinuePlayingWithoutSound: MediaPlayerPlayOnceWithSoundActionAtEnd?
private var pendingSetContinuePlayingWithoutSoundOnLostAudioSession: Bool?
private let hasSentFramesToDisplay: (() -> Void)?
init(context: AccountContext, postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, previewSourceFileReference: FileMediaReference?, limitedFileRange: Range<Int64>?, imageReference: ImageMediaReference?, streamVideo: MediaPlayerStreaming, loopVideo: Bool, enableSound: Bool, soundMuted: Bool, beginWithAmbientSound: Bool, mixWithOthers: Bool, baseRate: Double, fetchAutomatically: Bool, onlyFullSizeThumbnail: Bool, useLargeThumbnail: Bool, autoFetchFullSizeThumbnail: Bool, startTimestamp: Double?, endTimestamp: Double?, continuePlayingWithoutSoundOnLostAudioSession: Bool = false, placeholderColor: UIColor, tempFilePath: String?, isAudioVideoMessage: Bool, captureProtected: Bool, hintDimensions: CGSize?, storeAfterDownload: (() -> Void)? = nil, displayImage: Bool, hasSentFramesToDisplay: (() -> Void)?) {
self.postbox = postbox
self.userLocation = userLocation
self.fileReference = fileReference
self.previewSourceFileReference = previewSourceFileReference
self.limitedFileRange = limitedFileRange
self.streamVideo = streamVideo
self.placeholderColor = placeholderColor
self.enableSound = enableSound
self.soundMuted = soundMuted
self.beginWithAmbientSound = beginWithAmbientSound
self.mixWithOthers = mixWithOthers
self.loopVideo = loopVideo
self.baseRate = baseRate
self.audioSessionManager = audioSessionManager
self.isAudioVideoMessage = isAudioVideoMessage
self.captureProtected = captureProtected
self.continuePlayingWithoutSoundOnLostAudioSession = continuePlayingWithoutSoundOnLostAudioSession
self.displayImage = displayImage
self.hasSentFramesToDisplay = hasSentFramesToDisplay
self.imageNode = TransformImageNode()
var userContentType = MediaResourceUserContentType(file: fileReference.media)
switch fileReference {
case .story:
userContentType = .story
default:
break
}
let selectedFile = fileReference.media
self.playerNode = MediaPlayerNode(backgroundThread: false, captureProtected: captureProtected)
self.dimensions = fileReference.media.dimensions?.cgSize
if let dimensions = self.dimensions {
self.dimensionsPromise.set(dimensions)
}
super.init()
var didProcessFramesToDisplay = false
self.playerNode.isHidden = true
self.playerNode.hasSentFramesToDisplay = { [weak self] in
guard let self, !didProcessFramesToDisplay else {
return
}
didProcessFramesToDisplay = true
self.playerNode.isHidden = false
self.hasSentFramesToDisplay?()
}
if let dimensions = hintDimensions {
self.dimensions = dimensions
self.dimensionsPromise.set(dimensions)
}
if displayImage {
if captureProtected {
setLayerDisableScreenshots(self.imageNode.layer, captureProtected)
}
self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: userLocation, videoReference: fileReference, previewSourceFileReference: previewSourceFileReference, imageReference: imageReference, onlyFullSize: onlyFullSizeThumbnail, useLargeThumbnail: useLargeThumbnail, autoFetchFullSizeThumbnail: autoFetchFullSizeThumbnail || fileReference.media.isInstantVideo) |> map { [weak self] getSize, getData in
Queue.mainQueue().async {
if let strongSelf = self, strongSelf.dimensions == nil {
if let dimensions = getSize() {
strongSelf.dimensions = dimensions
strongSelf.dimensionsPromise.set(dimensions)
if let validLayout = strongSelf.validLayout {
strongSelf.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
}
}
}
}
return getData
})
self.addSubnode(self.imageNode)
}
self.addSubnode(self.playerNode)
self.fetchStatusDisposable.set((postbox.mediaBox.resourceStatus(selectedFile.resource)
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let strongSelf = self else {
return
}
switch status {
case .Local:
break
default:
if strongSelf.thumbnailPlayer == nil {
strongSelf.createThumbnailPlayer()
}
}
}))
if let size = selectedFile.size {
self._bufferingStatus.set(postbox.mediaBox.resourceRangesStatus(selectedFile.resource) |> map { ranges in
return (ranges, size)
})
} else {
self._bufferingStatus.set(.single(nil))
}
if self.displayImage {
self.imageNode.imageUpdated = { [weak self] _ in
self?._ready.set(.single(Void()))
}
} else {
self._ready.set(.single(Void()))
}
if let startTimestamp = startTimestamp {
self.seek(startTimestamp)
}
var useLegacyImplementation = !context.sharedContext.immediateExperimentalUISettings.playerV2
for attribute in fileReference.media.attributes {
if case let .Video(_, _, _, _, _, videoCodec) = attribute {
if videoCodec == "av1" || videoCodec == "av01" {
useLegacyImplementation = false
}
}
}
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_video_legacyplayer"] as? Double {
useLegacyImplementation = value != 0.0
}
if useLegacyImplementation {
let mediaPlayer = MediaPlayer(
audioSessionManager: audioSessionManager,
postbox: postbox,
userLocation: userLocation,
userContentType: userContentType,
resourceReference: fileReference.resourceReference(selectedFile.resource),
tempFilePath: tempFilePath,
limitedFileRange: limitedFileRange,
streamable: streamVideo,
video: true,
preferSoftwareDecoding: false,
playAutomatically: false,
enableSound: enableSound,
baseRate: baseRate,
fetchAutomatically: fetchAutomatically,
soundMuted: soundMuted,
ambient: beginWithAmbientSound,
mixWithOthers: mixWithOthers,
continuePlayingWithoutSoundOnLostAudioSession: continuePlayingWithoutSoundOnLostAudioSession,
storeAfterDownload: storeAfterDownload,
isAudioVideoMessage: isAudioVideoMessage
)
mediaPlayer.attachPlayerNode(self.playerNode)
self.initializePlayer(player: .legacy(mediaPlayer))
} else {
let mediaPlayer = ChunkMediaPlayerV2(
params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context),
audioSessionManager: audioSessionManager,
source: .directFetch(ChunkMediaPlayerV2.SourceDescription.ResourceDescription(
postbox: postbox,
size: selectedFile.size ?? 0,
reference: fileReference.resourceReference(selectedFile.resource),
userLocation: userLocation,
userContentType: userContentType,
statsCategory: statsCategoryForFileWithAttributes(fileReference.media.attributes),
fetchAutomatically: fetchAutomatically
)),
video: true,
playAutomatically: false,
enableSound: enableSound,
baseRate: baseRate,
soundMuted: soundMuted,
ambient: beginWithAmbientSound,
mixWithOthers: mixWithOthers,
continuePlayingWithoutSoundOnLostAudioSession: continuePlayingWithoutSoundOnLostAudioSession,
isAudioVideoMessage: isAudioVideoMessage,
playerNode: self.playerNode
)
self.initializePlayer(player: .chunked(mediaPlayer))
}
}
deinit {
self.initializePlayerDisposable?.dispose()
self.player?.pause()
self.thumbnailPlayer?.pause()
self.fetchDisposable.dispose()
self.fetchStatusDisposable.dispose()
}
private func initializePlayer(player: PlayerImpl) {
var player = player
self.player = player
var actionAtEndImpl: (() -> Void)?
if self.enableSound && !self.loopVideo {
player.actionAtEnd = .action({
actionAtEndImpl?()
})
} else {
player.actionAtEnd = .loop({
actionAtEndImpl?()
})
}
actionAtEndImpl = { [weak self] in
self?.performActionAtEnd()
}
self._status.set(combineLatest(self.dimensionsPromise.get(), player.status)
|> map { dimensions, status in
return MediaPlayerStatus(generationTimestamp: status.generationTimestamp, duration: status.duration, dimensions: dimensions, timestamp: status.timestamp, baseRate: status.baseRate, seekId: status.seekId, status: status.status, soundEnabled: status.soundEnabled)
})
if self.shouldPlay {
player.play()
} else {
player.pause()
}
if let pendingSeek = self.pendingSeek {
self.pendingSeek = nil
self.seek(pendingSeek)
}
if let pendingSetSoundEnabled = self.pendingSetSoundEnabled {
self.pendingSetSoundEnabled = nil
self.setSoundEnabled(pendingSetSoundEnabled)
}
if let pendingPlayOnceWithSound = self.pendingPlayOnceWithSound {
self.pendingPlayOnceWithSound = nil
self.playOnceWithSound(playAndRecord: pendingPlayOnceWithSound.playAndRecord, seek: pendingPlayOnceWithSound.seek, actionAtEnd: pendingPlayOnceWithSound.actionAtEnd)
}
if let pendingForceAudioToSpeaker = self.pendingForceAudioToSpeaker {
self.pendingForceAudioToSpeaker = nil
self.setForceAudioToSpeaker(pendingForceAudioToSpeaker)
}
if let pendingSetSoundMuted = self.pendingSetSoundMuted {
self.pendingSetSoundMuted = nil
self.setSoundMuted(soundMuted: pendingSetSoundMuted)
}
if let pendingContinueWithOverridingAmbientMode = self.pendingContinueWithOverridingAmbientMode {
self.pendingContinueWithOverridingAmbientMode = nil
self.continueWithOverridingAmbientMode(isAmbient: pendingContinueWithOverridingAmbientMode)
}
if let pendingSetBaseRate = self.pendingSetBaseRate {
self.pendingSetBaseRate = nil
self.setBaseRate(pendingSetBaseRate)
}
if let pendingContinuePlayingWithoutSound = self.pendingContinuePlayingWithoutSound {
self.pendingContinuePlayingWithoutSound = nil
self.continuePlayingWithoutSound(actionAtEnd: pendingContinuePlayingWithoutSound)
}
if let pendingSetContinuePlayingWithoutSoundOnLostAudioSession = self.pendingSetContinuePlayingWithoutSoundOnLostAudioSession {
self.pendingSetContinuePlayingWithoutSoundOnLostAudioSession = nil
self.setContinuePlayingWithoutSoundOnLostAudioSession(pendingSetContinuePlayingWithoutSoundOnLostAudioSession)
}
}
private func createThumbnailPlayer() {
guard let videoThumbnail = self.fileReference.media.videoThumbnails.first else {
return
}
let thumbnailPlayer = MediaPlayer(audioSessionManager: self.audioSessionManager, postbox: postbox, userLocation: self.userLocation, userContentType: MediaResourceUserContentType(file: self.fileReference.media), resourceReference: self.fileReference.resourceReference(videoThumbnail.resource), tempFilePath: nil, streamable: .none, video: true, preferSoftwareDecoding: false, playAutomatically: false, enableSound: false, baseRate: self.baseRate, fetchAutomatically: false, continuePlayingWithoutSoundOnLostAudioSession: false)
self.thumbnailPlayer = thumbnailPlayer
var actionAtEndImpl: (() -> Void)?
if self.enableSound && !self.loopVideo {
thumbnailPlayer.actionAtEnd = .action({
actionAtEndImpl?()
})
} else {
thumbnailPlayer.actionAtEnd = .loop({
actionAtEndImpl?()
})
}
actionAtEndImpl = { [weak self] in
self?.performActionAtEnd()
}
let thumbnailNode = MediaPlayerNode(backgroundThread: false)
self.thumbnailNode = thumbnailNode
thumbnailPlayer.attachPlayerNode(thumbnailNode)
self._thumbnailStatus.set(thumbnailPlayer.status
|> map { status in
return MediaPlayerStatus(generationTimestamp: status.generationTimestamp, duration: status.duration, dimensions: CGSize(), timestamp: status.timestamp, baseRate: status.baseRate, seekId: status.seekId, status: status.status, soundEnabled: status.soundEnabled)
})
self.addSubnode(thumbnailNode)
thumbnailNode.frame = self.playerNode.frame
if self.shouldPlay {
thumbnailPlayer.play()
}
var processedSentFramesToDisplay = false
thumbnailNode.hasSentFramesToDisplay = { [weak self] in
guard !processedSentFramesToDisplay, let strongSelf = self else {
return
}
processedSentFramesToDisplay = true
strongSelf.hasSentFramesToDisplay?()
Queue.mainQueue().after(0.1, {
guard let strongSelf = self else {
return
}
strongSelf.thumbnailNode?.isHidden = true
strongSelf.thumbnailPlayer?.pause()
})
}
}
private func performActionAtEnd() {
for listener in self.playbackCompletedListeners.copyItems() {
listener()
}
}
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, actualSize)
if let dimensions = self.dimensions {
let imageSize = CGSize(width: floor(dimensions.width / 2.0), height: floor(dimensions.height / 2.0))
let makeLayout = self.imageNode.asyncLayoutWithAnimation()
let applyLayout = makeLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: self.fileReference.media.isInstantVideo ? .clear : self.placeholderColor))
let mappedAnimation: ListViewItemUpdateAnimation
if case let .animated(duration, curve) = transition {
mappedAnimation = .System(duration: duration, transition: ControlledTransition(duration: duration, curve: curve, interactive: false))
} else {
mappedAnimation = .None
}
applyLayout(mappedAnimation)
}
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size))
let fromFrame = self.playerNode.frame
let toFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.0, dy: -1.0)
if case let .animated(duration, curve) = transition, fromFrame != toFrame, !fromFrame.width.isZero, !fromFrame.height.isZero, !toFrame.width.isZero, !toFrame.height.isZero {
let _ = duration
let _ = curve
self.playerNode.position = toFrame.center
self.playerNode.bounds = CGRect(origin: CGPoint(), size: toFrame.size)
self.playerNode.updateLayout()
transition.animatePosition(node: self.playerNode, from: CGPoint(x: fromFrame.center.x, y: fromFrame.center.y))
let transform = CATransform3DScale(CATransform3DIdentity, fromFrame.width / toFrame.width, fromFrame.height / toFrame.height, 1.0)
self.playerNode.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: curve.timingFunction, duration: duration)
} else {
transition.updatePosition(node: self.playerNode, position: toFrame.center)
transition.updateBounds(node: self.playerNode, bounds: CGRect(origin: CGPoint(), size: toFrame.size))
self.playerNode.updateLayout()
}
if let thumbnailNode = self.thumbnailNode {
transition.updateFrame(node: thumbnailNode, frame: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.0, dy: -1.0))
}
}
func play() {
assert(Queue.mainQueue().isCurrent())
self.player?.play()
self.shouldPlay = true
self.thumbnailPlayer?.play()
}
func pause() {
assert(Queue.mainQueue().isCurrent())
self.player?.pause()
self.shouldPlay = false
self.thumbnailPlayer?.pause()
}
func togglePlayPause() {
assert(Queue.mainQueue().isCurrent())
self.player?.togglePlayPause()
self.shouldPlay = !self.shouldPlay
self.thumbnailPlayer?.togglePlayPause()
}
func setSoundEnabled(_ value: Bool) {
assert(Queue.mainQueue().isCurrent())
if let player = self.player {
if value {
player.playOnceWithSound(playAndRecord: false, seek: .none)
} else {
player.continuePlayingWithoutSound(seek: .none)
}
} else {
self.pendingSetSoundEnabled = value
}
}
func seek(_ timestamp: Double) {
assert(Queue.mainQueue().isCurrent())
if let player = self.player {
player.seek(timestamp: timestamp)
} else {
self.pendingSeek = timestamp
}
}
func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
assert(Queue.mainQueue().isCurrent())
guard var player = self.player else {
self.pendingPlayOnceWithSound = (playAndRecord, seek, actionAtEnd)
return
}
let action = { [weak self] in
Queue.mainQueue().async {
self?.performActionAtEnd()
}
}
switch actionAtEnd {
case .loop:
player.actionAtEnd = .loop({})
case .loopDisablingSound:
player.actionAtEnd = .loopDisablingSound(action)
case .stop:
player.actionAtEnd = .action(action)
case .repeatIfNeeded:
let _ = (player.status
|> deliverOnMainQueue
|> take(1)).start(next: { [weak self] status in
guard let strongSelf = self, var player = strongSelf.player else {
return
}
if status.timestamp > status.duration * 0.1 {
player.actionAtEnd = .loop({ [weak self] in
guard let strongSelf = self, var player = strongSelf.player else {
return
}
player.actionAtEnd = .loopDisablingSound(action)
})
} else {
player.actionAtEnd = .loopDisablingSound(action)
}
})
}
player.playOnceWithSound(playAndRecord: playAndRecord, seek: seek)
}
func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) {
assert(Queue.mainQueue().isCurrent())
if let player = self.player {
player.setForceAudioToSpeaker(forceAudioToSpeaker)
} else {
self.pendingForceAudioToSpeaker = forceAudioToSpeaker
}
}
func setSoundMuted(soundMuted: Bool) {
if let player = self.player {
player.setSoundMuted(soundMuted: soundMuted)
} else {
self.pendingSetSoundMuted = soundMuted
}
}
func continueWithOverridingAmbientMode(isAmbient: Bool) {
if let player = self.player {
player.continueWithOverridingAmbientMode(isAmbient: isAmbient)
} else {
self.pendingContinueWithOverridingAmbientMode = isAmbient
}
}
func setBaseRate(_ baseRate: Double) {
if let player = self.player {
player.setBaseRate(baseRate)
} else {
self.pendingSetBaseRate = baseRate
}
}
func setVideoQuality(_ quality: UniversalVideoContentVideoQuality) {
}
func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? {
return nil
}
func videoQualityStateSignal() -> Signal<(current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])?, NoError> {
return .single(nil)
}
func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
assert(Queue.mainQueue().isCurrent())
guard var player = self.player else {
self.pendingContinuePlayingWithoutSound = actionAtEnd
return
}
let action = { [weak self] in
Queue.mainQueue().async {
self?.performActionAtEnd()
}
}
switch actionAtEnd {
case .loop:
player.actionAtEnd = .loop({})
case .loopDisablingSound, .repeatIfNeeded:
player.actionAtEnd = .loopDisablingSound(action)
case .stop:
player.actionAtEnd = .action(action)
}
player.continuePlayingWithoutSound()
}
func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) {
if let player = self.player {
player.setContinuePlayingWithoutSoundOnLostAudioSession(value)
} else {
self.pendingSetContinuePlayingWithoutSoundOnLostAudioSession = value
}
}
func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int {
return self.playbackCompletedListeners.add(f)
}
func removePlaybackCompleted(_ index: Int) {
self.playbackCompletedListeners.remove(index)
}
func fetchControl(_ control: UniversalVideoNodeFetchControl) {
switch control {
case .fetch:
self.fetchDisposable.set(fetchedMediaResource(mediaBox: self.postbox.mediaBox, userLocation: self.userLocation, userContentType: .video, reference: self.fileReference.resourceReference(self.fileReference.media.resource), statsCategory: statsCategoryForFileWithAttributes(self.fileReference.media.attributes)).start())
case .cancel:
self.postbox.mediaBox.cancelInteractiveResourceFetch(self.fileReference.media.resource)
}
}
func notifyPlaybackControlsHidden(_ hidden: Bool) {
}
func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) {
self.playerNode.setCanPlaybackWithoutHierarchy(canPlaybackWithoutHierarchy)
}
func enterNativePictureInPicture() -> Bool {
return false
}
func exitNativePictureInPicture() {
}
func setNativePictureInPictureIsActive(_ value: Bool) {
self.imageNode.isHidden = value
}
}
@@ -0,0 +1,231 @@
import Foundation
import UIKit
import AsyncDisplayKit
import SwiftSignalKit
import Display
import TelegramCore
import Postbox
import TelegramAudio
import AccountContext
import AVKit
import UniversalMediaPlayer
public final class OverlayUniversalVideoNode: OverlayMediaItemNode, AVPictureInPictureSampleBufferPlaybackDelegate {
public let content: UniversalVideoContent
private let videoNode: UniversalVideoNode
private let decoration: OverlayVideoDecoration
private var validLayoutSize: CGSize?
private var shouldBeDismissedDisposable: Disposable?
override public var group: OverlayMediaItemNodeGroup? {
return OverlayMediaItemNodeGroup(rawValue: 0)
}
override public var isMinimizeable: Bool {
return true
}
public var canAttachContent: Bool = true {
didSet {
self.videoNode.canAttachContent = self.canAttachContent
}
}
private let defaultExpand: () -> Void
public var customExpand: (() -> Void)?
public var customClose: (() -> Void)?
public var controlsAreShowingUpdated: ((Bool) -> Void)?
private var statusDisposable: Disposable?
private var status: MediaPlayerStatus?
public init(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, content: UniversalVideoContent, shouldBeDismissed: Signal<Bool, NoError> = .single(false), expand: @escaping () -> Void, close: @escaping () -> Void) {
self.content = content
self.defaultExpand = expand
var expandImpl: (() -> Void)?
var controlsAreShowingUpdatedImpl: ((Bool) -> Void)?
var unminimizeImpl: (() -> Void)?
var togglePlayPauseImpl: (() -> Void)?
var closeImpl: (() -> Void)?
let decoration = OverlayVideoDecoration(contentDimensions: content.dimensions, unminimize: {
unminimizeImpl?()
}, togglePlayPause: {
togglePlayPauseImpl?()
}, expand: {
expandImpl?()
}, close: {
closeImpl?()
}, controlsAreShowingUpdated: { value in
controlsAreShowingUpdatedImpl?(value)
})
self.videoNode = UniversalVideoNode(context: context, postbox: postbox, audioSession: audioSession, manager: manager, decoration: decoration, content: content, priority: .overlay)
self.decoration = decoration
super.init()
expandImpl = { [weak self] in
self?.expand()
}
unminimizeImpl = { [weak self] in
self?.unminimize?()
}
togglePlayPauseImpl = { [weak self] in
self?.videoNode.togglePlayPause()
}
closeImpl = { [weak self] in
if let strongSelf = self {
if let customClose = strongSelf.customClose {
customClose()
return
}
if strongSelf.videoNode.hasAttachedContext {
strongSelf.videoNode.continuePlayingWithoutSound()
}
strongSelf.layer.animateScale(from: 1.0, to: 0.1, duration: 0.25, removeOnCompletion: false, completion: { _ in
self?.dismiss()
close()
})
strongSelf.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
}
}
controlsAreShowingUpdatedImpl = { [weak self] value in
self?.controlsAreShowingUpdated?(value)
}
self.clipsToBounds = true
self.cornerRadius = 4.0
self.addSubnode(self.videoNode)
self.videoNode.ownsContentNodeUpdated = { [weak self] value in
if let strongSelf = self {
let previous = strongSelf.hasAttachedContext
strongSelf.hasAttachedContext = value
strongSelf.hasAttachedContextUpdated?(value)
if previous != value {
if !value {
strongSelf.dismiss()
closeImpl?()
}
}
}
}
self.videoNode.canAttachContent = true
self.shouldBeDismissedDisposable = (shouldBeDismissed
|> filter { $0 }
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.dismiss()
closeImpl?()
})
self.statusDisposable = (self.videoNode.status
|> deliverOnMainQueue).start(next: { [weak self] status in
self?.status = status
})
}
deinit {
self.shouldBeDismissedDisposable?.dispose()
self.statusDisposable?.dispose()
}
override public func didLoad() {
super.didLoad()
}
override public func layout() {
self.updateLayout(self.bounds.size)
}
override public func preferredSizeForOverlayDisplay(boundingSize: CGSize) -> CGSize {
return self.content.dimensions.aspectFitted(CGSize(width: 300.0, height: 300.0))
}
override public func updateLayout(_ size: CGSize) {
if size != self.validLayoutSize {
self.updateLayoutImpl(size, transition: .immediate)
}
}
public func updateLayout(_ size: CGSize, transition: ContainedViewLayoutTransition) {
if size != self.validLayoutSize {
self.updateLayoutImpl(size, transition: transition)
}
}
private func updateLayoutImpl(_ size: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayoutSize = size
transition.updateFrame(node: self.videoNode, frame: CGRect(origin: CGPoint(), size: size))
self.videoNode.updateLayout(size: size, transition: transition)
}
override public func updateMinimizedEdge(_ edge: OverlayMediaItemMinimizationEdge?, adjusting: Bool) {
self.decoration.updateMinimizedEdge(edge, adjusting: adjusting)
}
public func updateRoundCorners(_ value: Bool, transition: ContainedViewLayoutTransition) {
transition.updateCornerRadius(node: self, cornerRadius: value ? 4.0 : 0.0)
}
public func showControls() {
self.decoration.showControls()
}
public func expand() {
if let customExpand = self.customExpand {
customExpand()
} else {
self.defaultExpand()
}
}
public func controlPlay() {
self.videoNode.play()
}
@available(iOSApplicationExtension 15.0, iOS 15.0, *)
override public func makeNativeContentSource() -> AVPictureInPictureController.ContentSource? {
guard let videoLayer = self.videoNode.getVideoLayer() else {
return nil
}
return AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: videoLayer, playbackDelegate: self)
}
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
self.controlPlay()
}
public func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
guard let status = self.status else {
return CMTimeRange(start: CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(30.0)), duration: CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(30.0)))
}
return CMTimeRange(start: CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(30.0)), duration: CMTime(seconds: status.duration, preferredTimescale: CMTimeScale(30.0)))
}
public func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
return false
}
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
}
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
completionHandler()
}
public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
return false
}
}
@@ -0,0 +1,256 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import UniversalMediaPlayer
import LegacyComponents
import AccountContext
import RadialStatusNode
import AppBundle
private func setupArrowFrame(size: CGSize, edge: OverlayMediaItemMinimizationEdge, view: TGEmbedPIPPullArrowView) {
let arrowX: CGFloat
switch edge {
case .left:
view.transform = .identity
arrowX = size.width - 40.0 + floor((40.0 - view.bounds.size.width) / 2.0)
case .right:
view.transform = CGAffineTransform(scaleX: -1.0, y: 1.0)
arrowX = floor((40.0 - view.bounds.size.width) / 2.0)
}
view.frame = CGRect(origin: CGPoint(x: arrowX, y: floor((size.height - view.bounds.size.height) / 2.0)), size: view.bounds.size)
}
private let backgroundImage = UIImage(bundleImageName: "Chat/Message/OverlayPlainVideoShadow")?.precomposed().resizableImage(withCapInsets: UIEdgeInsets(top: 22.0, left: 25.0, bottom: 26.0, right: 25.0), resizingMode: .stretch)
final class OverlayVideoDecoration: UniversalVideoDecoration {
private let contentDimensions: CGSize
let backgroundNode: ASDisplayNode?
let contentContainerNode: ASDisplayNode
let foregroundNode: ASDisplayNode?
private let unminimize: () -> Void
private let controlsAreShowingUpdated: (Bool) -> Void
private let shadowNode: ASImageNode
private let foregroundContainerNode: ASDisplayNode
private let controlsNode: PictureInPictureVideoControlsNode
private let statusNode: RadialStatusNode
private var minimizedBlurView: UIVisualEffectView?
private var minimizedArrowView: TGEmbedPIPPullArrowView?
private var minimizedEdge: OverlayMediaItemMinimizationEdge?
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
private let statusDisposable = MetaDisposable()
private var validLayout: (size: CGSize, actualSize: CGSize)?
init(contentDimensions: CGSize, unminimize: @escaping () -> Void, togglePlayPause: @escaping () -> Void, expand: @escaping () -> Void, close: @escaping () -> Void, controlsAreShowingUpdated: @escaping (Bool) -> Void) {
self.contentDimensions = contentDimensions
self.unminimize = unminimize
self.controlsAreShowingUpdated = controlsAreShowingUpdated
self.shadowNode = ASImageNode()
self.shadowNode.image = backgroundImage
self.backgroundNode = self.shadowNode
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.backgroundColor = .black
self.controlsNode = PictureInPictureVideoControlsNode(leave: {
expand()
}, playPause: {
togglePlayPause()
}, close: {
close()
})
self.controlsNode.alpha = 0.0
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5))
self.statusNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 30.0, height: 30.0))
self.foregroundContainerNode = ASDisplayNode()
self.foregroundContainerNode.addSubnode(self.controlsNode)
self.foregroundContainerNode.addSubnode(self.statusNode)
self.foregroundNode = self.foregroundContainerNode
}
deinit {
self.statusDisposable.dispose()
}
private func frameForContent(size: CGSize) -> CGRect {
if !self.contentDimensions.width.isZero && !self.contentDimensions.height.isZero {
let fittedSize = self.contentDimensions.aspectFittedWithOverflow(size, leeway: 10.0)
return CGRect(origin: CGPoint(x: floor(size.width - fittedSize.width) / 2.0, y: floor(size.height - fittedSize.height) / 2.0), size: fittedSize)
}
return CGRect(origin: CGPoint(), size: size)
}
func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?) {
if self.contentNode !== contentNode {
let previous = self.contentNode
self.contentNode = contentNode
if let previous = previous {
if previous.supernode === self.contentContainerNode {
previous.removeFromSupernode()
}
}
if let contentNode = contentNode {
if contentNode.supernode !== self.contentContainerNode {
self.contentContainerNode.addSubnode(contentNode)
if let validLayout = self.validLayout {
contentNode.frame = self.frameForContent(size: validLayout.size)
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
}
}
}
}
}
func updateContentNodeSnapshot(_ snapshot: UIView?) {
}
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, actualSize)
let contentFrame = self.frameForContent(size: size)
let shadowInsets = UIEdgeInsets(top: 2.0, left: 3.0, bottom: 4.0, right: 3.0)
transition.updateFrame(node: self.shadowNode, frame: CGRect(origin: CGPoint(x: -shadowInsets.left, y: -shadowInsets.top), size: CGSize(width: size.width + shadowInsets.left + shadowInsets.right, height: size.height + shadowInsets.top + shadowInsets.bottom)))
transition.updateFrame(node: self.foregroundContainerNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.controlsNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height)))
self.controlsNode.updateLayout(size: size, transition: transition)
if let minimizedBlurView = self.minimizedBlurView {
minimizedBlurView.frame = CGRect(origin: CGPoint(), size: size)
}
if let minimizedArrowView = self.minimizedArrowView, let minimizedEdge = self.minimizedEdge {
setupArrowFrame(size: size, edge: minimizedEdge, view: minimizedArrowView)
}
transition.updateFrame(node: self.contentContainerNode, frame: CGRect(origin: CGPoint(), size: size))
let progressSize = CGSize(width: 30.0, height: 30.0)
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - progressSize.width) / 2.0), y: floorToScreenPixels((size.height - progressSize.height) / 2.0)), size: progressSize))
if let contentNode = self.contentNode {
transition.updateFrame(node: contentNode, frame: contentFrame)
contentNode.updateLayout(size: contentFrame.size, actualSize: contentFrame.size, transition: transition)
}
}
func tap() {
if self.minimizedEdge != nil {
self.unminimize()
} else {
if self.controlsNode.alpha.isZero {
self.controlsNode.alpha = 1.0
self.controlsNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.controlsAreShowingUpdated(true)
} else {
self.controlsNode.alpha = 0.0
self.controlsNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
self.controlsAreShowingUpdated(false)
}
}
}
func showControls() {
if self.controlsNode.alpha.isZero {
self.controlsNode.alpha = 1.0
self.controlsNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.controlsAreShowingUpdated(true)
}
}
func setStatus(_ status: Signal<MediaPlayerStatus?, NoError>) {
self.controlsNode.status = status |> map { value -> MediaPlayerStatus in
if let value = value {
return value
} else {
return MediaPlayerStatus(generationTimestamp: CACurrentMediaTime(), duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
}
}
self.statusDisposable.set((status |> deliverOnMainQueue).start(next: { [weak self] status in
guard let strongSelf = self else {
return
}
if let status = status, case .buffering = status.status {
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: nil, cancelEnabled: false, animateRotation: true))
} else {
strongSelf.statusNode.transitionToState(.none)
}
}))
}
func updateMinimizedEdge(_ edge: OverlayMediaItemMinimizationEdge?, adjusting: Bool) {
if self.minimizedEdge == edge {
if let minimizedArrowView = self.minimizedArrowView {
minimizedArrowView.setAngled(!adjusting, animated: true)
}
return
}
self.minimizedEdge = edge
if let edge = edge {
if self.minimizedBlurView == nil {
let minimizedBlurView = UIVisualEffectView(effect: nil)
self.minimizedBlurView = minimizedBlurView
if let validLayout = self.validLayout {
minimizedBlurView.frame = CGRect(origin: CGPoint(), size: validLayout.size)
}
minimizedBlurView.isHidden = true
self.foregroundContainerNode.view.addSubview(minimizedBlurView)
}
if self.minimizedArrowView == nil {
let minimizedArrowView = TGEmbedPIPPullArrowView(frame: CGRect(origin: CGPoint(), size: CGSize(width: 8.0, height: 38.0)))
minimizedArrowView.alpha = 0.0
self.minimizedArrowView = minimizedArrowView
self.minimizedBlurView?.contentView.addSubview(minimizedArrowView)
}
if let minimizedArrowView = self.minimizedArrowView {
if let validLayout = self.validLayout {
setupArrowFrame(size: validLayout.size, edge: edge, view: minimizedArrowView)
}
minimizedArrowView.setAngled(!adjusting, animated: true)
}
}
let effect: UIBlurEffect? = edge != nil ? UIBlurEffect(style: .light) : nil
if let edge = edge {
self.minimizedBlurView?.isHidden = false
switch edge {
case .left:
break
case .right:
break
}
}
UIView.animate(withDuration: 0.35, animations: {
self.minimizedBlurView?.effect = effect
self.minimizedArrowView?.alpha = edge != nil ? 1.0 : 0.0;
}, completion: { [weak self] finished in
if let strongSelf = self {
if finished && edge == nil {
strongSelf.minimizedBlurView?.isHidden = true
}
}
})
}
}
@@ -0,0 +1,135 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import UniversalMediaPlayer
import LegacyComponents
import AppBundle
private let leaveImage = UIImage(bundleImageName: "Media Gallery/PictureInPictureLeave")?.precomposed()
private let pauseImage = UIImage(bundleImageName: "Media Gallery/PictureInPicturePause")?.precomposed()
private let playImage = UIImage(bundleImageName: "Media Gallery/PictureInPicturePlay")?.precomposed()
private let closeImage = UIImage(bundleImageName: "Media Gallery/PictureInPictureClose")?.precomposed()
final class PictureInPictureVideoControlsNode: ASDisplayNode {
private let leave: () -> Void
private let playPause: () -> Void
private let close: () -> Void
private let leaveButton: TGEmbedPIPButton
private let pauseButton: TGEmbedPIPButton
private let playButton: TGEmbedPIPButton
private let closeButton: TGEmbedPIPButton
private var playbackStatusValue: MediaPlayerPlaybackStatus?
private var statusValue: MediaPlayerStatus? {
didSet {
if self.statusValue != oldValue {
let playbackStatus = self.statusValue?.status
if self.playbackStatusValue != playbackStatus {
self.playbackStatusValue = playbackStatus
if let playbackStatus = playbackStatus {
switch playbackStatus {
case .paused:
self.playButton.isHidden = false
self.pauseButton.isHidden = true
case .playing:
self.playButton.isHidden = true
self.pauseButton.isHidden = false
case let .buffering(_, whilePlaying, _, _):
if whilePlaying {
self.playButton.isHidden = true
self.pauseButton.isHidden = false
} else {
self.playButton.isHidden = false
self.pauseButton.isHidden = true
}
}
}
}
}
}
}
private var statusDisposable: Disposable?
private var statusValuePromise = Promise<MediaPlayerStatus>()
var status: Signal<MediaPlayerStatus, NoError>? {
didSet {
if let status = self.status {
self.statusValuePromise.set(status)
} else {
self.statusValuePromise.set(.never())
}
}
}
init(leave: @escaping () -> Void, playPause: @escaping () -> Void, close: @escaping () -> Void) {
self.leave = leave
self.playPause = playPause
self.close = close
self.leaveButton = TGEmbedPIPButton(frame: CGRect(origin: CGPoint(), size: TGEmbedPIPButtonSize))
self.pauseButton = TGEmbedPIPButton(frame: CGRect(origin: CGPoint(), size: TGEmbedPIPButtonSize))
self.playButton = TGEmbedPIPButton(frame: CGRect(origin: CGPoint(), size: TGEmbedPIPButtonSize))
self.closeButton = TGEmbedPIPButton(frame: CGRect(origin: CGPoint(), size: TGEmbedPIPButtonSize))
super.init()
self.leaveButton.setIconImage(leaveImage)
self.pauseButton.setIconImage(pauseImage)
self.playButton.setIconImage(playImage)
self.closeButton.setIconImage(closeImage)
self.view.addSubview(self.leaveButton)
self.view.addSubview(self.pauseButton)
self.view.addSubview(self.playButton)
self.view.addSubview(self.closeButton)
self.leaveButton.addTarget(self, action: #selector(self.leavePressed), for: .touchUpInside)
self.playButton.addTarget(self, action: #selector(self.playPausePressed), for: .touchUpInside)
self.pauseButton.addTarget(self, action: #selector(self.playPausePressed), for: .touchUpInside)
self.closeButton.addTarget(self, action: #selector(self.closePressed), for: .touchUpInside)
self.statusDisposable = (self.statusValuePromise.get()
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self {
strongSelf.statusValue = status
}
})
}
deinit {
self.statusDisposable?.dispose()
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
let forth = floor(size.width / 4.0)
let buttonSize = TGEmbedPIPButtonSize
transition.updateFrame(view: self.leaveButton, frame: CGRect(origin: CGPoint(x: forth - floor(buttonSize.width / 2.0) - 10.0, y: size.height - buttonSize.height - 15.0), size: buttonSize))
transition.updateFrame(view: self.pauseButton, frame: CGRect(origin: CGPoint(x: floor((size.width - buttonSize.width) / 2.0), y: size.height - buttonSize.height - 15.0), size: buttonSize))
transition.updateFrame(view: self.playButton, frame: CGRect(origin: CGPoint(x: floor((size.width - buttonSize.width) / 2.0), y: size.height - buttonSize.height - 15.0), size: buttonSize))
transition.updateFrame(view: self.closeButton, frame: CGRect(origin: CGPoint(x: self.playButton.frame.origin.x + forth + 10.0, y: size.height - buttonSize.height - 15.0), size: buttonSize))
}
@objc func leavePressed() {
self.leave()
}
@objc func playPausePressed() {
self.playPause()
}
@objc func closePressed() {
self.close()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,492 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import AVFoundation
import UniversalMediaPlayer
import TelegramAudio
import AccountContext
import PhotoResources
import RangeSet
public enum PlatformVideoContentId: Hashable {
case message(MessageId, UInt32, MediaId)
case instantPage(MediaId, MediaId)
public static func ==(lhs: PlatformVideoContentId, rhs: PlatformVideoContentId) -> Bool {
switch lhs {
case let .message(messageId, stableId, mediaId):
if case .message(messageId, stableId, mediaId) = rhs {
return true
} else {
return false
}
case let .instantPage(pageId, mediaId):
if case .instantPage(pageId, mediaId) = rhs {
return true
} else {
return false
}
}
}
public func hash(into hasher: inout Hasher) {
switch self {
case let .message(messageId, _, mediaId):
hasher.combine(messageId)
hasher.combine(mediaId)
case let .instantPage(pageId, mediaId):
hasher.combine(pageId)
hasher.combine(mediaId)
}
}
}
public final class PlatformVideoContent: UniversalVideoContent {
public enum Content {
case file(FileMediaReference)
case url(String)
var duration: Double? {
switch self {
case let .file(file):
return file.media.duration
case .url:
return nil
}
}
var dimensions: PixelDimensions? {
switch self {
case let .file(file):
return file.media.dimensions
case .url:
return PixelDimensions(width: 480, height: 300)
}
}
}
public let id: AnyHashable
public let nativeId: PlatformVideoContentId
let userLocation: MediaResourceUserLocation
let content: Content
public let dimensions: CGSize
public let duration: Double
let streamVideo: Bool
let loopVideo: Bool
let enableSound: Bool
let baseRate: Double
let fetchAutomatically: Bool
public init(id: PlatformVideoContentId, userLocation: MediaResourceUserLocation, content: Content, streamVideo: Bool = false, loopVideo: Bool = false, enableSound: Bool = true, baseRate: Double = 1.0, fetchAutomatically: Bool = true) {
self.id = id
self.userLocation = userLocation
self.nativeId = id
self.content = content
self.dimensions = self.content.dimensions?.cgSize ?? CGSize(width: 480, height: 320)
self.duration = self.content.duration ?? 0.0
self.streamVideo = streamVideo
self.loopVideo = loopVideo
self.enableSound = enableSound
self.baseRate = baseRate
self.fetchAutomatically = fetchAutomatically
}
public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
return PlatformVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, content: self.content, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically)
}
public func isEqual(to other: UniversalVideoContent) -> Bool {
if let other = other as? PlatformVideoContent {
if case let .message(_, stableId, _) = self.nativeId {
if case .message(_, stableId, _) = other.nativeId {
if case let .file(file) = self.content {
if file.media.isInstantVideo {
return true
}
}
}
}
}
return false
}
}
private final class PlatformVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
private let postbox: Postbox
private let userLocation: MediaResourceUserLocation
private let content: PlatformVideoContent.Content
private let approximateDuration: Double
private let intrinsicDimensions: CGSize
private let audioSessionManager: ManagedAudioSession
private let audioSessionDisposable = MetaDisposable()
private var hasAudioSession = false
private let playbackCompletedListeners = Bag<() -> Void>()
private var initializedStatus = false
private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
private var isBuffering = false
private let _status = ValuePromise<MediaPlayerStatus>()
var status: Signal<MediaPlayerStatus, NoError> {
return self._status.get()
}
private let _bufferingStatus = Promise<(RangeSet<Int64>, Int64)?>()
var bufferingStatus: Signal<(RangeSet<Int64>, Int64)?, NoError> {
return self._bufferingStatus.get()
}
var isNativePictureInPictureActive: Signal<Bool, NoError> {
return .single(false)
}
private let _ready = Promise<Void>()
var ready: Signal<Void, NoError> {
return self._ready.get()
}
private let _preloadCompleted = ValuePromise<Bool>()
var preloadCompleted: Signal<Bool, NoError> {
return self._preloadCompleted.get()
}
private let imageNode: TransformImageNode
private var playerItem: AVPlayerItem?
private let player: AVPlayer
private let playerNode: ASDisplayNode
private var loadProgressDisposable: Disposable?
private var statusDisposable: Disposable?
private var didPlayToEndTimeObserver: NSObjectProtocol?
private var didBecomeActiveObserver: NSObjectProtocol?
private var willResignActiveObserver: NSObjectProtocol?
private var playerItemFailedToPlayToEndTimeObserver: NSObjectProtocol?
private let fetchDisposable = MetaDisposable()
private var dimensions: CGSize?
private let dimensionsPromise = ValuePromise<CGSize>(CGSize())
private var validLayout: (size: CGSize, actualSize: CGSize)?
init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, content: PlatformVideoContent.Content, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) {
self.postbox = postbox
self.content = content
self.approximateDuration = Double(content.duration ?? 1)
self.audioSessionManager = audioSessionManager
self.userLocation = userLocation
self.imageNode = TransformImageNode()
let player = AVPlayer(playerItem: nil)
self.player = player
self.playerNode = ASDisplayNode()
self.playerNode.setLayerBlock({
return AVPlayerLayer(player: player)
})
self.intrinsicDimensions = content.dimensions?.cgSize ?? CGSize()
self.playerNode.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions)
super.init()
switch content {
case let .file(file):
self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: self.userLocation, videoReference: file) |> map { [weak self] getSize, getData in
Queue.mainQueue().async {
if let strongSelf = self, strongSelf.dimensions == nil {
if let dimensions = getSize() {
strongSelf.dimensions = dimensions
strongSelf.dimensionsPromise.set(dimensions)
if let validLayout = strongSelf.validLayout {
strongSelf.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
}
}
}
}
return getData
})
case .url:
break
}
self.addSubnode(self.imageNode)
self.addSubnode(self.playerNode)
self.player.actionAtItemEnd = .pause
self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self.player.currentItem, queue: nil, using: { [weak self] notification in
self?.performActionAtEnd()
})
self.imageNode.imageUpdated = { [weak self] _ in
self?._ready.set(.single(Void()))
}
self.player.addObserver(self, forKeyPath: "rate", options: [], context: nil)
self._bufferingStatus.set(.single(nil))
let playerItem: AVPlayerItem
switch content {
case let .file(file):
playerItem = AVPlayerItem(url: URL(string: postbox.mediaBox.completedResourcePath(file.media.resource, pathExtension: "mov") ?? "")!)
case let .url(url):
playerItem = AVPlayerItem(url: URL(string: url)!)
}
self.setPlayerItem(playerItem)
self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil, using: { [weak self] _ in
guard let strongSelf = self, let layer = strongSelf.playerNode.layer as? AVPlayerLayer else {
return
}
layer.player = strongSelf.player
})
self.willResignActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil, using: { [weak self] _ in
guard let strongSelf = self, let layer = strongSelf.playerNode.layer as? AVPlayerLayer else {
return
}
layer.player = nil
})
}
deinit {
self.player.removeObserver(self, forKeyPath: "rate")
self.setPlayerItem(nil)
self.audioSessionDisposable.dispose()
self.loadProgressDisposable?.dispose()
self.statusDisposable?.dispose()
if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver {
NotificationCenter.default.removeObserver(didPlayToEndTimeObserver)
}
if let didBecomeActiveObserver = self.didBecomeActiveObserver {
NotificationCenter.default.removeObserver(didBecomeActiveObserver)
}
if let willResignActiveObserver = self.willResignActiveObserver {
NotificationCenter.default.removeObserver(willResignActiveObserver)
}
}
private func setPlayerItem(_ item: AVPlayerItem?) {
if let playerItem = self.playerItem {
playerItem.removeObserver(self, forKeyPath: "playbackBufferEmpty")
playerItem.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp")
playerItem.removeObserver(self, forKeyPath: "playbackBufferFull")
playerItem.removeObserver(self, forKeyPath: "status")
if let playerItemFailedToPlayToEndTimeObserver = self.playerItemFailedToPlayToEndTimeObserver {
NotificationCenter.default.removeObserver(playerItemFailedToPlayToEndTimeObserver)
self.playerItemFailedToPlayToEndTimeObserver = nil
}
}
self.playerItem = item
if let playerItem = self.playerItem {
playerItem.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil)
playerItem.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil)
playerItem.addObserver(self, forKeyPath: "playbackBufferFull", options: .new, context: nil)
playerItem.addObserver(self, forKeyPath: "status", options: .new, context: nil)
self.playerItemFailedToPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime, object: playerItem, queue: OperationQueue.main, using: { [weak self] _ in
guard let strongSelf = self else {
return
}
switch strongSelf.content {
case .file:
break
case let .url(url):
let updatedPlayerItem = AVPlayerItem(url: URL(string: url)!)
strongSelf.setPlayerItem(updatedPlayerItem)
}
})
}
self.player.replaceCurrentItem(with: self.playerItem)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "rate" {
let isPlaying = !self.player.rate.isZero
let status: MediaPlayerPlaybackStatus
if isPlaying {
self.isBuffering = false
}
if self.isBuffering {
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
} else {
status = isPlaying ? .playing : .paused
}
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: status, soundEnabled: true)
self._status.set(self.statusValue)
} else if keyPath == "playbackBufferEmpty" {
let isPlaying = !self.player.rate.isZero
let status: MediaPlayerPlaybackStatus
self.isBuffering = true
if self.isBuffering {
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
} else {
status = isPlaying ? .playing : .paused
}
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: status, soundEnabled: true)
self._status.set(self.statusValue)
} else if keyPath == "playbackLikelyToKeepUp" || keyPath == "playbackBufferFull" {
let isPlaying = !self.player.rate.isZero
let status: MediaPlayerPlaybackStatus
self.isBuffering = false
if self.isBuffering {
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
} else {
status = isPlaying ? .playing : .paused
}
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: status, soundEnabled: true)
self._status.set(self.statusValue)
} else if keyPath == "status" {
/*if let playerItem = self.playerItem, false {
switch playerItem.status {
case .failed:
switch self.content {
case .file:
break
case let .url(url):
let updatedPlayerItem = AVPlayerItem(url: URL(string: url)!)
self.setPlayerItem(updatedPlayerItem)
}
default:
break
}
}*/
}
}
private func performActionAtEnd() {
for listener in self.playbackCompletedListeners.copyItems() {
listener()
}
}
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width)
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size))
let makeImageLayout = self.imageNode.asyncLayout()
let applyImageLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets()))
applyImageLayout()
}
func play() {
assert(Queue.mainQueue().isCurrent())
if !self.initializedStatus {
self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true))
}
if !self.hasAudioSession {
self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in
self?.hasAudioSession = true
self?.player.play()
}, deactivate: { [weak self] _ in
self?.hasAudioSession = false
self?.player.pause()
return .complete()
}))
} else {
self.player.play()
}
}
func pause() {
assert(Queue.mainQueue().isCurrent())
if !self.initializedStatus {
self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true))
}
self.player.pause()
}
func togglePlayPause() {
assert(Queue.mainQueue().isCurrent())
if self.player.rate.isZero {
self.play()
} else {
self.pause()
}
}
func setSoundEnabled(_ value: Bool) {
assert(Queue.mainQueue().isCurrent())
}
func seek(_ timestamp: Double) {
assert(Queue.mainQueue().isCurrent())
self.player.seek(to: CMTime(seconds: timestamp, preferredTimescale: 30))
}
func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
}
func setSoundMuted(soundMuted: Bool) {
}
func continueWithOverridingAmbientMode(isAmbient: Bool) {
}
func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) {
}
func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
}
func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) {
}
func setBaseRate(_ baseRate: Double) {
}
func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) {
}
func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? {
return nil
}
func videoQualityStateSignal() -> Signal<(current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])?, NoError> {
return .single(nil)
}
func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int {
return self.playbackCompletedListeners.add(f)
}
func removePlaybackCompleted(_ index: Int) {
self.playbackCompletedListeners.remove(index)
}
func fetchControl(_ control: UniversalVideoNodeFetchControl) {
}
func notifyPlaybackControlsHidden(_ hidden: Bool) {
}
func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) {
}
func enterNativePictureInPicture() -> Bool {
return false
}
func exitNativePictureInPicture() {
}
func setNativePictureInPictureIsActive(_ value: Bool) {
}
}
@@ -0,0 +1,332 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramAudio
import LegacyComponents
import UniversalMediaPlayer
import AccountContext
import PhotoResources
import RangeSet
import CoreMedia
import AVFoundation
public final class SystemVideoContent: UniversalVideoContent {
public let id: AnyHashable
let userLocation: MediaResourceUserLocation
let url: String
let imageReference: ImageMediaReference
public let dimensions: CGSize
public let duration: Double
public init(userLocation: MediaResourceUserLocation, url: String, imageReference: ImageMediaReference, dimensions: CGSize, duration: Double) {
self.id = AnyHashable(url)
self.url = url
self.userLocation = userLocation
self.imageReference = imageReference
self.dimensions = dimensions
self.duration = duration
}
public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
return SystemVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, url: self.url, imageReference: self.imageReference, intrinsicDimensions: self.dimensions, approximateDuration: self.duration)
}
}
private final class SystemVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
private let url: String
private let intrinsicDimensions: CGSize
private let approximateDuration: Double
private let audioSessionManager: ManagedAudioSession
private let audioSessionDisposable = MetaDisposable()
private var hasAudioSession = false
private let playbackCompletedListeners = Bag<() -> Void>()
private var initializedStatus = false
private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: false, progress: 0.0, display: true), soundEnabled: true)
private var isBuffering = true
private let _status = ValuePromise<MediaPlayerStatus>()
var status: Signal<MediaPlayerStatus, NoError> {
return self._status.get()
}
private let _bufferingStatus = Promise<(RangeSet<Int64>, Int64)?>()
var bufferingStatus: Signal<(RangeSet<Int64>, Int64)?, NoError> {
return self._bufferingStatus.get()
}
var isNativePictureInPictureActive: Signal<Bool, NoError> {
return .single(false)
}
private let _ready = Promise<Void>()
var ready: Signal<Void, NoError> {
return self._ready.get()
}
private let _preloadCompleted = ValuePromise<Bool>()
var preloadCompleted: Signal<Bool, NoError> {
return self._preloadCompleted.get()
}
private let imageNode: TransformImageNode
private let playerItem: AVPlayerItem
private let player: AVPlayer
private let playerNode: ASDisplayNode
private var loadProgressDisposable: Disposable?
private var statusDisposable: Disposable?
private var didBeginPlaying = false
private var didPlayToEndTimeObserver: NSObjectProtocol?
private var timeObserver: Any?
private var seekId: Int = 0
init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, url: String, imageReference: ImageMediaReference, intrinsicDimensions: CGSize, approximateDuration: Double) {
self.audioSessionManager = audioSessionManager
self.url = url
self.intrinsicDimensions = intrinsicDimensions
self.approximateDuration = approximateDuration
self.imageNode = TransformImageNode()
self.playerItem = AVPlayerItem(url: URL(string: url)!)
let player = AVPlayer(playerItem: self.playerItem)
self.player = player
self.playerNode = ASDisplayNode()
self.playerNode.setLayerBlock({
return AVPlayerLayer(player: player)
})
self.playerNode.frame = CGRect(origin: CGPoint(), size: intrinsicDimensions)
self.isBuffering = true
super.init()
self.imageNode.setSignal(chatMessagePhoto(postbox: postbox, userLocation: userLocation, photoReference: imageReference))
self.addSubnode(self.imageNode)
self.addSubnode(self.playerNode)
self.player.actionAtItemEnd = .pause
self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self.player.currentItem, queue: nil, using: { [weak self] notification in
if let strongSelf = self {
strongSelf.player.seek(to: CMTime(seconds: 0.0, preferredTimescale: 30))
strongSelf.play()
}
})
self.imageNode.imageUpdated = { [weak self] _ in
self?._ready.set(.single(Void()))
}
self.player.addObserver(self, forKeyPath: "rate", options: [], context: nil)
self.playerItem.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil)
self.playerItem.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil)
self.playerItem.addObserver(self, forKeyPath: "playbackBufferFull", options: .new, context: nil)
self._bufferingStatus.set(.single(nil))
self._status.set(self.statusValue)
self.timeObserver = self.player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 10), queue: DispatchQueue.main) { [weak self] time in
guard let strongSelf = self else {
return
}
strongSelf.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: strongSelf.statusValue.duration, dimensions: CGSize(), timestamp: CMTimeGetSeconds(time), baseRate: 1.0, seekId: strongSelf.seekId, status: strongSelf.statusValue.status, soundEnabled: true)
strongSelf._status.set(strongSelf.statusValue)
}
}
deinit {
if let timeObserver = self.timeObserver {
self.player.removeTimeObserver(timeObserver)
}
self.player.removeObserver(self, forKeyPath: "rate")
self.playerItem.removeObserver(self, forKeyPath: "playbackBufferEmpty")
self.playerItem.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp")
self.playerItem.removeObserver(self, forKeyPath: "playbackBufferFull")
self.audioSessionDisposable.dispose()
self.loadProgressDisposable?.dispose()
self.statusDisposable?.dispose()
if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver {
NotificationCenter.default.removeObserver(didPlayToEndTimeObserver)
}
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
let duration: Double
if let currentItem = self.player.currentItem {
duration = CMTimeGetSeconds(currentItem.duration)
} else {
duration = self.approximateDuration
}
if keyPath == "rate" {
let isPlaying = !self.player.rate.isZero
let status: MediaPlayerPlaybackStatus
if self.isBuffering {
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
} else {
status = isPlaying ? .playing : .paused
}
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: duration, dimensions: CGSize(), timestamp: self.statusValue.timestamp, baseRate: 1.0, seekId: self.seekId, status: status, soundEnabled: true)
self._status.set(self.statusValue)
} else if keyPath == "playbackBufferEmpty" {
let isPlaying = !self.player.rate.isZero
let status: MediaPlayerPlaybackStatus
self.isBuffering = true
if self.isBuffering {
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
} else {
status = isPlaying ? .playing : .paused
}
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: duration, dimensions: CGSize(), timestamp: self.statusValue.timestamp, baseRate: 1.0, seekId: self.seekId, status: status, soundEnabled: true)
self._status.set(self.statusValue)
} else if keyPath == "playbackLikelyToKeepUp" || keyPath == "playbackBufferFull" {
let isPlaying = !self.player.rate.isZero
let status: MediaPlayerPlaybackStatus
self.isBuffering = false
if self.isBuffering {
status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true)
} else {
status = isPlaying ? .playing : .paused
}
self.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: duration, dimensions: CGSize(), timestamp: self.statusValue.timestamp, baseRate: 1.0, seekId: self.seekId, status: status, soundEnabled: true)
self._status.set(self.statusValue)
if !self.didBeginPlaying {
self.didBeginPlaying = true
self.play()
}
}
}
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width)
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size))
let makeImageLayout = self.imageNode.asyncLayout()
let applyImageLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets()))
applyImageLayout()
}
func play() {
assert(Queue.mainQueue().isCurrent())
if !self.initializedStatus {
self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: self.approximateDuration, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true))
}
if !self.hasAudioSession {
self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in
self?.hasAudioSession = true
self?.player.play()
}, deactivate: { [weak self] _ in
self?.hasAudioSession = false
self?.player.pause()
return .complete()
}))
} else {
self.player.play()
}
}
func pause() {
assert(Queue.mainQueue().isCurrent())
if !self.initializedStatus {
self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: self.approximateDuration, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: self.seekId, status: .paused, soundEnabled: true))
}
self.player.pause()
}
func togglePlayPause() {
assert(Queue.mainQueue().isCurrent())
if self.player.rate.isZero {
self.play()
} else {
self.pause()
}
}
func setSoundEnabled(_ value: Bool) {
assert(Queue.mainQueue().isCurrent())
}
func seek(_ timestamp: Double) {
assert(Queue.mainQueue().isCurrent())
self.seekId += 1
self.playerItem.seek(to: CMTimeMake(value: Int64(timestamp) * 1000, timescale: 1000), completionHandler: nil)
}
func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
}
func setSoundMuted(soundMuted: Bool) {
}
func continueWithOverridingAmbientMode(isAmbient: Bool) {
}
func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) {
}
func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
}
func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) {
}
func setBaseRate(_ baseRate: Double) {
}
func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) {
}
func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? {
return nil
}
func videoQualityStateSignal() -> Signal<(current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])?, NoError> {
return .single(nil)
}
func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int {
return self.playbackCompletedListeners.add(f)
}
func removePlaybackCompleted(_ index: Int) {
self.playbackCompletedListeners.remove(index)
}
func fetchControl(_ control: UniversalVideoNodeFetchControl) {
}
func notifyPlaybackControlsHidden(_ hidden: Bool) {
}
func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) {
}
func enterNativePictureInPicture() -> Bool {
return false
}
func exitNativePictureInPicture() {
}
func setNativePictureInPictureIsActive(_ value: Bool) {
}
}
@@ -0,0 +1,19 @@
//
// TelegramUniversalVideoContent.h
// TelegramUniversalVideoContent
//
// Created by Peter on 8/11/19.
// Copyright © 2019 Telegram Messenger LLP. All rights reserved.
//
#import <UIKit/UIKit.h>
//! Project version number for TelegramUniversalVideoContent.
FOUNDATION_EXPORT double TelegramUniversalVideoContentVersionNumber;
//! Project version string for TelegramUniversalVideoContent.
FOUNDATION_EXPORT const unsigned char TelegramUniversalVideoContentVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <TelegramUniversalVideoContent/PublicHeader.h>
@@ -0,0 +1,120 @@
import Foundation
import WebKit
import SwiftSignalKit
import UniversalMediaPlayer
import AppBundle
func isTwitchVideoUrl(_ url: String) -> Bool {
return url.contains("//player.twitch.tv/") || url.contains("//clips.twitch.tv/")
}
final class TwitchEmbedImplementation: WebEmbedImplementation {
private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)?
private var updateStatus: ((MediaPlayerStatus) -> Void)?
private var onPlaybackStarted: (() -> Void)?
private let url: String
private var status : MediaPlayerStatus
private var started = false
init(url: String) {
self.url = url
self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)
}
func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) {
let bundle = getAppBundle()
guard let userScriptPath = bundle.path(forResource: "TwitchUserScript", ofType: "js") else {
return
}
guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else {
return
}
guard let userScript = String(data: userScriptData, encoding: .utf8) else {
return
}
guard let htmlTemplatePath = bundle.path(forResource: "Twitch", ofType: "html") else {
return
}
guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else {
return
}
guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else {
return
}
self.evalImpl = evaluateJavaScript
self.updateStatus = updateStatus
self.onPlaybackStarted = onPlaybackStarted
updateStatus(self.status)
let html = String(format: htmlTemplate, self.url)
webView.loadHTMLString(html, baseURL: URL(string: "about:blank"))
userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
func play() {
if let eval = self.evalImpl {
eval("playPause()", nil)
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: self.status.timestamp, baseRate: 1.0, seekId: self.status.seekId, status: .playing, soundEnabled: self.status.soundEnabled)
if let updateStatus = self.updateStatus {
updateStatus(self.status)
}
}
func pause() {
if let eval = self.evalImpl {
eval("playPause()", nil)
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: self.status.timestamp, baseRate: 1.0, seekId: self.status.seekId, status: .paused, soundEnabled: self.status.soundEnabled)
if let updateStatus = self.updateStatus {
updateStatus(self.status)
}
}
func togglePlayPause() {
if self.status.status == .playing {
self.pause()
} else {
self.play()
}
}
func seek(timestamp: Double) {
}
func setBaseRate(_ baseRate: Double) {
}
func pageReady() {
// Queue.mainQueue().after(delay: 0.5) {
// if let onPlaybackStarted = self.onPlaybackStarted {
// onPlaybackStarted()
// }
// }
}
func callback(url: URL) {
switch url.host {
case "onPlayback":
if !self.started {
self.started = true
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: self.status.timestamp, baseRate: 1.0, seekId: self.status.seekId, status: .playing, soundEnabled: self.status.soundEnabled)
if let updateStatus = self.updateStatus {
updateStatus(self.status)
}
if let onPlaybackStarted = self.onPlaybackStarted {
onPlaybackStarted()
}
}
default:
break
}
}
}
@@ -0,0 +1,367 @@
import Foundation
import UIKit
import AsyncDisplayKit
import SwiftSignalKit
import UniversalMediaPlayer
import AccountContext
import RangeSet
private final class UniversalVideoContentSubscriber {
let id: Int32
let priority: UniversalVideoPriority
let update: (((UniversalVideoContentNode & ASDisplayNode), Bool)?) -> Void
var active: Bool = false
init(id: Int32, priority: UniversalVideoPriority, update: @escaping (((UniversalVideoContentNode & ASDisplayNode), Bool)?) -> Void) {
self.id = id
self.priority = priority
self.update = update
}
}
private final class UniversalVideoContentHolder {
private var nextId: Int32 = 0
private var subscribers: [UniversalVideoContentSubscriber] = []
let content: UniversalVideoContent
let contentNode: UniversalVideoContentNode & ASDisplayNode
var statusDisposable: Disposable?
var statusValue: MediaPlayerStatus?
var bufferingStatusDisposable: Disposable?
var bufferingStatusValue: (RangeSet<Int64>, Int64)?
var isNativePictureInPictureActiveDisposable: Disposable?
var isNativePictureInPictureActiveValue: Bool = false
var playbackCompletedIndex: Int?
init(content: UniversalVideoContent, contentNode: UniversalVideoContentNode & ASDisplayNode, statusUpdated: @escaping (MediaPlayerStatus?) -> Void, bufferingStatusUpdated: @escaping ((RangeSet<Int64>, Int64)?) -> Void, playbackCompleted: @escaping () -> Void, isNativePictureInPictureActiveUpdated: @escaping (Bool) -> Void) {
self.content = content
self.contentNode = contentNode
self.statusDisposable = (contentNode.status |> deliverOnMainQueue).start(next: { [weak self] value in
if let strongSelf = self {
strongSelf.statusValue = value
statusUpdated(value)
}
})
self.bufferingStatusDisposable = (contentNode.bufferingStatus |> deliverOnMainQueue).start(next: { [weak self] value in
if let strongSelf = self {
strongSelf.bufferingStatusValue = value
bufferingStatusUpdated(value)
}
})
self.isNativePictureInPictureActiveDisposable = (contentNode.isNativePictureInPictureActive |> deliverOnMainQueue).start(next: { [weak self] value in
if let strongSelf = self {
strongSelf.isNativePictureInPictureActiveValue = value
isNativePictureInPictureActiveUpdated(value)
}
})
self.playbackCompletedIndex = contentNode.addPlaybackCompleted {
playbackCompleted()
}
}
deinit {
self.statusDisposable?.dispose()
self.bufferingStatusDisposable?.dispose()
self.isNativePictureInPictureActiveDisposable?.dispose()
if let playbackCompletedIndex = self.playbackCompletedIndex {
self.contentNode.removePlaybackCompleted(playbackCompletedIndex)
}
}
var isEmpty: Bool {
return self.subscribers.isEmpty
}
func addSubscriber(priority: UniversalVideoPriority, update: @escaping (((UniversalVideoContentNode & ASDisplayNode), Bool)?) -> Void) -> Int32 {
let id = self.nextId
self.nextId += 1
self.subscribers.append(UniversalVideoContentSubscriber(id: id, priority: priority, update: update))
self.subscribers.sort(by: { lhs, rhs in
if lhs.priority != rhs.priority {
return lhs.priority < rhs.priority
}
return lhs.id < rhs.id
})
return id
}
func removeSubscriberAndUpdate(id: Int32) {
for i in 0 ..< self.subscribers.count {
if self.subscribers[i].id == id {
let subscriber = self.subscribers[i]
self.subscribers.remove(at: i)
if subscriber.active {
self.update(removeSubscribers: [subscriber])
}
break
}
}
}
func update(forceUpdateId: Int32? = nil, initiatedCreation: Int32? = nil, removeSubscribers: [UniversalVideoContentSubscriber] = []) {
var removeSubscribers = removeSubscribers
for i in (0 ..< self.subscribers.count) {
if i == self.subscribers.count - 1 {
if !self.subscribers[i].active {
self.subscribers[i].active = true
self.subscribers[i].update((self.contentNode, initiatedCreation: initiatedCreation == self.subscribers[i].id))
}
} else {
if self.subscribers[i].active {
self.subscribers[i].active = false
removeSubscribers.append(self.subscribers[i])
}
}
}
for subscriber in removeSubscribers {
subscriber.update(nil)
}
if let forceUpdateId = forceUpdateId {
for subscriber in self.subscribers {
if subscriber.id == forceUpdateId {
if !subscriber.active {
subscriber.update(nil)
}
break
}
}
}
}
}
private final class UniversalVideoContentHolderCallbacks {
let playbackCompleted = Bag<() -> Void>()
let status = Bag<(MediaPlayerStatus?) -> Void>()
let bufferingStatus = Bag<((RangeSet<Int64>, Int64)?) -> Void>()
let isNativePictureInPictureActive = Bag<(Bool) -> Void>()
var isEmpty: Bool {
return self.playbackCompleted.isEmpty && self.status.isEmpty && self.bufferingStatus.isEmpty && self.isNativePictureInPictureActive.isEmpty
}
}
public final class UniversalVideoManagerImpl: UniversalVideoManager {
private var holders: [AnyHashable: UniversalVideoContentHolder] = [:]
private var holderCallbacks: [AnyHashable: UniversalVideoContentHolderCallbacks] = [:]
public init() {
}
public func attachUniversalVideoContent(content: UniversalVideoContent, priority: UniversalVideoPriority, create: () -> UniversalVideoContentNode & ASDisplayNode, update: @escaping (((UniversalVideoContentNode & ASDisplayNode), Bool)?) -> Void) -> (AnyHashable, Int32) {
assert(Queue.mainQueue().isCurrent())
var initiatedCreation = false
let holder: UniversalVideoContentHolder
if let current = self.holders[content.id] {
holder = current
} else {
let foundHolder: UniversalVideoContentHolder? = nil
for (_, current) in self.holders {
if current.content.isEqual(to: content) {
//foundHolder = current
break
}
}
if let foundHolder = foundHolder {
holder = foundHolder
} else {
initiatedCreation = true
holder = UniversalVideoContentHolder(content: content, contentNode: create(), statusUpdated: { [weak self] value in
if let strongSelf = self {
if let current = strongSelf.holderCallbacks[content.id] {
for subscriber in current.status.copyItems() {
subscriber(value)
}
}
}
}, bufferingStatusUpdated: { [weak self] value in
if let strongSelf = self {
if let current = strongSelf.holderCallbacks[content.id] {
for subscriber in current.bufferingStatus.copyItems() {
subscriber(value)
}
}
}
}, playbackCompleted: { [weak self] in
if let strongSelf = self {
if let current = strongSelf.holderCallbacks[content.id] {
for subscriber in current.playbackCompleted.copyItems() {
subscriber()
}
}
}
}, isNativePictureInPictureActiveUpdated: { [weak self] value in
if let strongSelf = self {
if let current = strongSelf.holderCallbacks[content.id] {
for subscriber in current.isNativePictureInPictureActive.copyItems() {
subscriber(value)
}
}
}
})
self.holders[content.id] = holder
}
}
let id = holder.addSubscriber(priority: priority, update: update)
holder.update(forceUpdateId: id, initiatedCreation: initiatedCreation ? id : nil)
return (holder.content.id, id)
}
public func detachUniversalVideoContent(id: AnyHashable, index: Int32) {
assert(Queue.mainQueue().isCurrent())
if let holder = self.holders[id] {
holder.removeSubscriberAndUpdate(id: index)
if holder.isEmpty {
self.holders.removeValue(forKey: id)
if let current = self.holderCallbacks[id] {
for subscriber in current.status.copyItems() {
subscriber(nil)
}
}
}
}
}
public func withUniversalVideoContent(id: AnyHashable, _ f: ((UniversalVideoContentNode & ASDisplayNode)?) -> Void) {
if let holder = self.holders[id] {
f(holder.contentNode)
} else {
f(nil)
}
}
public func addPlaybackCompleted(id: AnyHashable, _ f: @escaping () -> Void) -> Int {
assert(Queue.mainQueue().isCurrent())
var callbacks: UniversalVideoContentHolderCallbacks
if let current = self.holderCallbacks[id] {
callbacks = current
} else {
callbacks = UniversalVideoContentHolderCallbacks()
self.holderCallbacks[id] = callbacks
}
return callbacks.playbackCompleted.add(f)
}
public func removePlaybackCompleted(id: AnyHashable, index: Int) {
if let current = self.holderCallbacks[id] {
current.playbackCompleted.remove(index)
if current.playbackCompleted.isEmpty {
self.holderCallbacks.removeValue(forKey: id)
}
}
}
public func statusSignal(content: UniversalVideoContent) -> Signal<MediaPlayerStatus?, NoError> {
return Signal { subscriber in
var callbacks: UniversalVideoContentHolderCallbacks
if let current = self.holderCallbacks[content.id] {
callbacks = current
} else {
callbacks = UniversalVideoContentHolderCallbacks()
self.holderCallbacks[content.id] = callbacks
}
let index = callbacks.status.add({ value in
subscriber.putNext(value)
})
if let current = self.holders[content.id] {
subscriber.putNext(current.statusValue)
} else {
subscriber.putNext(nil)
}
return ActionDisposable {
Queue.mainQueue().async {
if let current = self.holderCallbacks[content.id] {
current.status.remove(index)
if current.playbackCompleted.isEmpty {
self.holderCallbacks.removeValue(forKey: content.id)
}
}
}
}
} |> runOn(Queue.mainQueue())
}
public func bufferingStatusSignal(content: UniversalVideoContent) -> Signal<(RangeSet<Int64>, Int64)?, NoError> {
return Signal { subscriber in
var callbacks: UniversalVideoContentHolderCallbacks
if let current = self.holderCallbacks[content.id] {
callbacks = current
} else {
callbacks = UniversalVideoContentHolderCallbacks()
self.holderCallbacks[content.id] = callbacks
}
let index = callbacks.bufferingStatus.add({ value in
subscriber.putNext(value)
})
if let current = self.holders[content.id] {
subscriber.putNext(current.bufferingStatusValue)
} else {
subscriber.putNext(nil)
}
return ActionDisposable {
Queue.mainQueue().async {
if let current = self.holderCallbacks[content.id] {
current.status.remove(index)
if current.playbackCompleted.isEmpty {
self.holderCallbacks.removeValue(forKey: content.id)
}
}
}
}
} |> runOn(Queue.mainQueue())
}
public func isNativePictureInPictureActiveSignal(content: UniversalVideoContent) -> Signal<Bool, NoError> {
return Signal { subscriber in
var callbacks: UniversalVideoContentHolderCallbacks
if let current = self.holderCallbacks[content.id] {
callbacks = current
} else {
callbacks = UniversalVideoContentHolderCallbacks()
self.holderCallbacks[content.id] = callbacks
}
let index = callbacks.isNativePictureInPictureActive.add({ value in
subscriber.putNext(value)
})
if let current = self.holders[content.id] {
subscriber.putNext(current.isNativePictureInPictureActiveValue)
} else {
subscriber.putNext(false)
}
return ActionDisposable {
Queue.mainQueue().async {
if let current = self.holderCallbacks[content.id] {
current.status.remove(index)
if current.playbackCompleted.isEmpty {
self.holderCallbacks.removeValue(forKey: content.id)
}
}
}
}
} |> runOn(Queue.mainQueue())
}
}
@@ -0,0 +1,266 @@
import Foundation
import WebKit
import SwiftSignalKit
import UniversalMediaPlayer
import AppBundle
func extractVimeoVideoIdAndTimestamp(url: String) -> (String, Int)? {
guard let url = URL(string: url), let host = url.host?.lowercased() else {
return nil
}
let match = ["vimeo.com", "player.vimeo.com"].contains(where: { (domain) -> Bool in
return host == domain || host.contains(".\(domain)")
})
guard match else {
return nil
}
var videoId: String?
var timestamp = 0
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
if let queryItems = components.queryItems {
for queryItem in queryItems {
if let value = queryItem.value {
if queryItem.name == "t" {
if value.contains("s") {
var range = value.startIndex..<value.endIndex
if let hoursRange = value.range(of: "h", options: .caseInsensitive, range: range, locale: nil) {
let subvalue = String(value[range.lowerBound ..< hoursRange.lowerBound])
if let hours = Int(subvalue) {
timestamp = timestamp + hours * 3600
}
range = hoursRange.upperBound..<value.endIndex
}
if let minutesRange = value.range(of: "m", options: .caseInsensitive, range: range, locale: nil) {
let subvalue = String(value[range.lowerBound ..< minutesRange.lowerBound])
if let minutes = Int(subvalue) {
timestamp = timestamp + minutes * 60
}
range = minutesRange.upperBound..<value.endIndex
}
if let secondsRange = value.range(of: "s", options: .caseInsensitive, range: range, locale: nil) {
let subvalue = String(value[range.lowerBound ..< secondsRange.lowerBound])
if let seconds = Int(subvalue) {
timestamp = timestamp + seconds
}
}
} else {
if let seconds = Int(value) {
timestamp = seconds
}
}
}
}
}
}
if videoId == nil {
let pathComponents = components.path.components(separatedBy: "/")
var nextComponentIsVideoId = false
for component in pathComponents {
if !component.isEmpty && (CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: component)) || nextComponentIsVideoId) {
videoId = component
break
} else if component == "video" {
nextComponentIsVideoId = true
}
}
}
}
if let videoId = videoId {
return (videoId, timestamp)
}
return nil
}
final class VimeoEmbedImplementation: WebEmbedImplementation {
private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)?
private var updateStatus: ((MediaPlayerStatus) -> Void)?
private var onPlaybackStarted: (() -> Void)?
private let videoId: String
private let timestamp: Int
private var baseRate: Double = 1.0
private var status : MediaPlayerStatus
private var ready: Bool = false
private var started: Bool = false
private var ignorePosition: Int?
init(videoId: String, timestamp: Int = 0) {
self.videoId = videoId
self.timestamp = timestamp
self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: Double(timestamp), baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)
}
func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) {
let bundle = getAppBundle()
guard let userScriptPath = bundle.path(forResource: "VimeoUserScript", ofType: "js") else {
return
}
guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else {
return
}
guard let userScript = String(data: userScriptData, encoding: .utf8) else {
return
}
guard let htmlTemplatePath = bundle.path(forResource: "Vimeo", ofType: "html") else {
return
}
guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else {
return
}
guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else {
return
}
self.evalImpl = evaluateJavaScript
self.updateStatus = updateStatus
self.onPlaybackStarted = onPlaybackStarted
updateStatus(self.status)
let html = String(format: htmlTemplate, self.videoId, "true")
webView.loadHTMLString(html, baseURL: URL(string: "https://player.vimeo.com/"))
webView.isUserInteractionEnabled = false
userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
func play() {
if let eval = self.evalImpl {
eval("play();", nil)
}
ignorePosition = 2
}
func pause() {
if let eval = self.evalImpl {
eval("pause();", nil)
}
}
func togglePlayPause() {
if case .playing = self.status.status {
pause()
} else {
play()
}
}
func seek(timestamp: Double) {
if let eval = self.evalImpl {
eval("seek(\(timestamp));", nil)
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, baseRate: self.status.baseRate, seekId: self.status.seekId + 1, status: self.status.status, soundEnabled: self.status.soundEnabled)
self.updateStatus?(self.status)
self.ignorePosition = 2
}
func setBaseRate(_ baseRate: Double) {
var baseRate = baseRate
if baseRate < 0.5 {
baseRate = 0.5
}
if baseRate > 2.0 {
baseRate = 2.0
}
if !self.ready {
self.baseRate = baseRate
}
if let eval = self.evalImpl {
eval("setRate(\(baseRate));", nil)
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: self.status.timestamp, baseRate: baseRate, seekId: self.status.seekId + 1, status: self.status.status, soundEnabled: true)
self.updateStatus?(self.status)
}
func pageReady() {
}
func callback(url: URL) {
if url.host == "onState" {
var newTimestamp = self.status.timestamp
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
var playback: Int?
var position: Double?
var duration: Double?
var download: Float?
//var failed: Bool?
if let queryItems = components.queryItems {
for queryItem in queryItems {
if let value = queryItem.value {
if queryItem.name == "playback" {
playback = Int(value)
} else if queryItem.name == "position" {
position = Double(value)
} else if queryItem.name == "duration" {
duration = Double(value)
} else if queryItem.name == "download" {
download = Float(value)
}
}
}
}
let _ = download
if let position = position {
if let ticksToIgnore = self.ignorePosition {
if ticksToIgnore > 1 {
self.ignorePosition = ticksToIgnore - 1
} else {
self.ignorePosition = nil
}
} else {
newTimestamp = Double(position)
}
}
if !self.ready {
self.ready = true
self.play()
}
if let updateStatus = self.updateStatus, let playback = playback, let duration = duration {
let playbackStatus: MediaPlayerPlaybackStatus
switch playback {
case 0:
playbackStatus = .paused
case 1:
playbackStatus = .playing
case 2:
playbackStatus = .paused
newTimestamp = 0.0
default:
playbackStatus = .buffering(initial: true, whilePlaying: false, progress: 0.0, display: true)
}
if case .playing = playbackStatus, !self.started {
self.started = true
Queue.mainQueue().after(0.5) {
self.onPlaybackStarted?()
}
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: Double(duration), dimensions: self.status.dimensions, timestamp: newTimestamp, baseRate: self.status.baseRate, seekId: self.status.seekId, status: playbackStatus, soundEnabled: true)
updateStatus(self.status)
}
}
}
}
}
@@ -0,0 +1,240 @@
import Foundation
import UIKit
import AsyncDisplayKit
import SwiftSignalKit
@preconcurrency import WebKit
import TelegramCore
import UniversalMediaPlayer
protocol WebEmbedImplementation {
func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void)
func play()
func pause()
func togglePlayPause()
func seek(timestamp: Double)
func setBaseRate(_ baseRate: Double)
func pageReady()
func callback(url: URL)
}
public enum WebEmbedType {
case youtube(videoId: String, timestamp: Int)
case vimeo(videoId: String, timestamp: Int)
case twitch(url: String)
case iframe(url: String)
public var supportsSeeking: Bool {
switch self {
case .youtube, .vimeo:
return true
default:
return false
}
}
}
public func webEmbedType(content: TelegramMediaWebpageLoadedContent, forcedTimestamp: Int? = nil) -> WebEmbedType {
if let (videoId, timestamp) = extractYoutubeVideoIdAndTimestamp(url: content.url) {
return .youtube(videoId: videoId, timestamp: forcedTimestamp ?? timestamp)
} else if let embedUrl = content.embedUrl, let (videoId, timestamp) = extractYoutubeVideoIdAndTimestamp(url: embedUrl) {
return .youtube(videoId: videoId, timestamp: forcedTimestamp ?? timestamp)
} else if let (videoId, timestamp) = extractVimeoVideoIdAndTimestamp(url: content.url) {
return .vimeo(videoId: videoId, timestamp: forcedTimestamp ?? timestamp)
} else if let embedUrl = content.embedUrl, isTwitchVideoUrl(embedUrl) && false {
return .twitch(url: embedUrl)
} else {
return .iframe(url: content.embedUrl ?? content.url)
}
}
func webEmbedImplementation(for type: WebEmbedType) -> WebEmbedImplementation {
switch type {
case let .youtube(videoId, timestamp):
return YoutubeEmbedImplementation(videoId: videoId, timestamp: timestamp)
case let .vimeo(videoId, timestamp):
return VimeoEmbedImplementation(videoId: videoId, timestamp: timestamp)
case let .twitch(url):
return TwitchEmbedImplementation(url: url)
case let .iframe(url):
return GenericEmbedImplementation(url: url)
}
}
final class WebEmbedPlayerNode: ASDisplayNode, WKNavigationDelegate {
private let statusValue = ValuePromise<MediaPlayerStatus>(MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true), ignoreRepeated: true)
var status: Signal<MediaPlayerStatus, NoError> {
return self.statusValue.get()
}
private var readyValue: ValuePromise<Bool> = ValuePromise<Bool>(false)
var ready: Signal<Bool, NoError> {
return self.readyValue.get()
}
let impl: WebEmbedImplementation
private let openUrl: (URL) -> Void
private let intrinsicDimensions: CGSize
private let webView: WKWebView
private let semaphore = DispatchSemaphore(value: 0)
private let queue = Queue()
init(impl: WebEmbedImplementation, intrinsicDimensions: CGSize, openUrl: @escaping (URL) -> Void) {
self.impl = impl
self.intrinsicDimensions = intrinsicDimensions
self.openUrl = openUrl
let userContentController = WKUserContentController()
if impl is YoutubeEmbedImplementation {
} else {
userContentController.addUserScript(WKUserScript(source: "var meta = document.createElement('meta'); meta.setAttribute('name', 'viewport'); meta.setAttribute('content', 'width=device-width'); document.getElementsByTagName('head')[0].appendChild(meta)", injectionTime: .atDocumentEnd, forMainFrameOnly: true))
}
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.userContentController = userContentController
if #available(iOSApplicationExtension 10.0, iOS 10.0, *) {
configuration.mediaTypesRequiringUserActionForPlayback = []
} else {
configuration.mediaPlaybackRequiresUserAction = false
}
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
configuration.allowsPictureInPictureMediaPlayback = false
}
let frame = CGRect(origin: CGPoint.zero, size: intrinsicDimensions)
self.webView = WKWebView(frame: frame, configuration: configuration)
super.init()
self.frame = frame
self.webView.navigationDelegate = self
self.webView.scrollView.isScrollEnabled = false
self.webView.allowsLinkPreview = false
self.webView.allowsBackForwardNavigationGestures = false
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.webView.accessibilityIgnoresInvertColors = true
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
}
self.view.addSubview(self.webView)
self.impl.setup(self.webView, userContentController: userContentController, evaluateJavaScript: { [weak self] js, completion in
self?.evaluateJavaScript(js: js, completion: completion)
}, updateStatus: { [weak self] status in
self?.statusValue.set(status)
}, onPlaybackStarted: { [weak self] in
self?.readyValue.set(true)
})
}
deinit {
let webView = self.webView
Queue.mainQueue().after(1.0) {
print(webView.debugDescription)
}
func disableGestures(view: UIView) {
if let recognizers = view.gestureRecognizers {
for recognizer in recognizers {
recognizer.isEnabled = false
}
}
for subview in view.subviews {
disableGestures(view: subview)
}
}
disableGestures(view: self.webView)
}
func play() {
self.impl.play()
}
func pause() {
self.impl.pause()
}
func togglePlayPause() {
self.impl.togglePlayPause()
}
func seek(timestamp: Double) {
self.impl.seek(timestamp: timestamp)
}
func setBaseRate(_ baseRate: Double) {
self.impl.setBaseRate(baseRate)
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.impl.pageReady()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if let error = error as? WKError, error.code.rawValue == 204 {
return
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let url = navigationAction.request.url, url.scheme == "embed" {
self.impl.callback(url: url)
decisionHandler(.cancel)
} else if let _ = navigationAction.targetFrame {
decisionHandler(.allow)
} else {
if let url = navigationAction.request.url, url.absoluteString.contains("youtube") {
self.openUrl(url)
}
decisionHandler(.cancel)
}
}
private func evaluateJavaScript(js: String, completion: ((Any?) -> Void)?) {
self.queue.async { [weak self] in
if let strongSelf = self {
let impl = {
strongSelf.webView.evaluateJavaScript(js, completionHandler: { (result, _) in
strongSelf.semaphore.signal()
if let completion = completion {
completion(result)
}
})
}
Queue.mainQueue().async(impl)
strongSelf.semaphore.wait()
}
}
}
func notifyPlaybackControlsHidden(_ hidden: Bool) {
if impl is YoutubeEmbedImplementation {
self.webView.isUserInteractionEnabled = !hidden
}
}
func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) {
}
func enterNativePictureInPicture() -> Bool {
return false
}
func exitNativePictureInPicture() {
}
func setNativePictureInPictureIsActive(_ value: Bool) {
}
}
@@ -0,0 +1,228 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramAudio
import UniversalMediaPlayer
import LegacyComponents
import AccountContext
import PhotoResources
import RangeSet
public final class WebEmbedVideoContent: UniversalVideoContent {
public let id: AnyHashable
let userLocation: MediaResourceUserLocation
let webPage: TelegramMediaWebpage
public let webpageContent: TelegramMediaWebpageLoadedContent
public let dimensions: CGSize
public let duration: Double
let forcedTimestamp: Int?
let openUrl: (URL) -> Void
public init?(userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, webpageContent: TelegramMediaWebpageLoadedContent, forcedTimestamp: Int? = nil, openUrl: @escaping (URL) -> Void) {
guard let embedUrl = webpageContent.embedUrl else {
return nil
}
self.id = AnyHashable(embedUrl)
self.userLocation = userLocation
self.webPage = webPage
self.webpageContent = webpageContent
self.dimensions = webpageContent.embedSize?.cgSize ?? CGSize(width: 128.0, height: 128.0)
self.duration = webpageContent.duration.flatMap(Double.init) ?? 0.0
self.forcedTimestamp = forcedTimestamp
self.openUrl = openUrl
}
public func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
return WebEmbedVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, webPage: self.webPage, webpageContent: self.webpageContent, forcedTimestamp: self.forcedTimestamp, openUrl: self.openUrl)
}
}
final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
private let webpageContent: TelegramMediaWebpageLoadedContent
private let intrinsicDimensions: CGSize
private let playbackCompletedListeners = Bag<() -> Void>()
private var initializedStatus = false
private let _status = Promise<MediaPlayerStatus>()
var status: Signal<MediaPlayerStatus, NoError> {
return self._status.get()
}
private let _bufferingStatus = Promise<(RangeSet<Int64>, Int64)?>()
var bufferingStatus: Signal<(RangeSet<Int64>, Int64)?, NoError> {
return self._bufferingStatus.get()
}
var isNativePictureInPictureActive: Signal<Bool, NoError> {
return .single(false)
}
private var seekId: Int = 0
private let _ready = Promise<Void>()
var ready: Signal<Void, NoError> {
return self._ready.get()
}
private let imageNode: TransformImageNode
private let playerNode: WebEmbedPlayerNode
var impl: WebEmbedImplementation {
return playerNode.impl
}
private var readyDisposable = MetaDisposable()
init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, webpageContent: TelegramMediaWebpageLoadedContent, forcedTimestamp: Int? = nil, openUrl: @escaping (URL) -> Void) {
self.webpageContent = webpageContent
if let embedSize = webpageContent.embedSize {
self.intrinsicDimensions = embedSize.cgSize
} else {
self.intrinsicDimensions = CGSize(width: 480.0, height: 320.0)
}
self.imageNode = TransformImageNode()
let embedType = webEmbedType(content: webpageContent, forcedTimestamp: forcedTimestamp)
let embedImpl = webEmbedImplementation(for: embedType)
self.playerNode = WebEmbedPlayerNode(impl: embedImpl, intrinsicDimensions: self.intrinsicDimensions, openUrl: openUrl)
super.init()
self.addSubnode(self.playerNode)
self.addSubnode(self.imageNode)
if let image = webpageContent.image {
self.imageNode.setSignal(chatMessagePhoto(postbox: postbox, userLocation: userLocation, photoReference: .webPage(webPage: WebpageReference(webPage), media: image)))
self.imageNode.imageUpdated = { [weak self] _ in
self?._ready.set(.single(Void()))
}
} else {
self._ready.set(.single(Void()))
}
self._status.set(self.playerNode.status)
self._bufferingStatus.set(.single(nil))
self.readyDisposable.set(self.playerNode.ready.start(next: { [weak self] ready in
if ready {
self?.imageNode.isHidden = true
}
}, error: { _ in }, completed: {}))
}
deinit {
self.readyDisposable.dispose()
}
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width)
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size))
if let image = webpageContent.image, let representation = image.representationForDisplayAtSize(PixelDimensions(self.intrinsicDimensions)) {
let makeImageLayout = self.imageNode.asyncLayout()
let applyImageLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: representation.dimensions.cgSize.aspectFilled(size), boundingSize: size, intrinsicInsets: UIEdgeInsets()))
applyImageLayout()
}
}
func play() {
assert(Queue.mainQueue().isCurrent())
self.playerNode.play()
}
func pause() {
assert(Queue.mainQueue().isCurrent())
self.playerNode.pause()
}
func togglePlayPause() {
assert(Queue.mainQueue().isCurrent())
self.playerNode.togglePlayPause()
}
func setSoundEnabled(_ value: Bool) {
assert(Queue.mainQueue().isCurrent())
}
func seek(_ timestamp: Double) {
assert(Queue.mainQueue().isCurrent())
self.seekId += 1
self.playerNode.seek(timestamp: timestamp)
}
func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
if case let .timecode(time) = seek {
self.playerNode.seek(timestamp: time)
} else {
self.playerNode.play()
}
}
func setSoundMuted(soundMuted: Bool) {
}
func continueWithOverridingAmbientMode(isAmbient: Bool) {
}
func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) {
}
func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
}
func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) {
}
func setBaseRate(_ baseRate: Double) {
self.playerNode.setBaseRate(baseRate)
}
func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) {
}
func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? {
return nil
}
func videoQualityStateSignal() -> Signal<(current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])?, NoError> {
return .single(nil)
}
func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int {
return self.playbackCompletedListeners.add(f)
}
func removePlaybackCompleted(_ index: Int) {
self.playbackCompletedListeners.remove(index)
}
func fetchControl(_ control: UniversalVideoNodeFetchControl) {
}
func notifyPlaybackControlsHidden(_ hidden: Bool) {
self.playerNode.notifyPlaybackControlsHidden(hidden)
}
func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) {
}
func enterNativePictureInPicture() -> Bool {
return false
}
func exitNativePictureInPicture() {
}
func setNativePictureInPictureIsActive(_ value: Bool) {
}
}
@@ -0,0 +1,76 @@
import Foundation
import UIKit
@preconcurrency import WebKit
import SwiftSignalKit
private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
private let f: (WKScriptMessage) -> ()
init(_ f: @escaping (WKScriptMessage) -> ()) {
self.f = f
super.init()
}
func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) {
self.f(scriptMessage)
}
}
final class WebViewHLSJSContextImpl: HLSJSContext {
let webView: WKWebView
init(handleScriptMessage: @escaping ([String: Any]) -> Void) {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
config.allowsPictureInPictureMediaPlayback = true
let userController = WKUserContentController()
var handleScriptMessageImpl: (([String: Any]) -> Void)?
userController.add(WeakScriptMessageHandler { message in
guard let body = message.body as? [String: Any] else {
return
}
handleScriptMessageImpl?(body)
}, name: "performAction")
let isDebug: Bool
#if DEBUG
isDebug = true
#else
isDebug = false
#endif
config.userContentController = userController
let webView = WKWebView(frame: CGRect(origin: CGPoint(), size: CGSize(width: 100.0, height: 100.0)), configuration: config)
self.webView = webView
webView.scrollView.isScrollEnabled = false
webView.allowsLinkPreview = false
webView.allowsBackForwardNavigationGestures = false
webView.accessibilityIgnoresInvertColors = true
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.alpha = 0.0
if #available(iOS 16.4, *) {
webView.isInspectable = isDebug
}
handleScriptMessageImpl = { message in
Queue.mainQueue().async {
handleScriptMessage(message)
}
}
let bundle = Bundle(for: WebViewHLSJSContextImpl.self)
let bundlePath = bundle.bundlePath + "/HlsBundle.bundle"
webView.loadFileURL(URL(fileURLWithPath: bundlePath + "/index.html"), allowingReadAccessTo: URL(fileURLWithPath: bundlePath))
}
func evaluateJavaScript(_ string: String) {
self.webView.evaluateJavaScript(string, completionHandler: nil)
}
}
@@ -0,0 +1,256 @@
import Foundation
import UIKit
import JavaScriptCore
import TelegramCore
import SwiftSignalKit
private var ObjCKey_ContextReference: Int?
@objc private protocol JsCorePolyfillsExport: JSExport {
func postMessage(_ object: JSValue)
func consoleLog(_ object: JSValue)
func consoleLog(_ object: JSValue, _ arg1: JSValue)
func consoleLog(_ object: JSValue, _ arg1: JSValue, _ arg2: JSValue)
func performanceNow() -> Double
}
@objc private final class JsCorePolyfills: NSObject, JsCorePolyfillsExport {
private let queue: Queue
private let context: WebViewNativeJSContextImpl.Reference
init(queue: Queue, context: WebViewNativeJSContextImpl.Reference) {
self.queue = queue
self.context = context
super.init()
}
@objc func postMessage(_ object: JSValue) {
guard object.isObject else {
return
}
guard let message = object.toDictionary() as? [String: Any] else {
return
}
let context = self.context
self.queue.async {
guard let context = context.context else {
return
}
let handleScriptMessage = context.handleScriptMessage
Queue.mainQueue().async {
handleScriptMessage(message)
}
}
}
@objc func consoleLog(_ object: JSValue) {
#if DEBUG
print("\(object)")
#endif
}
@objc func consoleLog(_ object: JSValue, _ arg1: JSValue) {
#if DEBUG
print("\(object) \(arg1)")
#endif
}
@objc func consoleLog(_ object: JSValue, _ arg1: JSValue, _ arg2: JSValue) {
#if DEBUG
print("\(object) \(arg1) \(arg2)")
#endif
}
@objc func performanceNow() -> Double {
return CFAbsoluteTimeGetCurrent()
}
}
@objc private protocol TimerJSExport: JSExport {
func setTimeout(_ callback: JSValue, _ ms: Double) -> Int32
func setInterval(_ callback: JSValue, _ ms: Double) -> Int32
func clearTimeout(_ id: Int32)
}
@objc private class TimeoutPolyfill: NSObject, TimerJSExport {
private let queue: Queue
private var timers: [Int32: SwiftSignalKit.Timer] = [:]
private var nextId: Int32 = 0
init(queue: Queue) {
self.queue = queue
}
deinit {
self.cleanup()
}
func cleanup() {
for (_, timer) in self.timers {
timer.invalidate()
}
self.timers.removeAll()
}
func register(jsContext: JSContext) {
jsContext.evaluateScript("""
function setTimeout(...args) {
if (args.length === 0) {
return -1;
}
const [callback, delay = 0, ...callbackArgs] = args;
return _timeoutPolyfill.setTimeout(() => {
callback(...callbackArgs);
}, delay);
}
function setInterval(...args) {
if (args.length === 0) {
return -1;
}
const [callback, delay = 0, ...callbackArgs] = args;
return _timeoutPolyfill.setInterval(() => {
callback(...callbackArgs);
}, delay);
}
function clearTimeout(indentifier) {
_timeoutPolyfill.clearTimeout(indentifier)
}
function clearInterval(indentifier) {
_timeoutPolyfill.clearTimeout(indentifier)
}
"""
)
}
func clearTimeout(_ id: Int32) {
let timer = self.timers.removeValue(forKey: id)
timer?.invalidate()
}
func setTimeout(_ callback: JSValue, _ ms: Double) -> Int32 {
return self.createTimer(callback: callback, ms: ms, repeats: false)
}
func setInterval(_ callback: JSValue, _ ms: Double) -> Int32 {
return self.createTimer(callback: callback, ms: ms, repeats: true)
}
func createTimer(callback: JSValue, ms: Double, repeats: Bool) -> Int32 {
let timeInterval = ms / 1000.0
let id = self.nextId
self.nextId += 1
let timer = SwiftSignalKit.Timer(timeout: timeInterval, repeat: repeats, completion: { [weak self] in
guard let self else {
return
}
callback.call(withArguments: nil)
if !repeats {
self.timers.removeValue(forKey: id)
}
}, queue: self.queue)
self.timers[id] = timer
timer.start()
return id
}
}
final class WebViewNativeJSContextImpl: HLSJSContext {
fileprivate final class Reference {
weak var context: WebViewNativeJSContextImpl.Impl?
init(context: WebViewNativeJSContextImpl.Impl) {
self.context = context
}
}
fileprivate final class Impl {
let queue: Queue
let context: JSContext
let timeoutPolyfill: TimeoutPolyfill
let handleScriptMessage: ([String: Any]) -> Void
init(queue: Queue, handleScriptMessage: @escaping ([String: Any]) -> Void) {
self.queue = queue
self.context = JSContext()
self.handleScriptMessage = handleScriptMessage
self.timeoutPolyfill = TimeoutPolyfill(queue: self.queue)
#if DEBUG
if #available(iOS 16.4, *) {
self.context.isInspectable = true
}
#endif
self.context.exceptionHandler = { context, exception in
if let exception {
Logger.shared.log("WebViewNativeJSContextImpl", "JS exception: \(exception)")
#if DEBUG
print("JS exception: \(exception)")
#endif
}
}
self.context.setObject(self.timeoutPolyfill, forKeyedSubscript: "_timeoutPolyfill" as (NSCopying & NSObjectProtocol))
self.timeoutPolyfill.register(jsContext: self.context)
self.context.setObject(JsCorePolyfills(queue: self.queue, context: Reference(context: self)), forKeyedSubscript: "_JsCorePolyfills" as (NSCopying & NSObjectProtocol))
let bundle = Bundle(for: WebViewHLSJSContextImpl.self)
let bundlePath = bundle.bundlePath + "/HlsBundle.bundle"
if let indexJsString = try? String(contentsOf: URL(fileURLWithPath: bundlePath + "/headless_prologue.js"), encoding: .utf8) {
self.context.evaluateScript(indexJsString, withSourceURL: URL(fileURLWithPath: "index/index.bundle.js"))
} else {
assertionFailure()
}
if let indexJsString = try? String(contentsOf: URL(fileURLWithPath: bundlePath + "/index.bundle.js"), encoding: .utf8) {
self.context.evaluateScript(indexJsString, withSourceURL: URL(fileURLWithPath: "index.bundle.js"))
} else {
assertionFailure()
}
}
deinit {
self.timeoutPolyfill.cleanup()
print("WebViewNativeJSContextImpl.deinit")
}
func evaluateJavaScript(_ string: String) {
self.context.evaluateScript(string)
}
}
static let sharedQueue = Queue(name: "WebViewNativeJSContextImpl", qos: .default)
private let queue: Queue
private let impl: QueueLocalObject<Impl>
init(handleScriptMessage: @escaping ([String: Any]) -> Void) {
let queue = WebViewNativeJSContextImpl.sharedQueue
self.queue = queue
self.impl = QueueLocalObject(queue: queue, generate: {
return Impl(queue: queue, handleScriptMessage: handleScriptMessage)
})
}
func evaluateJavaScript(_ string: String) {
self.impl.with { impl in
impl.evaluateJavaScript(string)
}
}
}
@@ -0,0 +1,679 @@
import Foundation
import Display
import Postbox
import TelegramCore
import AccountContext
import WebKit
import SwiftSignalKit
import UniversalMediaPlayer
import AppBundle
func extractYoutubeVideoIdAndTimestamp(url: String) -> (String, Int)? {
guard let url = URL(string: url), let host = url.host?.lowercased() else {
return nil
}
let match = ["youtube.com", "youtu.be"].contains(where: { (domain) -> Bool in
return host == domain || host.contains(".\(domain)")
})
guard match else {
return nil
}
var videoId: String?
var timestamp = 0
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
if let queryItems = components.queryItems {
for queryItem in queryItems {
if let value = queryItem.value {
if queryItem.name == "v" {
videoId = value
} else if queryItem.name == "t" || queryItem.name == "time_continue" {
if value.contains("s") {
var range = value.startIndex..<value.endIndex
if let hoursRange = value.range(of: "h", options: .caseInsensitive, range: range, locale: nil) {
let subvalue = String(value[range.lowerBound ..< hoursRange.lowerBound])
if let hours = Int(subvalue) {
timestamp = timestamp + hours * 3600
}
range = hoursRange.upperBound..<value.endIndex
}
if let minutesRange = value.range(of: "m", options: .caseInsensitive, range: range, locale: nil) {
let subvalue = String(value[range.lowerBound ..< minutesRange.lowerBound])
if let minutes = Int(subvalue) {
timestamp = timestamp + minutes * 60
}
range = minutesRange.upperBound..<value.endIndex
}
if let secondsRange = value.range(of: "s", options: .caseInsensitive, range: range, locale: nil) {
let subvalue = String(value[range.lowerBound ..< secondsRange.lowerBound])
if let seconds = Int(subvalue) {
timestamp = timestamp + seconds
}
}
} else {
if let seconds = Int(value) {
timestamp = seconds
}
}
}
}
}
}
if videoId == nil {
let pathComponents = components.path.components(separatedBy: "/")
var nextComponentIsVideoId = host.contains("youtu.be")
for component in pathComponents {
if component.count > 0 && nextComponentIsVideoId {
videoId = component
break
} else if component == "embed" {
nextComponentIsVideoId = true
}
}
}
}
if let videoId = videoId {
return (videoId, timestamp)
}
return nil
}
final class YoutubeEmbedImplementation: WebEmbedImplementation {
private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)?
private var updateStatus: ((MediaPlayerStatus) -> Void)?
private var onPlaybackStarted: (() -> Void)?
fileprivate let videoId: String
fileprivate var storyboardSpec: String?
fileprivate var duration: Double {
return self.status.duration
}
private var timestamp: Int
private var baseRate: Double = 1.0
private var ignoreEarlierTimestamps = false
private var status: MediaPlayerStatus
private var ready = false
private var started = false
private var ignorePosition: Int?
private var isPlaying = true
private enum PlaybackDelay {
case none
case afterPositionUpdates(count: Int)
}
private var playbackDelay = PlaybackDelay.none
private let benchmarkStartTime: CFAbsoluteTime
init(videoId: String, timestamp: Int = 0) {
self.videoId = videoId
self.timestamp = timestamp
if self.timestamp > 0 {
self.ignoreEarlierTimestamps = true
}
self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: Double(timestamp), baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)
self.benchmarkStartTime = CFAbsoluteTimeGetCurrent()
}
func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) {
let bundle = getAppBundle()
guard let userScriptPath = bundle.path(forResource: "YoutubeUserScript", ofType: "js") else {
return
}
guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else {
return
}
guard let userScript = String(data: userScriptData, encoding: .utf8) else {
return
}
guard let htmlTemplatePath = bundle.path(forResource: "Youtube", ofType: "html") else {
return
}
guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else {
return
}
guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else {
return
}
let params: [String : Any] = [ "videoId": self.videoId,
"width": "100%",
"height": "100%",
"events": [ "onReady": "onReady",
"onStateChange": "onStateChange",
"onPlaybackQualityChange": "onPlaybackQualityChange",
"onError": "onPlayerError" ],
"playerVars": [ "cc_load_policy": 1,
"iv_load_policy": 3,
"controls": 0,
"playsinline": 1,
"autohide": 1,
"showinfo": 0,
"rel": 0,
"modestbranding": 1,
"start": self.timestamp ] ]
guard let paramsJsonData = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted), let paramsJson = String(data: paramsJsonData, encoding: .utf8) else {
return
}
self.evalImpl = evaluateJavaScript
self.updateStatus = updateStatus
self.onPlaybackStarted = onPlaybackStarted
updateStatus(self.status)
let html = String(format: htmlTemplate, paramsJson)
webView.loadHTMLString(html, baseURL: URL(string: "https://ph.telegra.Telegraph"))
// webView.isUserInteractionEnabled = false
userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
func play() {
guard self.ready else {
self.playbackDelay = .afterPositionUpdates(count: 2)
return
}
self.isPlaying = true
if let eval = self.evalImpl {
eval("play();", nil)
}
self.ignorePosition = 2
}
func pause() {
self.isPlaying = false
if let eval = self.evalImpl {
eval("pause();", nil)
}
}
func togglePlayPause() {
if case .playing = self.status.status {
self.pause()
} else {
self.play()
}
}
func seek(timestamp: Double) {
if !self.ready {
self.timestamp = Int(timestamp)
self.ignoreEarlierTimestamps = true
}
if let eval = self.evalImpl {
eval("seek(\(timestamp));", nil)
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, baseRate: self.status.baseRate, seekId: self.status.seekId + 1, status: self.status.status, soundEnabled: true)
self.updateStatus?(self.status)
self.ignorePosition = 2
}
func setBaseRate(_ baseRate: Double) {
var baseRate = baseRate
if baseRate < 0.5 {
baseRate = 0.5
}
if baseRate > 2.0 {
baseRate = 2.0
}
if !self.ready {
self.baseRate = baseRate
}
if let eval = self.evalImpl {
eval("setRate(\(baseRate));", nil)
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: self.status.timestamp, baseRate: baseRate, seekId: self.status.seekId + 1, status: self.status.status, soundEnabled: true)
self.updateStatus?(self.status)
}
func pageReady() {
}
func callback(url: URL) {
switch url.host {
case "onState":
var newTimestamp = self.status.timestamp
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
var playback: Int?
var position: Double?
var duration: Int?
var download: Float?
var failed: Bool?
if let queryItems = components.queryItems {
for queryItem in queryItems {
if let value = queryItem.value {
if queryItem.name == "playback" {
playback = Int(value)
} else if queryItem.name == "position" {
position = Double(value)
} else if queryItem.name == "duration" {
duration = Int(value)
} else if queryItem.name == "download" {
download = Float(value)
} else if queryItem.name == "failed" {
failed = Bool(value)
} else if queryItem.name == "storyboard" {
let urlString = url.absoluteString
if value.count > 10, let range = urlString.range(of: "storyboard=") {
self.storyboardSpec = String(urlString[range.upperBound..<urlString.endIndex]).removingPercentEncoding
}
}
}
}
}
let _ = download
if let position = position {
if self.ignoreEarlierTimestamps {
if position >= Double(self.timestamp) {
self.ignoreEarlierTimestamps = false
newTimestamp = Double(position)
}
} else if let ticksToIgnore = self.ignorePosition {
if ticksToIgnore > 1 {
self.ignorePosition = ticksToIgnore - 1
} else {
self.ignorePosition = nil
}
} else {
newTimestamp = Double(position)
}
}
if let updateStatus = self.updateStatus, let playback = playback, let duration = duration {
if let failed, failed {
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: 0.0, dimensions: self.status.dimensions, timestamp: 0.0, baseRate: 0.0, seekId: self.status.seekId, status: .playing, soundEnabled: false)
updateStatus(self.status)
self.onPlaybackStarted?()
} else {
let playbackStatus: MediaPlayerPlaybackStatus
switch playback {
case 0:
if newTimestamp > Double(duration) - 1.0 {
self.isPlaying = false
playbackStatus = .paused
newTimestamp = 0.0
} else {
playbackStatus = .buffering(initial: false, whilePlaying: true, progress: 0.0, display: false)
}
case 1:
playbackStatus = .playing
case 2:
playbackStatus = .paused
case 3:
playbackStatus = .buffering(initial: !self.started, whilePlaying: self.isPlaying, progress: 0.0, display: false)
default:
playbackStatus = .buffering(initial: true, whilePlaying: true, progress: 0.0, display: false)
}
if case .playing = playbackStatus, !self.started {
self.started = true
print("YT started in \(CFAbsoluteTimeGetCurrent() - self.benchmarkStartTime)")
self.onPlaybackStarted?()
}
self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: Double(duration), dimensions: self.status.dimensions, timestamp: newTimestamp, baseRate: self.status.baseRate, seekId: self.status.seekId, status: playbackStatus, soundEnabled: true)
updateStatus(self.status)
}
}
}
if case let .afterPositionUpdates(count) = self.playbackDelay {
if count == 1 {
self.ready = true
self.playbackDelay = .none
self.play()
} else {
self.playbackDelay = .afterPositionUpdates(count: count - 1)
}
}
case "onReady":
self.ready = true
if case .afterPositionUpdates(_) = self.playbackDelay {
self.playbackDelay = .none
self.play()
}
if self.baseRate != 1.0 {
self.setBaseRate(self.baseRate)
}
print("YT ready in \(CFAbsoluteTimeGetCurrent() - self.benchmarkStartTime)")
Queue.mainQueue().async {
let delay = self.timestamp > 0 ? 2.8 : 2.0
if self.timestamp > 0 {
self.seek(timestamp: Double(self.timestamp))
self.play()
} else {
self.play()
}
Queue.mainQueue().after(delay, {
if !self.started {
self.play()
}
self.onPlaybackStarted?()
})
}
default:
break
}
}
}
public struct YoutubeEmbedStoryboardMediaResourceId {
public let videoId: String
public let storyboardId: Int32
public var uniqueId: String {
return "youtube-storyboard-\(self.videoId)-\(self.storyboardId)"
}
public var hashValue: Int {
return self.uniqueId.hashValue
}
}
public class YoutubeEmbedStoryboardMediaResource: TelegramMediaResource {
public let videoId: String
public let storyboardId: Int32
public let url: String
public var size: Int64? {
return nil
}
public init(videoId: String, storyboardId: Int32, url: String) {
self.videoId = videoId
self.storyboardId = storyboardId
self.url = url
}
public required init(decoder: PostboxDecoder) {
self.videoId = decoder.decodeStringForKey("v", orElse: "")
self.storyboardId = decoder.decodeInt32ForKey("i", orElse: 0)
self.url = decoder.decodeStringForKey("u", orElse: "")
}
public func encode(_ encoder: PostboxEncoder) {
encoder.encodeString(self.videoId, forKey: "v")
encoder.encodeInt32(self.storyboardId, forKey: "i")
encoder.encodeString(self.url, forKey: "u")
}
public var id: MediaResourceId {
return MediaResourceId(YoutubeEmbedStoryboardMediaResourceId(videoId: self.videoId, storyboardId: self.storyboardId).uniqueId)
}
public func isEqual(to: MediaResource) -> Bool {
if let to = to as? YoutubeEmbedStoryboardMediaResource {
return self.videoId == to.videoId && self.storyboardId == to.storyboardId && self.url == to.url
} else {
return false
}
}
}
public final class YoutubeEmbedStoryboardMediaResourceRepresentation: CachedMediaResourceRepresentation {
public let keepDuration: CachedMediaRepresentationKeepDuration = .shortLived
public var uniqueId: String {
return "cached"
}
public init() {
}
public func isEqual(to: CachedMediaResourceRepresentation) -> Bool {
if to is YoutubeEmbedStoryboardMediaResourceRepresentation {
return true
} else {
return false
}
}
}
public func fetchYoutubeEmbedStoryboardResource(resource: YoutubeEmbedStoryboardMediaResource) -> Signal<CachedMediaResourceRepresentationResult, NoError> {
return Signal { subscriber in
subscriber.putNext(.reset)
let disposable = MetaDisposable()
disposable.set(fetchHttpResource(url: resource.url).start(next: { next in
if case let .dataPart(_, data, _, complete) = next, complete {
let tempFile = TempBox.shared.tempFile(fileName: "image.jpg")
if let _ = try? data.write(to: URL(fileURLWithPath: tempFile.path), options: .atomic) {
subscriber.putNext(.tempFile(tempFile))
subscriber.putCompletion()
}
}
}))
return ActionDisposable {
disposable.dispose()
}
}
}
private func youtubeEmbedStoryboardData(account: Account, resource: YoutubeEmbedStoryboardMediaResource) -> Signal<Data?, NoError> {
return Signal<Data?, NoError> { subscriber in
let dataDisposable = account.postbox.mediaBox.cachedResourceRepresentation(resource, representation: YoutubeEmbedStoryboardMediaResourceRepresentation(), complete: true).start(next: { next in
if next.size != 0 {
subscriber.putNext(next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: []))
}
}, error: subscriber.putError, completed: subscriber.putCompletion)
return ActionDisposable {
dataDisposable.dispose()
}
}
}
private func youtubeEmbedStoryboardImage(account: Account, resource: YoutubeEmbedStoryboardMediaResource, frame: Int32, size: YoutubeEmbedFramePreview.StoryboardSpec.StoryboardSize) -> Signal<UIImage?, NoError> {
let signal = youtubeEmbedStoryboardData(account: account, resource: resource)
return signal |> map { fullSizeData in
let drawingSize = CGSize(width: CGFloat(size.width), height: CGFloat(size.height))
guard let context = DrawingContext(size: drawingSize, clear: true) else {
return nil
}
var fullSizeImage: CGImage?
if let fullSizeData = fullSizeData {
let options = NSMutableDictionary()
options[kCGImageSourceShouldCache as NSString] = false as NSNumber
if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) {
fullSizeImage = image
}
if let fullSizeImage = fullSizeImage {
let rect: CGRect
let imageSize = CGSize(width: CGFloat(fullSizeImage.width), height: CGFloat(fullSizeImage.height))
let row = floor(CGFloat(frame) / CGFloat(size.cols))
let col = CGFloat(frame % size.cols)
rect = CGRect(origin: CGPoint(x: -drawingSize.width * col, y: -drawingSize.height * row), size: imageSize)
context.withFlippedContext { c in
c.setBlendMode(.copy)
c.interpolationQuality = .medium
c.draw(fullSizeImage, in: rect)
}
return context.generateImage()
}
}
return nil
}
}
public final class YoutubeEmbedFramePreview: FramePreview {
fileprivate struct StoryboardSpec {
struct StoryboardSize {
let width: Int32
let height: Int32
let quality: Int32
let cols: Int32
let rows: Int32
let duration: Int32
let imageName: String
let sigh: String
}
let baseUrl: String
let sizes: [StoryboardSize]
init?(specString: String) {
let sections = specString.components(separatedBy: "|")
if sections.count < 2 {
return nil
}
guard let baseUrl = sections.first else {
return nil
}
self.baseUrl = baseUrl
var sizes: [StoryboardSize] = []
for i in 1 ..< sections.count - 1 {
let section = sections[i]
let data = section.components(separatedBy: "#")
if data.count >= 8, let width = Int32(data[0]), let height = Int32(data[1]), let quality = Int32(data[2]), let cols = Int32(data[3]), let rows = Int32(data[4]), let duration = Int32(data[5]) {
let size = StoryboardSize(width: width, height: height, quality: quality, cols: cols, rows: rows, duration: duration, imageName: data[6], sigh: data[7])
sizes.append(size)
}
}
self.sizes = sizes
}
var bestSize: (Int, StoryboardSize)? {
var best: (Int, StoryboardSize)?
for i in 0 ..< self.sizes.count {
let size = self.sizes[i]
if let (_, currentBest) = best {
if currentBest.width < size.width || (currentBest.width == size.width && currentBest.cols < size.cols) {
best = (i, size)
}
} else {
best = (i, size)
}
}
return best
}
}
private func storyboardUrl(spec: StoryboardSpec, sizeIndex: Int, num: Int32) -> String {
let size = spec.sizes[sizeIndex]
var url = spec.baseUrl
url = url.replacingOccurrences(of: "$L", with: "\(sizeIndex)")
url = url.replacingOccurrences(of: "$N", with: size.imageName)
url = url.replacingOccurrences(of: "$M", with: "\(num)")
url += "&sigh=\(size.sigh)"
return url
}
private let context: AccountContext
private weak var content: WebEmbedVideoContent?
private let currentFrameDisposable = MetaDisposable()
private var currentFrameTimestamp: Double?
private var nextFrameTimestamp: Double?
fileprivate let framePipe = ValuePipe<FramePreviewResult>()
public init(context: AccountContext, content: WebEmbedVideoContent) {
self.context = context
self.content = content
}
deinit {
self.currentFrameDisposable.dispose()
}
public var generatedFrames: Signal<FramePreviewResult, NoError> {
return self.framePipe.signal()
}
public func generateFrame(at timestamp: Double) {
guard let content = self.content else {
return
}
if self.currentFrameTimestamp != nil {
self.nextFrameTimestamp = timestamp
return
}
self.currentFrameTimestamp = timestamp
self.context.sharedContext.mediaManager.universalVideoManager.withUniversalVideoContent(id: content.id) { [weak self] node in
guard let strongSelf = self, let node = node as? WebEmbedVideoContentNode, let youtubeImpl = node.impl as? YoutubeEmbedImplementation, youtubeImpl.duration > 0.0, let specString = youtubeImpl.storyboardSpec, let storyboardSpec = StoryboardSpec(specString: specString), let bestSize = storyboardSpec.bestSize else {
return
}
var duration: Double = Double(bestSize.1.duration) / 1000.0
var totalFrames: Int32 = 1
let framesOnStoryboard: Int32 = bestSize.1.cols * bestSize.1.rows
if duration > 0.0 {
totalFrames = Int32(ceil(youtubeImpl.duration / duration))
} else {
duration = youtubeImpl.duration / Double(framesOnStoryboard)
}
let globalFrame = Int32(floor(timestamp / youtubeImpl.duration * Double(totalFrames)))
let frame: Int32 = globalFrame % framesOnStoryboard
let num: Int32 = Int32(floor(Double(globalFrame) / Double(framesOnStoryboard)))
let url = strongSelf.storyboardUrl(spec: storyboardSpec, sizeIndex: bestSize.0, num: num)
strongSelf.framePipe.putNext(.waitingForData)
strongSelf.currentFrameDisposable.set(youtubeEmbedStoryboardImage(account: strongSelf.context.account, resource: YoutubeEmbedStoryboardMediaResource(videoId: youtubeImpl.videoId, storyboardId: num, url: url), frame: frame, size: bestSize.1).start(next: { image in
if let strongSelf = self {
if let image = image {
strongSelf.framePipe.putNext(.image(image))
}
strongSelf.currentFrameTimestamp = nil
if let nextFrameTimestamp = strongSelf.nextFrameTimestamp {
strongSelf.nextFrameTimestamp = nil
strongSelf.generateFrame(at: nextFrameTimestamp)
}
}
}))
}
}
public func cancelPendingFrames() {
self.nextFrameTimestamp = nil
self.currentFrameTimestamp = nil
self.currentFrameDisposable.set(nil)
}
}