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,77 @@
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 = "CallScreenMetalSources",
srcs = glob([
"Metal/**/*.metal",
]),
visibility = ["//visibility:public"],
)
plist_fragment(
name = "CallScreenMetalSourcesBundleInfoPlist",
extension = "plist",
template =
"""
<key>CFBundleIdentifier</key>
<string>org.telegram.CallScreenMetalSources</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleName</key>
<string>CallScreen</string>
"""
)
apple_resource_bundle(
name = "CallScreenMetalSourcesBundle",
infoplists = [
":CallScreenMetalSourcesBundleInfoPlist",
],
resources = [
":CallScreenMetalSources",
],
)
filegroup(
name = "Assets",
srcs = glob(["CallScreenAssets.xcassets/**"]),
visibility = ["//visibility:public"],
)
swift_library(
name = "CallScreen",
module_name = "CallScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
data = [
":CallScreenMetalSourcesBundle",
":Assets",
],
deps = [
"//submodules/Display",
"//submodules/MetalEngine",
"//submodules/ComponentFlow",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/TelegramUI/Components/AnimatedTextComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/AppBundle",
"//submodules/UIKitRuntimeUtils",
"//submodules/TelegramPresentationData",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_call_audioairpods.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_call_audioairpodspro.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_call_airpodsmax.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_call_audiobt.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "locksettings (1).pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,123 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.000000 0.000000 0.000000 scn
0.000000 3.800000 m
0.000000 4.920105 0.000000 5.480157 0.217987 5.907981 c
0.409734 6.284305 0.715695 6.590266 1.092019 6.782013 c
1.519843 7.000000 2.079895 7.000000 3.200000 7.000000 c
5.800000 7.000000 l
6.920105 7.000000 7.480157 7.000000 7.907981 6.782013 c
8.284306 6.590266 8.590266 6.284305 8.782013 5.907981 c
9.000000 5.480157 9.000000 4.920105 9.000000 3.800000 c
9.000000 3.200000 l
9.000000 2.079895 9.000000 1.519843 8.782013 1.092019 c
8.590266 0.715695 8.284306 0.409734 7.907981 0.217987 c
7.480157 0.000000 6.920105 0.000000 5.800000 0.000000 c
3.200000 0.000000 l
2.079895 0.000000 1.519843 0.000000 1.092019 0.217987 c
0.715695 0.409734 0.409734 0.715695 0.217987 1.092019 c
0.000000 1.519843 0.000000 2.079895 0.000000 3.200000 c
0.000000 3.800000 l
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 2.000000 2.400391 cm
0.000000 0.000000 0.000000 scn
4.200000 1.599609 m
4.200000 1.157782 4.558172 0.799609 5.000000 0.799609 c
5.441828 0.799609 5.800000 1.157782 5.800000 1.599609 c
4.200000 1.599609 l
h
-0.800000 1.599609 m
-0.800000 1.157782 -0.441828 0.799609 -0.000000 0.799609 c
0.441828 0.799609 0.800000 1.157782 0.800000 1.599609 c
-0.800000 1.599609 l
h
4.200000 6.099609 m
4.200000 1.599609 l
5.800000 1.599609 l
5.800000 6.099609 l
4.200000 6.099609 l
h
0.800000 1.599609 m
0.800000 6.099609 l
-0.800000 6.099609 l
-0.800000 1.599609 l
0.800000 1.599609 l
h
2.500000 7.799609 m
3.438884 7.799609 4.200000 7.038493 4.200000 6.099609 c
5.800000 6.099609 l
5.800000 7.922149 4.322540 9.399610 2.500000 9.399610 c
2.500000 7.799609 l
h
2.500000 9.399610 m
0.677460 9.399610 -0.800000 7.922149 -0.800000 6.099609 c
0.800000 6.099609 l
0.800000 7.038493 1.561116 7.799609 2.500000 7.799609 c
2.500000 9.399610 l
h
f
n
Q
endstream
endobj
3 0 obj
1865
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 9.000000 12.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Pages 5 0 R
/Type /Catalog
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000001955 00000 n
0000001978 00000 n
0000002150 00000 n
0000002224 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
2283
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "CallCancelIcon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "CallCancelIcon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_flip (1).pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_call_microphone.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Snow.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_call_speaker.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_call_camera.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "smoothGradient 0.4.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,395 @@
#include <metal_stdlib>
using namespace metal;
struct Rectangle {
float2 origin;
float2 size;
};
constant static float2 quadVertices[6] = {
float2(0.0, 0.0),
float2(1.0, 0.0),
float2(0.0, 1.0),
float2(1.0, 0.0),
float2(0.0, 1.0),
float2(1.0, 1.0)
};
struct QuadVertexOut {
float4 position [[position]];
float2 uv;
};
vertex QuadVertexOut callBackgroundVertex(
const device Rectangle &rect [[ buffer(0) ]],
unsigned int vid [[ vertex_id ]]
) {
float2 quadVertex = quadVertices[vid];
QuadVertexOut out;
out.position = float4(rect.origin.x + quadVertex.x * rect.size.x, rect.origin.y + quadVertex.y * rect.size.y, 0.0, 1.0);
out.position.x = -1.0 + out.position.x * 2.0;
out.position.y = -1.0 + out.position.y * 2.0;
out.uv = quadVertex;
return out;
}
half4 rgb2hsv(half4 c) {
half4 K = half4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
half4 p = mix(half4(c.bg, K.wz), half4(c.gb, K.xy), step(c.b, c.g));
half4 q = mix(half4(p.xyw, c.r), half4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return half4(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x, c.a);
}
half4 hsv2rgb(half4 c) {
half4 K = half4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
half3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return half4(c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y), c.a);
}
fragment half4 callBackgroundFragment(
QuadVertexOut in [[stage_in]],
const device float2 *positions [[ buffer(0) ]],
const device float4 *colors [[ buffer(1) ]],
const device float &brightness [[ buffer(2) ]],
const device float &saturation [[ buffer(3) ]],
const device float4 &overlay [[ buffer(4) ]]
) {
half centerDistanceX = in.uv.x - 0.5;
half centerDistanceY = in.uv.y - 0.5;
half centerDistance = distance(half2(in.uv), half2(0.5, 0.5));
half swirlFactor = 0.35 * centerDistance;
half theta = swirlFactor * swirlFactor * 0.8 * 8.0;
half sinTheta = sin(theta);
half cosTheta = cos(theta);
half pixelX = max(0.0, min(1.0, 0.5 + centerDistanceX * cosTheta - centerDistanceY * sinTheta));
half pixelY = max(0.0, min(1.0, 0.5 + centerDistanceX * sinTheta + centerDistanceY * cosTheta));
half distanceSum = 0.0;
half r = 0.0;
half g = 0.0;
half b = 0.0;
for (int i = 0; i < 4; i++) {
half4 color = half4(colors[i]);
half2 colorXY = half2(positions[i]);
half2 distanceXY = half2(pixelX - colorXY.x, pixelY - colorXY.y);
half distance = max(0.0, 0.92 - sqrt(distanceXY.x * distanceXY.x + distanceXY.y * distanceXY.y));
distance = distance * distance * distance;
distanceSum += distance;
r = r + distance * color.r;
g = g + distance * color.g;
b = b + distance * color.b;
}
if (distanceSum < 0.00001) {
distanceSum = 0.00001;
}
half pixelB = b / distanceSum;
half pixelG = g / distanceSum;
half pixelR = r / distanceSum;
half4 color(pixelR, pixelG, pixelB, 1.0);
color = rgb2hsv(color);
color.b = clamp(color.b * brightness, 0.0, 1.0);
color.g = clamp(color.g * saturation, 0.0, 1.0);
color = hsv2rgb(color);
color.rgb += half3(overlay.rgb * overlay.a);
color.rgb = min(color.rgb, half3(1.0, 1.0, 1.0));
return color;
}
struct BlobVertexOut {
float4 position [[position]];
};
float2 blobVertex(float2 center, float angle, float radius) {
return float2(center.x + radius * cos(angle), center.y + radius * sin(angle));
}
float2 mapPointInRect(Rectangle rect, half2 point) {
half2 out(rect.origin.x + rect.size.x * point.x, rect.origin.y + rect.size.y * point.y);
out.x = -1.0 + out.x * 2.0;
out.y = -1.0 + out.y * 2.0;
return float2(out);
}
struct SmoothPoint {
half2 point;
half inAngle;
half inLength;
half outAngle;
half outLength;
half2 smoothIn() {
return smooth(inAngle, inLength);
}
half2 smoothOut() {
return smooth(outAngle, outLength);
}
private:
half2 smooth(half angle, half length) {
return half2(
point.x + length * cos(angle),
point.y + length * sin(angle)
);
}
};
half2 evaluateBlobPoint(const device Rectangle &rect, const device float *positions, int index, int count, int subdivisions) {
float position = positions[index];
float segmentAngle = float(index) / float(count) * 2.0 * 3.1415926;
return half2(blobVertex(float2(0.5, 0.5), segmentAngle, 0.45 + 0.05 * position));
}
SmoothPoint evaluateSmoothBlobPoint(const device Rectangle &rect, const device float *positions, int index, int count, int subdivisions) {
int prevIndex = (index - 1) < 0 ? (count - 1) : (index - 1);
int nextIndex = (index + 1) % count;
half2 prev = evaluateBlobPoint(rect, positions, prevIndex, count, subdivisions);
half2 curr = evaluateBlobPoint(rect, positions, index, count, subdivisions);
half2 next = evaluateBlobPoint(rect, positions, nextIndex, count, subdivisions);
float dx = next.x - prev.x;
float dy = -next.y + prev.y;
float angle = atan2(dy, dx);
if (angle < 0.0) {
angle = abs(angle);
} else {
angle = 2 * 3.1415926 - angle;
}
float smoothAngle = (3.1415926 * 2.0) / float(count);
float smoothness = ((4.0 / 3.0) * tan(smoothAngle / 4.0)) / sin(smoothAngle / 2.0) / 2.0;
SmoothPoint point;
point.point = curr;
point.inAngle = angle + 3.1415926;
point.inLength = smoothness * distance(curr, prev);
point.outAngle = angle;
point.outLength = smoothness * distance(curr, next);
return point;
}
half2 evaluateBezierBlobPoint(thread SmoothPoint &curr, thread SmoothPoint &next, half t) {
half oneMinusT = 1.0 - t;
half2 p0 = curr.point;
half2 p1 = curr.smoothOut();
half2 p2 = next.smoothIn();
half2 p3 = next.point;
return oneMinusT * oneMinusT * oneMinusT * p0 + 3.0 * t * oneMinusT * oneMinusT * p1 + 3.0 * t * t * oneMinusT * p2 + t * t * t * p3;
}
vertex BlobVertexOut callBlobVertex(
const device Rectangle &rect [[ buffer(0) ]],
const device float *positions [[ buffer(1) ]],
const device int &count [[ buffer(2) ]],
unsigned int vid [[ vertex_id ]]
) {
const int subdivisions = 8;
int triangleIndex = vid / 3;
int segmentIndex = triangleIndex / subdivisions;
int nextIndex = (segmentIndex + 1) % count;
half innerPosition = half(triangleIndex - segmentIndex * subdivisions) / half(subdivisions);
half nextInnerPosition = half(triangleIndex + 1 - segmentIndex * subdivisions) / half(subdivisions);
SmoothPoint curr = evaluateSmoothBlobPoint(rect, positions, segmentIndex, count, subdivisions);
SmoothPoint next = evaluateSmoothBlobPoint(rect, positions, nextIndex, count, subdivisions);
half2 triangle[3];
triangle[0] = half2(0.5, 0.5);
triangle[1] = evaluateBezierBlobPoint(curr, next, innerPosition);
triangle[2] = evaluateBezierBlobPoint(curr, next, nextInnerPosition);
BlobVertexOut out;
out.position = float4(float2(mapPointInRect(rect, triangle[vid % 3])), 0.0, 1.0);
return out;
}
fragment half4 callBlobFragment(
BlobVertexOut in [[stage_in]],
const device float4 &color [[ buffer(0) ]]
) {
return half4(color.r * color.a, color.g * color.a, color.b * color.a, color.a);
}
kernel void videoBiPlanarToRGBA(
texture2d<half, access::read> inTextureY [[ texture(0) ]],
texture2d<half, access::read> inTextureUV [[ texture(1) ]],
texture2d<half, access::write> outTexture [[ texture(2) ]],
uint2 threadPosition [[ thread_position_in_grid ]]
) {
half y = inTextureY.read(threadPosition).r;
half2 uv = inTextureUV.read(uint2(threadPosition.x / 2, threadPosition.y / 2)).rg - half2(0.5, 0.5);
half4 color(y + 1.403 * uv.y, y - 0.344 * uv.x - 0.714 * uv.y, y + 1.770 * uv.x, 1.0);
outTexture.write(color, threadPosition);
}
kernel void videoTriPlanarToRGBA(
texture2d<half, access::read> inTextureY [[ texture(0) ]],
texture2d<half, access::read> inTextureU [[ texture(1) ]],
texture2d<half, access::read> inTextureV [[ texture(2) ]],
texture2d<half, access::write> outTexture [[ texture(3) ]],
uint2 threadPosition [[ thread_position_in_grid ]]
) {
half y = inTextureY.read(threadPosition).r;
uint2 uvPosition = uint2(threadPosition.x / 2, threadPosition.y / 2);
half2 inUV = (inTextureU.read(uvPosition).r, inTextureV.read(uvPosition).r);
half2 uv = inUV - half2(0.5, 0.5);
half4 color(y + 1.403 * uv.y, y - 0.344 * uv.x - 0.714 * uv.y, y + 1.770 * uv.x, 1.0);
outTexture.write(color, threadPosition);
}
vertex QuadVertexOut mainVideoVertex(
const device Rectangle &rect [[ buffer(0) ]],
const device uint2 &mirror [[ buffer(1) ]],
unsigned int vid [[ vertex_id ]]
) {
float2 quadVertex = quadVertices[vid];
QuadVertexOut out;
out.position = float4(rect.origin.x + quadVertex.x * rect.size.x, rect.origin.y + quadVertex.y * rect.size.y, 0.0, 1.0);
out.position.x = -1.0 + out.position.x * 2.0;
out.position.y = -1.0 + out.position.y * 2.0;
out.uv = float2(quadVertex.x, 1.0 - quadVertex.y);
if (mirror.x == 1) {
out.uv.x = 1.0 - out.uv.x;
}
if (mirror.y == 1) {
out.uv.y = 1.0 - out.uv.y;
}
return out;
}
fragment half4 mainVideoFragment(
QuadVertexOut in [[stage_in]],
texture2d<half> texture [[ texture(0) ]],
const device float &brightness [[ buffer(0) ]],
const device float &saturation [[ buffer(1) ]],
const device float4 &overlay [[ buffer(2) ]]
) {
constexpr sampler sampler(coord::normalized, address::repeat, filter::linear);
half4 color = texture.sample(sampler, in.uv);
color = rgb2hsv(color);
color.b = clamp(color.b * brightness, 0.0, 1.0);
color.g = clamp(color.g * saturation, 0.0, 1.0);
color = hsv2rgb(color);
color.rgb += half3(overlay.rgb * overlay.a);
color.rgb = min(color.rgb, half3(1.0, 1.0, 1.0));
return half4(color.r, color.g, color.b, color.a);
}
constant int BLUR_SAMPLE_COUNT = 7;
constant float BLUR_OFFSETS[BLUR_SAMPLE_COUNT] = {
-5.227545617192816,
-3.3147990233346842,
-1.4174297935376852,
0.47225076494548685,
2.364576440741639,
4.268941421369995,
6
};
constant float BLUR_WEIGHTS[BLUR_SAMPLE_COUNT] = {
0.015167713616041436,
0.10117053983645591,
0.2894431725427234,
0.3570581167968804,
0.19014435646109845,
0.0435647539906345,
0.0034513467561660305
};
static void gaussianBlur(
texture2d<half, access::sample> inTexture,
texture2d<half, access::write> outTexture,
float2 offset,
uint2 gid
) {
constexpr sampler sampler(coord::normalized, address::clamp_to_edge, filter::linear);
uint2 textureDim(outTexture.get_width(), outTexture.get_height());
if(all(gid < textureDim)) {
float3 outColor(0.0);
float2 size(inTexture.get_width(), inTexture.get_height());
float2 baseTexCoord = float2(gid);
for (int i = 0; i < BLUR_SAMPLE_COUNT; i++) {
outColor += float3(inTexture.sample(sampler, (baseTexCoord + offset * BLUR_OFFSETS[i]) / size).rgb) * BLUR_WEIGHTS[i];
}
outTexture.write(half4(half3(outColor), 1.0), gid);
}
}
kernel void gaussianBlurHorizontal(
texture2d<half, access::sample> inTexture [[ texture(0) ]],
texture2d<half, access::write> outTexture [[ texture(1) ]],
uint2 gid [[ thread_position_in_grid ]]
) {
gaussianBlur(inTexture, outTexture, float2(1, 0), gid);
}
kernel void gaussianBlurVertical(
texture2d<half, access::sample> inTexture [[ texture(0) ]],
texture2d<half, access::write> outTexture [[ texture(1) ]],
uint2 gid [[ thread_position_in_grid ]]
) {
gaussianBlur(inTexture, outTexture, float2(0, 1), gid);
}
vertex QuadVertexOut edgeTestVertex(
const device Rectangle &rect [[ buffer(0) ]],
unsigned int vid [[ vertex_id ]]
) {
float2 quadVertex = quadVertices[vid];
QuadVertexOut out;
out.position = float4(rect.origin.x + quadVertex.x * rect.size.x, rect.origin.y + quadVertex.y * rect.size.y, 0.0, 1.0);
out.position.x = -1.0 + out.position.x * 2.0;
out.position.y = -1.0 + out.position.y * 2.0;
out.uv = quadVertex;
return out;
}
fragment half4 edgeTestFragment(
QuadVertexOut in [[stage_in]],
const device float4 &colorIn
) {
half4 color = half4(colorIn);
return color;
}
@@ -0,0 +1,170 @@
import Foundation
import UIKit
public enum AnimationCurve {
case linear
case easeInOut
case spring
}
extension AnimationCurve {
func map(_ fraction: CGFloat) -> CGFloat {
switch self {
case .linear:
return fraction
case .easeInOut:
return bezierPoint(0.42, 0.0, 0.58, 1.0, fraction)
case .spring:
return bezierPoint(0.23, 1.0, 0.32, 1.0, fraction)
}
}
}
open class AnyAnimation {
}
open class AnimationInterpolator<T> {
private let impl: (T, T, CGFloat) -> T
init(_ impl: @escaping (T, T, CGFloat) -> T) {
self.impl = impl
}
public func interpolate(from: T, to: T, fraction: CGFloat) -> T {
return self.impl(from, to, fraction)
}
}
public protocol AnimationInterpolatable {
static var animationInterpolator: AnimationInterpolator<Self> { get }
}
private let CGFloatInterpolator = AnimationInterpolator<CGFloat> { from, to, fraction in
return from * (1.0 - fraction) + to * fraction
}
extension CGFloat: AnimationInterpolatable {
public static var animationInterpolator: AnimationInterpolator<CGFloat> {
return CGFloatInterpolator
}
}
private let CGPointInterpolator = AnimationInterpolator<CGPoint> { from, to, fraction in
return CGPoint(
x: CGFloatInterpolator.interpolate(from: from.x, to: to.x, fraction: fraction),
y: CGFloatInterpolator.interpolate(from: from.y, to: to.y, fraction: fraction)
)
}
extension CGPoint: AnimationInterpolatable {
public static var animationInterpolator: AnimationInterpolator<CGPoint> {
return CGPointInterpolator
}
}
#if targetEnvironment(simulator)
@_silgen_name("UIAnimationDragCoefficient") func UIAnimationDragCoefficient() -> Float
#endif
public final class Animation<T: AnimationInterpolatable>: AnyAnimation {
private let from: T
private let to: T
private let duration: Double
private let curve: AnimationCurve
private let interpolator: AnimationInterpolator<T>
private var startTime: Double?
public private(set) var isFinished: Bool = false
var didStart: (() -> Void)?
public init(from: T, to: T, duration: Double, curve: AnimationCurve) {
self.from = from
self.to = to
#if targetEnvironment(simulator)
self.duration = duration * Double(UIAnimationDragCoefficient())
#else
self.duration = duration
#endif
self.curve = curve
self.interpolator = T.animationInterpolator
}
func start() {
self.startTime = CACurrentMediaTime()
}
func update(at timestamp: Double) -> T {
guard let startTime = self.startTime else {
return self.from
}
if self.isFinished {
return self.to
}
let fraction = max(0.0, min(1.0, (timestamp - startTime) / self.duration))
if timestamp > startTime + self.duration {
self.isFinished = true
}
if fraction >= 1.0 {
return self.to
}
return self.interpolator.interpolate(from: self.from, to: self.to, fraction: self.curve.map(fraction))
}
}
public class AnyAnimatedProperty {
var didStartAnimation: (() -> Void)?
var hasRunningAnimation: Bool {
return false
}
public func update() {
}
}
public final class AnimatedProperty<T: AnimationInterpolatable>: AnyAnimatedProperty {
private var animation: Animation<T>?
override var hasRunningAnimation: Bool {
return self.animation != nil
}
public private(set) var value: T
public init(_ value: T) {
self.value = value
}
public func animate(to: T, duration: Double, curve: AnimationCurve) {
let timestamp = CACurrentMediaTime()
let fromValue: T
if let animation = self.animation {
fromValue = animation.update(at: timestamp)
} else {
fromValue = self.value
}
self.animation = Animation(from: fromValue, to: to, duration: duration, curve: curve)
self.animation?.start()
self.didStartAnimation?()
}
public func animate(from: T, to: T, duration: Double, curve: AnimationCurve) {
self.value = from
self.animation = Animation(from: from, to: to, duration: duration, curve: curve)
self.animation?.start()
self.didStartAnimation?()
}
public func set(to: T) {
self.animation = nil
self.value = to
}
override public func update() {
if let animation = self.animation {
self.value = animation.update(at: CACurrentMediaTime())
if animation.isFinished {
self.animation = nil
}
}
}
}
@@ -0,0 +1,51 @@
import Foundation
import UIKit
import Display
public final class ManagedAnimations {
private var displayLinkSubscription: SharedDisplayLinkDriver.Link?
private var properties: [AnyAnimatedProperty] = []
public var updated: (() -> Void)?
public init() {
}
public func add(property: AnyAnimatedProperty) {
self.properties.append(property)
property.didStartAnimation = { [weak self] in
guard let self else {
return
}
self.updateNeedAnimations()
}
}
private func updateNeedAnimations() {
if self.displayLinkSubscription == nil {
self.displayLinkSubscription = SharedDisplayLinkDriver.shared.add { [weak self] _ in
guard let self else {
return
}
self.update()
}
}
}
private func update() {
var hasRunningAnimations = false
for property in self.properties {
property.update()
if property.hasRunningAnimation {
hasRunningAnimations = true
}
}
if !hasRunningAnimations {
self.displayLinkSubscription = nil
}
self.updated?()
}
}
@@ -0,0 +1,53 @@
import Foundation
private func a(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat
{
return 1.0 - 3.0 * a2 + 3.0 * a1
}
private func b(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat
{
return 3.0 * a2 - 6.0 * a1
}
private func c(_ a1: CGFloat) -> CGFloat
{
return 3.0 * a1
}
private func calcBezier(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat
{
return ((a(a1, a2)*t + b(a1, a2))*t + c(a1)) * t
}
private func calcSlope(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat
{
return 3.0 * a(a1, a2) * t * t + 2.0 * b(a1, a2) * t + c(a1)
}
private func getTForX(_ x: CGFloat, _ x1: CGFloat, _ x2: CGFloat) -> CGFloat {
var t = x
var i = 0
while i < 4 {
let currentSlope = calcSlope(t, x1, x2)
if currentSlope == 0.0 {
return t
} else {
let currentX = calcBezier(t, x1, x2) - x
t -= currentX / currentSlope
}
i += 1
}
return t
}
public func bezierPoint(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x: CGFloat) -> CGFloat
{
var value = calcBezier(getTForX(x, x1, x2), y1, y2)
if value >= 0.997 {
value = 1.0
}
return value
}
@@ -0,0 +1,28 @@
import Foundation
import UIKit
import Display
import MetalKit
private final class BundleMarker: NSObject {
}
private var metalLibraryValue: MTLLibrary?
func metalLibrary(device: MTLDevice) -> MTLLibrary? {
if let metalLibraryValue {
return metalLibraryValue
}
let mainBundle = Bundle(for: BundleMarker.self)
guard let path = mainBundle.path(forResource: "CallScreenMetalSourcesBundle", ofType: "bundle") else {
return nil
}
guard let bundle = Bundle(path: path) else {
return nil
}
guard let library = try? device.makeDefaultLibrary(bundle: bundle) else {
return nil
}
metalLibraryValue = library
return library
}
@@ -0,0 +1,103 @@
import Foundation
import UIKit
import Display
import ComponentFlow
final class AvatarLayer: SimpleLayer {
struct Params: Equatable {
var size: CGSize
var cornerRadius: CGFloat
var isExpanded: Bool
init(size: CGSize, cornerRadius: CGFloat, isExpanded: Bool) {
self.size = size
self.cornerRadius = cornerRadius
self.isExpanded = isExpanded
}
}
private(set) var params: Params?
private var rasterizedImage: UIImage?
private var isAnimating: Bool = false
var image: UIImage? {
didSet {
if self.image !== oldValue {
self.updateImage()
}
}
}
override init() {
super.init()
self.contentsGravity = .resizeAspectFill
self.masksToBounds = true
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateImage() {
guard let params else {
return
}
if self.isAnimating || params.isExpanded {
self.contents = self.image?.cgImage
} else {
self.contents = self.image.flatMap({ image -> UIImage? in
let imageSize = CGSize(width: min(params.size.width, params.size.height), height: min(params.size.width, params.size.height))
return generateImage(imageSize, contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
if params.cornerRadius == size.width * 0.5 {
context.addEllipse(in: CGRect(origin: CGPoint(), size: size))
} else {
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: params.cornerRadius).cgPath)
}
context.clip()
if let cgImage = image.cgImage {
context.draw(cgImage, in: CGRect(origin: CGPoint(), size: size))
}
})
})?.cgImage
}
}
func update(size: CGSize, isExpanded: Bool, cornerRadius: CGFloat, transition: ComponentTransition) {
let params = Params(size: size, cornerRadius: cornerRadius, isExpanded: isExpanded)
if self.params == params {
return
}
let previousCornerRadius = self.params?.cornerRadius
self.params = params
if previousCornerRadius != params.cornerRadius {
self.masksToBounds = true
self.isAnimating = true
self.updateImage()
if let previousCornerRadius, self.animation(forKey: "cornerRadius") == nil {
self.cornerRadius = previousCornerRadius
}
transition.setCornerRadius(layer: self, cornerRadius: cornerRadius, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.isAnimating = false
self.masksToBounds = false
self.cornerRadius = 0.0
self.updateImage()
})
}
}
}
@@ -0,0 +1,88 @@
import Foundation
import UIKit
import Display
final class BackButtonView: HighlightableButton {
private struct Params: Equatable {
var text: String
init(text: String) {
self.text = text
}
}
private struct Layout: Equatable {
var params: Params
var size: CGSize
init(params: Params, size: CGSize) {
self.params = params
self.size = size
}
}
private let iconView: UIImageView
private let textView: TextView
private var currentLayout: Layout?
var pressAction: (() -> Void)?
override init(frame: CGRect) {
self.iconView = UIImageView(image: NavigationBar.backArrowImage(color: .white))
self.iconView.isUserInteractionEnabled = false
self.textView = TextView()
self.textView.isUserInteractionEnabled = false
super.init(frame: frame)
self.addSubview(self.iconView)
self.addSubview(self.textView)
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
self.pressAction?()
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) {
return super.hitTest(self.bounds.center, with: event)
} else {
return nil
}
}
func update(text: String) -> CGSize {
let params = Params(text: text)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.size
}
let size = self.update(params: params)
self.currentLayout = Layout(params: params, size: size)
return size
}
private func update(params: Params) -> CGSize {
let spacing: CGFloat = 8.0
var iconSize: CGSize = self.iconView.image?.size ?? CGSize(width: 2.0, height: 2.0)
let iconScaleFactor: CGFloat = 0.9
iconSize.width = floor(iconSize.width * iconScaleFactor)
iconSize.height = floor(iconSize.height * iconScaleFactor)
let textSize = self.textView.update(string: params.text, fontSize: 17.0, fontWeight: UIFont.Weight.regular.rawValue, color: .white, constrainedWidth: 100.0, transition: .immediate)
let size = CGSize(width: iconSize.width + spacing + textSize.width, height: textSize.height)
self.iconView.frame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((size.height - iconSize.height) * 0.5)), size: iconSize)
self.textView.frame = CGRect(origin: CGPoint(x: iconSize.width + spacing, y: floorToScreenPixels((size.height - textSize.height) * 0.5)), size: textSize)
return size
}
}
@@ -0,0 +1,322 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AppBundle
import TelegramPresentationData
final class ButtonGroupView: OverlayMaskContainerView {
final class Button {
enum Content: Equatable {
enum Key: Hashable {
case speaker
case flipCamera
case video
case microphone
case end
}
case speaker(audioOutput: PrivateCallScreen.State.AudioOutput)
case flipCamera
case video(isActive: Bool)
case microphone(isMuted: Bool)
case end
var key: Key {
switch self {
case .speaker:
return .speaker
case .flipCamera:
return .flipCamera
case .video:
return .video
case .microphone:
return .microphone
case .end:
return .end
}
}
}
let content: Content
let isEnabled: Bool
let action: () -> Void
init(content: Content, isEnabled: Bool, action: @escaping () -> Void) {
self.content = content
self.isEnabled = isEnabled
self.action = action
}
}
final class Notice {
let id: AnyHashable
let icon: String
let text: String
init(id: AnyHashable, icon: String, text: String) {
self.id = id
self.icon = icon
self.text = text
}
}
private var buttons: [Button]?
private var buttonViews: [Button.Content.Key: ContentOverlayButton] = [:]
private var noticeViews: [AnyHashable: NoticeView] = [:]
private var closeButtonView: CloseButtonView?
var closePressed: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let result = super.hitTest(point, with: event) else {
return nil
}
if result === self {
return nil
}
return result
}
func update(size: CGSize, insets: UIEdgeInsets, minWidth: CGFloat, controlsHidden: Bool, displayClose: Bool, strings: PresentationStrings, buttons: [Button], notices: [Notice], isAnimatedOutToGroupCall: Bool, transition: ComponentTransition) -> CGFloat {
self.buttons = buttons
let buttonSize: CGFloat = 56.0
let buttonSpacing: CGFloat = 36.0
let buttonNoticeSpacing: CGFloat = 16.0
let controlsHiddenNoticeSpacing: CGFloat = 0.0
var nextNoticeY: CGFloat
if isAnimatedOutToGroupCall {
nextNoticeY = size.height + 4.0
} else if controlsHidden {
nextNoticeY = size.height - insets.bottom - 4.0
} else {
nextNoticeY = size.height - insets.bottom - 52.0 - buttonSize - buttonNoticeSpacing
}
let noticeSpacing: CGFloat = 8.0
var validNoticeIds: [AnyHashable] = []
var noticesHeight: CGFloat = 0.0
for notice in notices {
validNoticeIds.append(notice.id)
let noticeView: NoticeView
var noticeTransition = transition
var animateIn = false
if let current = self.noticeViews[notice.id] {
noticeView = current
} else {
noticeTransition = noticeTransition.withAnimation(.none)
animateIn = true
noticeView = NoticeView()
self.noticeViews[notice.id] = noticeView
self.addSubview(noticeView)
}
if noticesHeight != 0.0 {
noticesHeight += noticeSpacing
} else {
if controlsHidden {
noticesHeight += controlsHiddenNoticeSpacing
} else {
noticesHeight += buttonNoticeSpacing
}
}
let noticeSize = noticeView.update(icon: notice.icon, text: notice.text, constrainedWidth: size.width - insets.left * 2.0 - 16.0 * 2.0, transition: noticeTransition)
let noticeFrame = CGRect(origin: CGPoint(x: floor((size.width - noticeSize.width) * 0.5), y: isAnimatedOutToGroupCall ? nextNoticeY : (nextNoticeY - noticeSize.height)), size: noticeSize)
noticesHeight += noticeSize.height
if !isAnimatedOutToGroupCall {
nextNoticeY -= noticeSize.height + noticeSpacing
}
noticeTransition.setFrame(view: noticeView, frame: noticeFrame)
if animateIn, !transition.animation.isImmediate {
noticeView.animateIn()
}
}
if noticesHeight != 0.0 {
noticesHeight += 5.0
}
if isAnimatedOutToGroupCall {
noticesHeight = 0.0
}
var removedNoticeIds: [AnyHashable] = []
for (id, noticeView) in self.noticeViews {
if !validNoticeIds.contains(id) {
removedNoticeIds.append(id)
if !transition.animation.isImmediate {
noticeView.animateOut(completion: { [weak noticeView] in
noticeView?.removeFromSuperview()
})
} else {
noticeView.removeFromSuperview()
}
}
}
for id in removedNoticeIds {
self.noticeViews.removeValue(forKey: id)
}
let buttonY: CGFloat
let resultHeight: CGFloat
if controlsHidden || isAnimatedOutToGroupCall {
buttonY = size.height + 12.0
resultHeight = insets.bottom + 4.0 + noticesHeight
} else {
buttonY = size.height - insets.bottom - 52.0 - buttonSize
resultHeight = size.height - buttonY + noticesHeight
}
var buttonX: CGFloat = floor((size.width - buttonSize * CGFloat(buttons.count) - buttonSpacing * CGFloat(buttons.count - 1)) * 0.5)
if displayClose {
let closeButtonView: CloseButtonView
var closeButtonTransition = transition
var animateIn = false
if let current = self.closeButtonView {
closeButtonView = current
} else {
closeButtonTransition = closeButtonTransition.withAnimation(.none)
animateIn = true
closeButtonView = CloseButtonView()
self.closeButtonView = closeButtonView
self.addSubview(closeButtonView)
closeButtonView.pressAction = { [weak self] in
guard let self else {
return
}
self.closePressed?()
}
}
let closeButtonSize = CGSize(width: minWidth, height: buttonSize)
closeButtonView.update(text: strings.Common_Close, size: closeButtonSize, transition: closeButtonTransition)
closeButtonTransition.setFrame(view: closeButtonView, frame: CGRect(origin: CGPoint(x: floor((size.width - closeButtonSize.width) * 0.5), y: buttonY), size: closeButtonSize))
if animateIn && !transition.animation.isImmediate {
closeButtonView.animateIn()
}
} else {
if let closeButtonView = self.closeButtonView {
self.closeButtonView = nil
if !transition.animation.isImmediate {
closeButtonView.animateOut(completion: { [weak closeButtonView] in
closeButtonView?.removeFromSuperview()
})
} else {
closeButtonView.removeFromSuperview()
}
}
}
for button in buttons {
let title: String
let image: UIImage?
let isActive: Bool
var isDestructive: Bool = false
switch button.content {
case let .speaker(audioOutput):
switch audioOutput {
case .internalSpeaker, .speaker:
title = strings.Call_Speaker
default:
title = strings.Call_Audio
}
switch audioOutput {
case .internalSpeaker:
image = UIImage(bundleImageName: "Call/Speaker")
isActive = false
case .speaker:
image = UIImage(bundleImageName: "Call/Speaker")
isActive = true
case .airpods:
image = UIImage(bundleImageName: "Call/CallAirpodsButton")
isActive = true
case .airpodsPro:
image = UIImage(bundleImageName: "Call/CallAirpodsProButton")
isActive = true
case .airpodsMax:
image = UIImage(bundleImageName: "Call/CallAirpodsMaxButton")
isActive = true
case .headphones:
image = UIImage(bundleImageName: "Call/CallHeadphonesButton")
isActive = true
case .bluetooth:
image = UIImage(bundleImageName: "Call/CallBluetoothButton")
isActive = true
}
case .flipCamera:
title = strings.Call_Flip
image = UIImage(bundleImageName: "Call/Flip")
isActive = false
case let .video(isActiveValue):
title = strings.Call_Video
image = UIImage(bundleImageName: "Call/Video")
isActive = isActiveValue
case let .microphone(isActiveValue):
title = strings.Call_Mute
image = UIImage(bundleImageName: "Call/Mute")
isActive = isActiveValue
case .end:
title = strings.Call_End
image = UIImage(bundleImageName: "Call/End")
isActive = false
isDestructive = true
}
var buttonTransition = transition
let buttonView: ContentOverlayButton
if let current = self.buttonViews[button.content.key] {
buttonView = current
} else {
buttonTransition = transition.withAnimation(.none)
buttonView = ContentOverlayButton(frame: CGRect())
self.addSubview(buttonView)
self.buttonViews[button.content.key] = buttonView
let key = button.content.key
buttonView.action = { [weak self] in
guard let self, let button = self.buttons?.first(where: { $0.content.key == key }) else {
return
}
button.action()
}
ComponentTransition.immediate.setScale(view: buttonView, scale: 0.001)
buttonView.alpha = 0.0
transition.setScale(view: buttonView, scale: 1.0)
}
transition.setAlpha(view: buttonView, alpha: displayClose ? 0.0 : 1.0)
buttonTransition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(x: buttonX, y: buttonY), size: CGSize(width: buttonSize, height: buttonSize)))
buttonView.update(size: CGSize(width: buttonSize, height: buttonSize), image: image, isSelected: isActive, isDestructive: isDestructive, isEnabled: button.isEnabled, title: title, transition: buttonTransition)
buttonX += buttonSize + buttonSpacing
}
var removeKeys: [Button.Content.Key] = []
for (key, buttonView) in self.buttonViews {
if !buttons.contains(where: { $0.content.key == key }) {
removeKeys.append(key)
transition.setScale(view: buttonView, scale: 0.001)
transition.setAlpha(view: buttonView, alpha: 0.0, completion: { [weak buttonView] _ in
buttonView?.removeFromSuperview()
})
}
}
for key in removeKeys {
self.buttonViews.removeValue(forKey: key)
}
return resultHeight
}
}
@@ -0,0 +1,229 @@
import Foundation
import MetalKit
import UIKit
import MetalEngine
import ComponentFlow
import Display
private func shiftArray(array: [SIMD2<Float>], offset: Int) -> [SIMD2<Float>] {
var newArray = array
var offset = offset
while offset > 0 {
let element = newArray.removeFirst()
newArray.append(element)
offset -= 1
}
return newArray
}
private func gatherPositions(_ list: [SIMD2<Float>]) -> [SIMD2<Float>] {
var result: [SIMD2<Float>] = []
for i in 0 ..< list.count / 2 {
result.append(list[i * 2])
}
return result
}
private func hexToFloat(_ hex: Int) -> SIMD4<Float> {
let red = Float((hex >> 16) & 0xFF) / 255.0
let green = Float((hex >> 8) & 0xFF) / 255.0
let blue = Float((hex >> 0) & 0xFF) / 255.0
return SIMD4<Float>(x: red, y: green, z: blue, w: 1.0)
}
private struct ColorSet: Equatable, AnimationInterpolatable {
static let animationInterpolator = AnimationInterpolator<ColorSet> { from, to, fraction in
var result: [SIMD4<Float>] = []
for i in 0 ..< min(from.colors.count, to.colors.count) {
result.append(from.colors[i] * Float(1.0 - fraction) + to.colors[i] * Float(fraction))
}
return ColorSet(colors: result)
}
var colors: [SIMD4<Float>]
}
final class CallBackgroundLayer: MetalEngineSubjectLayer, MetalEngineSubject {
var internalData: MetalEngineSubjectInternalData?
final class RenderState: RenderToLayerState {
let pipelineState: MTLRenderPipelineState
init?(device: MTLDevice) {
guard let library = metalLibrary(device: device) else {
return nil
}
guard let vertexFunction = library.makeFunction(name: "callBackgroundVertex"), let fragmentFunction = library.makeFunction(name: "callBackgroundFragment") else {
return nil
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
guard let pipelineState = try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) else {
return nil
}
self.pipelineState = pipelineState
}
}
private static var basePositions: [SIMD2<Float>] = [
SIMD2<Float>(x: 0.80, y: 0.10),
SIMD2<Float>(x: 0.60, y: 0.20),
SIMD2<Float>(x: 0.35, y: 0.25),
SIMD2<Float>(x: 0.25, y: 0.60),
SIMD2<Float>(x: 0.20, y: 0.90),
SIMD2<Float>(x: 0.40, y: 0.80),
SIMD2<Float>(x: 0.65, y: 0.75),
SIMD2<Float>(x: 0.75, y: 0.40)
]
let blurredLayer: MetalEngineSubjectLayer
let externalBlurredLayer: MetalEngineSubjectLayer
private var phase: Float = 0.0
private var displayLinkSubscription: SharedDisplayLinkDriver.Link?
var renderSpec: RenderLayerSpec? {
didSet {
if self.renderSpec != oldValue {
self.setNeedsUpdate()
}
}
}
private let colorSets: [ColorSet]
private let colorTransition: AnimatedProperty<ColorSet>
private var stateIndex: Int = 0
private var isEnergySavingEnabled: Bool = false
private let phaseAcceleration = AnimatedProperty<CGFloat>(0.0)
override init() {
self.blurredLayer = MetalEngineSubjectLayer()
self.externalBlurredLayer = MetalEngineSubjectLayer()
self.colorSets = [
ColorSet(colors: [
hexToFloat(0x568FD6),
hexToFloat(0x626ED5),
hexToFloat(0xA667D5),
hexToFloat(0x7664DA)
]),
ColorSet(colors: [
hexToFloat(0xACBD65),
hexToFloat(0x459F8D),
hexToFloat(0x53A4D1),
hexToFloat(0x3E917A)
]),
ColorSet(colors: [
hexToFloat(0xC0508D),
hexToFloat(0xF09536),
hexToFloat(0xCE5081),
hexToFloat(0xFC7C4C)
])
]
self.colorTransition = AnimatedProperty<ColorSet>(colorSets[0])
super.init()
self.blurredLayer.cloneLayers.append(self.externalBlurredLayer)
self.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.displayLinkSubscription = SharedDisplayLinkDriver.shared.add(framesPerSecond: .fps(30), { [weak self] timeDelta in
guard let self else {
return
}
self.colorTransition.update()
self.phaseAcceleration.update()
let stepCount = 8
var phaseStep: CGFloat = 0.5 * timeDelta
phaseStep += phaseStep * self.phaseAcceleration.value * 0.5
self.phase = (self.phase + Float(phaseStep)).truncatingRemainder(dividingBy: Float(stepCount))
self.setNeedsUpdate()
})
}
self.didExitHierarchy = { [weak self] in
guard let self else {
return
}
self.displayLinkSubscription = nil
}
}
override init(layer: Any) {
self.blurredLayer = MetalEngineSubjectLayer()
self.externalBlurredLayer = MetalEngineSubjectLayer()
self.colorSets = []
self.colorTransition = AnimatedProperty<ColorSet>(ColorSet(colors: []))
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(stateIndex: Int, isEnergySavingEnabled: Bool, transition: ComponentTransition) {
self.isEnergySavingEnabled = isEnergySavingEnabled
if self.stateIndex != stateIndex {
self.stateIndex = stateIndex
if !transition.animation.isImmediate {
self.phaseAcceleration.animate(from: 1.0, to: 0.0, duration: 2.0, curve: .easeInOut)
self.colorTransition.animate(to: self.colorSets[stateIndex % self.colorSets.count], duration: 0.3, curve: .easeInOut)
} else {
self.colorTransition.set(to: self.colorSets[stateIndex % self.colorSets.count])
}
self.setNeedsUpdate()
}
}
func update(context: MetalEngineSubjectContext) {
guard let renderSpec = self.renderSpec else {
return
}
let phase = self.isEnergySavingEnabled ? 0.0 : self.phase
for i in 0 ..< 2 {
let isBlur = i == 1
context.renderToLayer(spec: renderSpec, state: RenderState.self, layer: i == 0 ? self : self.blurredLayer, commands: { encoder, placement in
var effectiveRect = placement.effectiveRect
effectiveRect = effectiveRect.insetBy(dx: -effectiveRect.width * 0.1, dy: -effectiveRect.height * 0.1)
var rect = SIMD4<Float>(Float(effectiveRect.minX), Float(effectiveRect.minY), Float(effectiveRect.width), Float(effectiveRect.height))
encoder.setVertexBytes(&rect, length: 4 * 4, index: 0)
let baseStep = floor(phase)
let nextStepInterpolation = phase - floor(phase)
let positions0 = gatherPositions(shiftArray(array: CallBackgroundLayer.basePositions, offset: Int(baseStep)))
let positions1 = gatherPositions(shiftArray(array: CallBackgroundLayer.basePositions, offset: Int(baseStep) + 1))
var positions = Array<SIMD2<Float>>(repeating: SIMD2<Float>(), count: 4)
for i in 0 ..< 4 {
positions[i] = interpolatePoints(positions0[i], positions1[i], at: nextStepInterpolation)
}
encoder.setFragmentBytes(&positions, length: 4 * MemoryLayout<SIMD2<Float>>.size, index: 0)
var colors: [SIMD4<Float>] = self.colorTransition.value.colors
encoder.setFragmentBytes(&colors, length: 4 * MemoryLayout<SIMD4<Float>>.size, index: 1)
var brightness: Float = isBlur ? 0.9 : 1.0
var saturation: Float = isBlur ? 1.1 : 1.0
var overlay: SIMD4<Float> = isBlur ? SIMD4<Float>(1.0, 1.0, 1.0, 0.2) : SIMD4<Float>()
encoder.setFragmentBytes(&brightness, length: 4, index: 2)
encoder.setFragmentBytes(&saturation, length: 4, index: 3)
encoder.setFragmentBytes(&overlay, length: 4 * 4, index: 4)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
})
}
}
}
@@ -0,0 +1,156 @@
import Foundation
import MetalKit
import MetalEngine
import Display
public final class CallBlobsLayer: MetalEngineSubjectLayer, MetalEngineSubject {
public var internalData: MetalEngineSubjectInternalData?
private struct Blob {
var color: SIMD4<Float>
var points: [Float]
var nextPoints: [Float]
init(count: Int, color: SIMD4<Float>) {
self.color = color
self.points = (0 ..< count).map { _ in
Float.random(in: 0.0 ... 1.0)
}
self.nextPoints = (0 ..< count).map { _ in
Float.random(in: 0.0 ... 1.0)
}
}
func interpolate(at t: Float) -> [Float] {
var points: [Float] = Array(repeating: 0.0, count: self.points.count)
for i in 0 ..< self.points.count {
points[i] = interpolateFloat(self.points[i], self.nextPoints[i], at: t)
}
return points
}
mutating func advance() {
self.points = self.nextPoints
self.nextPoints = (0 ..< self.points.count).map { _ in
Float.random(in: 0.0 ... 1.0)
}
}
}
private final class RenderState: RenderToLayerState {
let pipelineState: MTLRenderPipelineState
required init?(device: MTLDevice) {
guard let library = metalLibrary(device: device) else {
return nil
}
guard let vertexFunction = library.makeFunction(name: "callBlobVertex"), let fragmentFunction = library.makeFunction(name: "callBlobFragment") else {
return nil
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true
pipelineDescriptor.colorAttachments[0].rgbBlendOperation = .add
pipelineDescriptor.colorAttachments[0].alphaBlendOperation = .add
pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .one
pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .one
pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .one
guard let pipelineState = try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) else {
return nil
}
self.pipelineState = pipelineState
}
}
private var phase: Float = 0.0
private var blobs: [Blob] = []
private var displayLinkSubscription: SharedDisplayLinkDriver.Link?
public init(colors: [UIColor] = [UIColor(white: 1.0, alpha: 0.35), UIColor(white: 1.0, alpha: 0.35)]) {
super.init()
self.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.displayLinkSubscription = SharedDisplayLinkDriver.shared.add(framesPerSecond: .fps(30), { [weak self] deltaTime in
guard let self else {
return
}
self.phase += 3.0 * Float(deltaTime)
if self.phase >= 1.0 {
for i in 0 ..< self.blobs.count {
self.blobs[i].advance()
}
}
self.phase = self.phase.truncatingRemainder(dividingBy: 1.0)
self.setNeedsUpdate()
})
}
self.didExitHierarchy = { [weak self] in
guard let self else {
return
}
self.displayLinkSubscription = nil
}
self.isOpaque = false
self.blobs = colors.reversed().map { color in
var r: CGFloat = 0.0
var g: CGFloat = 0.0
var b: CGFloat = 0.0
var a: CGFloat = 0.0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
return Blob(count: 8, color: SIMD4<Float>(Float(r), Float(g), Float(b), Float(a)))
}
}
override public init(layer: Any) {
super.init(layer: layer)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func update(context: MetalEngineSubjectContext) {
if self.bounds.isEmpty {
return
}
let phase = self.phase
let blobs = self.blobs
context.renderToLayer(spec: RenderLayerSpec(size: RenderSize(width: Int(self.bounds.width * 3.0), height: Int(self.bounds.height * 3.0)), edgeInset: 2), state: RenderState.self, layer: self, commands: { encoder, placement in
let rect = placement.effectiveRect
for i in 0 ..< blobs.count {
var points = blobs[i].interpolate(at: phase)
var count: Int32 = Int32(points.count)
let insetFraction: CGFloat = CGFloat(i) * 0.1
let blobRect = rect.insetBy(dx: insetFraction * 0.5 * rect.width, dy: insetFraction * 0.5 * rect.height)
var rect = SIMD4<Float>(Float(blobRect.minX), Float(blobRect.minY), Float(blobRect.width), Float(blobRect.height))
encoder.setVertexBytes(&rect, length: 4 * 4, index: 0)
encoder.setVertexBytes(&points, length: MemoryLayout<Float>.size * points.count, index: 1)
encoder.setVertexBytes(&count, length: MemoryLayout<Float>.size, index: 2)
var color = blobs[i].color
encoder.setFragmentBytes(&color, length: MemoryLayout<Float>.size * 4, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3 * 8 * points.count)
}
})
}
}
@@ -0,0 +1,185 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import UIKitRuntimeUtils
final class CloseButtonView: HighlightTrackingButton, OverlayMaskContainerViewProtocol {
private struct Params: Equatable {
var text: String
var size: CGSize
init(text: String, size: CGSize) {
self.text = text
self.size = size
}
}
private let backdropBackgroundView: RoundedCornersView
private let backgroundView: RoundedCornersView
private let backgroundMaskView: UIView
private let backgroundClippingView: UIView
private let duration: Double = 5.0
private var fillTime: Double = 0.0
private let backgroundTextView: TextView
private let backgroundTextClippingView: UIView
private let textView: TextView
var pressAction: (() -> Void)?
private var params: Params?
private var updateDisplayLink: SharedDisplayLinkDriver.Link?
let maskContents: UIView
override static var layerClass: AnyClass {
return MirroringLayer.self
}
override init(frame: CGRect) {
self.backdropBackgroundView = RoundedCornersView(color: .white, smoothCorners: true)
self.backdropBackgroundView.update(cornerRadius: 12.0, transition: .immediate)
self.backgroundView = RoundedCornersView(color: .white, smoothCorners: true)
self.backgroundView.update(cornerRadius: 12.0, transition: .immediate)
self.backgroundView.isUserInteractionEnabled = false
self.backgroundMaskView = UIView()
self.backgroundMaskView.backgroundColor = .white
self.backgroundView.mask = self.backgroundMaskView
if let filter = makeLuminanceToAlphaFilter() {
self.backgroundMaskView.layer.filters = [filter]
}
self.backgroundClippingView = UIView()
self.backgroundClippingView.clipsToBounds = true
self.backgroundClippingView.layer.cornerRadius = 12.0
self.backgroundTextClippingView = UIView()
self.backgroundTextClippingView.clipsToBounds = true
self.backgroundTextView = TextView()
self.textView = TextView()
self.maskContents = UIView()
self.maskContents.addSubview(self.backdropBackgroundView)
super.init(frame: frame)
(self.layer as? MirroringLayer)?.targetLayer = self.maskContents.layer
self.backgroundTextClippingView.addSubview(self.backgroundTextView)
self.backgroundTextClippingView.isUserInteractionEnabled = false
self.addSubview(self.backgroundTextClippingView)
self.backgroundClippingView.addSubview(self.backgroundView)
self.backgroundClippingView.isUserInteractionEnabled = false
self.addSubview(self.backgroundClippingView)
self.backgroundMaskView.addSubview(self.textView)
self.internalHighligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "sublayerTransform")
let transition = ComponentTransition(animation: .curve(duration: 0.15, curve: .easeInOut))
transition.setScale(layer: self.layer, scale: topScale)
} else {
let t = self.layer.presentation()?.transform ?? layer.transform
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
let transition = ComponentTransition(animation: .none)
transition.setScale(layer: self.layer, scale: 1.0)
self.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
(self.layer as? MirroringLayer)?.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
if self.fillTime < self.duration && self.updateDisplayLink == nil {
self.updateDisplayLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] deltaTime in
guard let self else {
return
}
self.fillTime = min(self.duration, self.fillTime + deltaTime)
if let params = self.params {
self.update(params: params, transition: .immediate)
}
if self.fillTime >= self.duration {
self.updateDisplayLink = nil
}
})
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
self.pressAction?()
}
func animateIn() {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
func animateOut(completion: @escaping () -> Void) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
completion()
})
}
func update(text: String, size: CGSize, transition: ComponentTransition) {
let params = Params(text: text, size: size)
if self.params == params {
return
}
self.params = params
self.update(params: params, transition: transition)
}
private func update(params: Params, transition: ComponentTransition) {
let fillFraction: CGFloat = CGFloat(self.fillTime / self.duration)
let sideInset: CGFloat = 12.0
let textSize = self.textView.update(string: params.text, fontSize: 17.0, fontWeight: UIFont.Weight.semibold.rawValue, color: .black, constrainedWidth: params.size.width - sideInset * 2.0, transition: .immediate)
let _ = self.backgroundTextView.update(string: params.text, fontSize: 17.0, fontWeight: UIFont.Weight.semibold.rawValue, color: .white, constrainedWidth: params.size.width - sideInset * 2.0, transition: .immediate)
transition.setFrame(view: self.backdropBackgroundView, frame: CGRect(origin: CGPoint(), size: params.size))
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: params.size))
transition.setFrame(view: self.backgroundMaskView, frame: CGRect(origin: CGPoint(), size: params.size))
let progressWidth: CGFloat = max(0.0, min(params.size.width, floorToScreenPixels(fillFraction * params.size.width)))
let backgroundClippingFrame = CGRect(origin: CGPoint(x: progressWidth, y: 0.0), size: CGSize(width: params.size.width - progressWidth, height: params.size.height))
transition.setPosition(view: self.backgroundClippingView, position: backgroundClippingFrame.center)
transition.setBounds(view: self.backgroundClippingView, bounds: CGRect(origin: CGPoint(x: backgroundClippingFrame.minX, y: 0.0), size: backgroundClippingFrame.size))
let backgroundTextClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: progressWidth, height: params.size.height))
transition.setPosition(view: self.backgroundTextClippingView, position: backgroundTextClippingFrame.center)
transition.setBounds(view: self.backgroundTextClippingView, bounds: CGRect(origin: CGPoint(), size: backgroundTextClippingFrame.size))
let textFrame = CGRect(origin: CGPoint(x: floor((params.size.width - textSize.width) * 0.5), y: floor((params.size.height - textSize.height) * 0.5)), size: textSize)
transition.setFrame(view: self.textView, frame: textFrame)
transition.setFrame(view: self.backgroundTextView, frame: textFrame)
}
}
@@ -0,0 +1,104 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import UIKitRuntimeUtils
import AppBundle
final class ConferenceButtonView: HighlightTrackingButton, OverlayMaskContainerViewProtocol {
private struct Params: Equatable {
var size: CGSize
init(size: CGSize) {
self.size = size
}
}
private let backdropBackgroundView: RoundedCornersView
private let iconView: UIImageView
var pressAction: (() -> Void)?
private var params: Params?
let maskContents: UIView
override static var layerClass: AnyClass {
return MirroringLayer.self
}
override init(frame: CGRect) {
self.backdropBackgroundView = RoundedCornersView(color: .white, smoothCorners: true)
self.iconView = UIImageView()
self.maskContents = UIView()
self.maskContents.addSubview(self.backdropBackgroundView)
super.init(frame: frame)
self.addSubview(self.iconView)
(self.layer as? MirroringLayer)?.targetLayer = self.maskContents.layer
self.internalHighligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "sublayerTransform")
let transition = ComponentTransition(animation: .curve(duration: 0.15, curve: .easeInOut))
transition.setScale(layer: self.layer, scale: topScale)
} else {
let t = self.layer.presentation()?.transform ?? layer.transform
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
let transition = ComponentTransition(animation: .none)
transition.setScale(layer: self.layer, scale: 1.0)
self.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
self.pressAction?()
}
func update(size: CGSize, transition: ComponentTransition) {
let params = Params(size: size)
if self.params == params {
return
}
self.params = params
self.update(params: params, transition: transition)
}
private func update(params: Params, transition: ComponentTransition) {
self.backdropBackgroundView.update(cornerRadius: params.size.height * 0.5, transition: transition)
transition.setFrame(view: self.backdropBackgroundView, frame: CGRect(origin: CGPoint(), size: params.size))
if self.iconView.image == nil {
self.iconView.image = UIImage(bundleImageName: "Call/CallNavigationAddPerson")?.withRenderingMode(.alwaysTemplate)
self.iconView.tintColor = .white
}
if let image = self.iconView.image {
let fraction: CGFloat = 1.0
let imageSize = CGSize(width: floor(image.size.width * fraction), height: floor(image.size.height * fraction))
transition.setFrame(view: self.iconView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - imageSize.width) * 0.5), y: floorToScreenPixels((params.size.height - imageSize.height) * 0.5)), size: imageSize))
}
}
}
@@ -0,0 +1,206 @@
import Foundation
import UIKit
import Display
import ComponentFlow
final class ContentOverlayButton: HighlightTrackingButton, OverlayMaskContainerViewProtocol {
private struct ContentParams: Equatable {
var size: CGSize
var image: UIImage?
var isSelected: Bool
var isDestructive: Bool
var isEnabled: Bool
init(size: CGSize, image: UIImage?, isSelected: Bool, isDestructive: Bool, isEnabled: Bool) {
self.size = size
self.image = image
self.isSelected = isSelected
self.isDestructive = isDestructive
self.isEnabled = isEnabled
}
}
let maskContents: UIView
override static var layerClass: AnyClass {
return MirroringLayer.self
}
var action: (() -> Void)?
private let contentView: UIImageView
private var currentContentViewIsSelected: Bool?
private let textView: TextView
private var contentParams: ContentParams?
override init(frame: CGRect) {
self.maskContents = UIView()
self.contentView = UIImageView()
self.textView = TextView()
super.init(frame: frame)
self.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
let size: CGFloat = 56.0
let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: CGSize(width: size, height: size)))
self.maskContents.layer.contents = renderer.image { context in
UIGraphicsPushContext(context.cgContext)
context.cgContext.setFillColor(UIColor.white.cgColor)
context.cgContext.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size, height: size)))
UIGraphicsPopContext()
}.cgImage
(self.layer as? MirroringLayer)?.targetLayer = self.maskContents.layer
self.addSubview(self.contentView)
self.addSubview(self.textView)
self.internalHighligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "sublayerTransform")
let transition = ComponentTransition(animation: .curve(duration: 0.15, curve: .easeInOut))
transition.setScale(layer: self.layer, scale: topScale)
} else {
let t = self.layer.presentation()?.transform ?? layer.transform
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
let transition = ComponentTransition(animation: .none)
transition.setScale(layer: self.layer, scale: 1.0)
self.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func buttonPressed() {
self.action?()
}
func update(size: CGSize, image: UIImage?, isSelected: Bool, isDestructive: Bool, isEnabled: Bool, title: String, transition: ComponentTransition) {
let contentParams = ContentParams(size: size, image: image, isSelected: isSelected, isDestructive: isDestructive, isEnabled: isEnabled)
if self.contentParams != contentParams {
self.contentParams = contentParams
self.updateContent(contentParams: contentParams, transition: transition)
}
self.isUserInteractionEnabled = isEnabled
transition.setFrame(view: self.contentView, frame: CGRect(origin: CGPoint(), size: size))
let textSize = self.textView.update(string: title, fontSize: 13.0, fontWeight: 0.0, color: .white, constrainedWidth: 100.0, transition: .immediate)
self.textView.frame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) * 0.5), y: size.height + 4.0), size: textSize)
}
private func updateContent(contentParams: ContentParams, transition: ComponentTransition) {
let image = generateImage(contentParams.size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
if contentParams.isDestructive {
context.setFillColor(UIColor(rgb: 0xFF3B30).cgColor)
} else {
context.setFillColor(UIColor(white: 1.0, alpha: contentParams.isSelected ? 1.0 : 0.0).cgColor)
}
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
if let image = contentParams.image, let cgImage = image.cgImage {
let imageSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8)
let imageFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) * 0.5), y: floor((size.height - imageSize.height) * 0.5)), size: imageSize)
context.saveGState()
context.translateBy(x: imageFrame.midX, y: imageFrame.midY)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -imageFrame.midX, y: -imageFrame.midY)
context.clip(to: imageFrame, mask: cgImage)
context.setBlendMode(contentParams.isSelected ? .copy : .normal)
context.setFillColor(contentParams.isSelected ? UIColor(white: 1.0, alpha: contentParams.isEnabled ? 0.0 : 0.5).cgColor : UIColor(white: 1.0, alpha: contentParams.isEnabled ? 1.0 : 0.5).cgColor)
context.fill(imageFrame)
context.resetClip()
context.restoreGState()
}
})
if !transition.animation.isImmediate, let currentContentViewIsSelected = self.currentContentViewIsSelected, currentContentViewIsSelected != contentParams.isSelected, let previousImage = self.contentView.image {
self.contentView.layer.mask = nil
let previousContentView = UIImageView(image: previousImage)
previousContentView.frame = self.contentView.frame
self.addSubview(previousContentView)
let animationDuration = 0.16
let animationTimingFunction: String = CAMediaTimingFunctionName.linear.rawValue
if contentParams.isSelected {
let maskLayer = CAShapeLayer()
maskLayer.frame = self.contentView.bounds
maskLayer.path = UIBezierPath(ovalIn: self.contentView.bounds).cgPath
maskLayer.strokeColor = UIColor.black.cgColor
maskLayer.fillColor = nil
maskLayer.lineWidth = 1.0
self.contentView.layer.mask = maskLayer
maskLayer.animate(from: 0.0 as NSNumber, to: contentParams.size.width as NSNumber, keyPath: "lineWidth", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak self, weak maskLayer] _ in
guard let self, let maskLayer, self.contentView.layer.mask === maskLayer else {
return
}
self.contentView.layer.mask = nil
})
let previousMaskLayer = CAShapeLayer()
previousMaskLayer.frame = previousContentView.bounds
previousMaskLayer.path = UIBezierPath(ovalIn: previousContentView.bounds).cgPath
previousMaskLayer.strokeColor = nil
previousMaskLayer.fillColor = UIColor.black.cgColor
previousContentView.layer.mask = previousMaskLayer
previousMaskLayer.animate(from: 1.0 as NSNumber, to: 0.0001 as NSNumber, keyPath: "transform.scale", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak previousContentView] _ in
previousContentView?.removeFromSuperview()
})
} else {
let maskLayer = CAShapeLayer()
maskLayer.frame = self.contentView.bounds
maskLayer.path = UIBezierPath(ovalIn: self.contentView.bounds).cgPath
maskLayer.strokeColor = nil
maskLayer.fillColor = UIColor.black.cgColor
self.contentView.layer.mask = maskLayer
maskLayer.animate(from: 0.0001 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak self, weak maskLayer] _ in
guard let self, let maskLayer, self.contentView.layer.mask === maskLayer else {
return
}
self.contentView.layer.mask = nil
})
let previousMaskLayer = CAShapeLayer()
previousMaskLayer.frame = previousContentView.bounds
previousMaskLayer.path = UIBezierPath(ovalIn: previousContentView.bounds).cgPath
previousMaskLayer.strokeColor = UIColor.black.cgColor
previousMaskLayer.fillColor = nil
previousMaskLayer.lineWidth = 1.0
previousContentView.layer.mask = previousMaskLayer
previousMaskLayer.animate(from: contentParams.size.width as NSNumber, to: 0.0 as NSNumber, keyPath: "lineWidth", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak previousContentView] _ in
previousContentView?.removeFromSuperview()
})
}
}
self.contentView.image = image
self.currentContentViewIsSelected = contentParams.isSelected
}
}
@@ -0,0 +1,171 @@
import Foundation
import UIKit
import Display
import ComponentFlow
final class EmojiExpandedInfoView: OverlayMaskContainerView {
private struct Params: Equatable {
var width: CGFloat
init(width: CGFloat) {
self.width = width
}
}
private struct Layout: Equatable {
var params: Params
var size: CGSize
init(params: Params, size: CGSize) {
self.params = params
self.size = size
}
}
private let title: String
private let text: String
private let backgroundView: UIImageView
private let separatorLayer: SimpleLayer
private let titleView: TextView
private let textView: TextView
private let actionButton: HighlightTrackingButton
private let actionTitleView: TextView
private var currentLayout: Layout?
var closeAction: (() -> Void)?
init(title: String, text: String) {
self.title = title
self.text = text
self.backgroundView = UIImageView()
let cornerRadius: CGFloat = 18.0
let buttonHeight: CGFloat = 56.0
self.backgroundView.image = generateImage(CGSize(width: cornerRadius * 2.0 + 10.0, height: cornerRadius + 10.0 + buttonHeight), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: cornerRadius).cgPath)
context.setFillColor(UIColor.white.cgColor)
context.fillPath()
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(CGRect(origin: CGPoint(x: 0.0, y: size.height - buttonHeight), size: CGSize(width: size.width, height: UIScreenPixel)))
})?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5)
self.separatorLayer = SimpleLayer()
self.titleView = TextView()
self.textView = TextView()
self.actionButton = HighlightTrackingButton()
self.actionTitleView = TextView()
self.actionTitleView.isUserInteractionEnabled = false
super.init(frame: CGRect())
self.maskContents.addSubview(self.backgroundView)
self.layer.addSublayer(self.separatorLayer)
self.addSubview(self.titleView)
self.addSubview(self.textView)
self.addSubview(self.actionButton)
self.actionButton.addSubview(self.actionTitleView)
self.actionButton.internalHighligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.actionButton.layer.removeAnimation(forKey: "sublayerTransform")
let transition = ComponentTransition(animation: .curve(duration: 0.15, curve: .easeInOut))
transition.setScale(layer: self.actionButton.layer, scale: topScale)
} else {
let t = self.actionButton.layer.presentation()?.transform ?? layer.transform
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
let transition = ComponentTransition(animation: .none)
transition.setScale(layer: self.actionButton.layer, scale: 1.0)
self.actionButton.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.actionButton.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), for: .touchUpInside)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func actionButtonPressed() {
self.closeAction?()
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.closeAction?()
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let result = self.actionButton.hitTest(self.convert(point, to: self.actionButton), with: event) {
return result
}
return nil
}
func update(width: CGFloat, transition: ComponentTransition) -> CGSize {
let params = Params(width: width)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.size
}
let size = self.update(params: params, transition: transition)
self.currentLayout = Layout(params: params, size: size)
return size
}
private func update(params: Params, transition: ComponentTransition) -> CGSize {
let buttonHeight: CGFloat = 56.0
let titleSize = self.titleView.update(string: self.title, fontSize: 16.0, fontWeight: 0.3, alignment: .center, color: .white, constrainedWidth: params.width - 16.0 * 2.0, transition: transition)
let textSize = self.textView.update(string: self.text, fontSize: 16.0, fontWeight: 0.0, alignment: .center, color: .white, constrainedWidth: params.width - 16.0 * 2.0, transition: transition)
let contentHeight = 78.0 + titleSize.height + 10.0 + textSize.height + 22.0 + buttonHeight
let size = CGSize(width: params.width, height: contentHeight)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: 78.0), size: titleSize)
transition.setFrame(view: self.titleView, frame: titleFrame)
let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) * 0.5), y: titleFrame.maxY + 10.0), size: textSize)
transition.setFrame(view: self.textView, frame: textFrame)
let buttonFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - buttonHeight), size: CGSize(width: size.width, height: buttonHeight))
transition.setFrame(view: self.actionButton, frame: buttonFrame)
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - buttonHeight), size: CGSize(width: size.width, height: UIScreenPixel)))
let actionTitleSize = self.actionTitleView.update(string: "OK", fontSize: 19.0, fontWeight: 0.3, color: .white, constrainedWidth: size.width, transition: transition)
let actionTitleFrame = CGRect(origin: CGPoint(x: floor((buttonFrame.width - actionTitleSize.width) * 0.5), y: floor((buttonFrame.height - actionTitleSize.height) * 0.5)), size: actionTitleSize)
transition.setFrame(view: self.actionTitleView, frame: actionTitleFrame)
return size
}
}
@@ -0,0 +1,136 @@
import Foundation
import UIKit
import Display
import AppBundle
private func addRoundedRectPath(context: CGContext, rect: CGRect, radius: CGFloat) {
context.saveGState()
context.translateBy(x: rect.minX, y: rect.minY)
context.scaleBy(x: radius, y: radius)
let fw = rect.width / radius
let fh = rect.height / radius
context.move(to: CGPoint(x: fw, y: fh / 2.0))
context.addArc(tangent1End: CGPoint(x: fw, y: fh), tangent2End: CGPoint(x: fw/2, y: fh), radius: 1.0)
context.addArc(tangent1End: CGPoint(x: 0, y: fh), tangent2End: CGPoint(x: 0, y: fh/2), radius: 1)
context.addArc(tangent1End: CGPoint(x: 0, y: 0), tangent2End: CGPoint(x: fw/2, y: 0), radius: 1)
context.addArc(tangent1End: CGPoint(x: fw, y: 0), tangent2End: CGPoint(x: fw, y: fh/2), radius: 1)
context.closePath()
context.restoreGState()
}
final class EmojiTooltipView: OverlayMaskContainerView {
private struct Params: Equatable {
var constrainedWidth: CGFloat
var subjectWidth: CGFloat
init(constrainedWidth: CGFloat, subjectWidth: CGFloat) {
self.constrainedWidth = constrainedWidth
self.subjectWidth = subjectWidth
}
}
private struct Layout {
var params: Params
var size: CGSize
init(params: Params, size: CGSize) {
self.params = params
self.size = size
}
}
private let text: String
private let backgroundView: UIImageView
private let iconView: UIImageView
private let textView: TextView
private var currentLayout: Layout?
init(text: String) {
self.text = text
self.backgroundView = UIImageView()
self.iconView = UIImageView()
self.textView = TextView()
super.init(frame: CGRect())
self.maskContents.addSubview(self.backgroundView)
self.addSubview(self.iconView)
self.addSubview(self.textView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func animateIn() {
let anchorPoint = CGPoint(x: self.bounds.width * 0.5, y: 0.0)
self.layer.animateSpring(from: 0.001 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5)
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: anchorPoint.x - self.bounds.width * 0.5, y: anchorPoint.y - self.bounds.height * 0.5)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.5, additive: true)
}
func animateOut(completion: @escaping () -> Void) {
let anchorPoint = CGPoint(x: self.bounds.width * 0.5, y: 0.0)
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
completion()
})
self.layer.animateScale(from: 1.0, to: 0.4, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.layer.animatePosition(from: CGPoint(), to: CGPoint(x: anchorPoint.x - self.bounds.width * 0.5, y: anchorPoint.y - self.bounds.height * 0.5), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true)
}
func update(constrainedWidth: CGFloat, subjectWidth: CGFloat) -> CGSize {
let params = Params(constrainedWidth: constrainedWidth, subjectWidth: subjectWidth)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.size
}
let size = self.update(params: params)
self.currentLayout = Layout(params: params, size: size)
return size
}
private func update(params: Params) -> CGSize {
let horizontalInset: CGFloat = 13.0
let verticalInset: CGFloat = 10.0
let arrowHeight: CGFloat = 8.0
let iconSpacing: CGFloat = 5.0
if self.iconView.image == nil {
self.iconView.image = UIImage(bundleImageName: "Call/EmojiTooltipLock")?.withRenderingMode(.alwaysTemplate)
self.iconView.tintColor = .white
}
let iconSize = self.iconView.image?.size ?? CGSize(width: 12.0, height: 12.0)
let textSize = self.textView.update(
string: self.text,
fontSize: 15.0,
fontWeight: 0.0,
color: .white,
constrainedWidth: params.constrainedWidth - horizontalInset * 2.0,
transition: .immediate
)
let size = CGSize(width: iconSize.width + iconSpacing + textSize.width + horizontalInset * 2.0, height: arrowHeight + textSize.height + verticalInset * 2.0)
self.iconView.frame = CGRect(origin: CGPoint(x: horizontalInset, y: arrowHeight + verticalInset + floorToScreenPixels((textSize.height - iconSize.height) * 0.5)), size: iconSize)
self.textView.frame = CGRect(origin: CGPoint(x: horizontalInset + iconSize.width + iconSpacing, y: arrowHeight + verticalInset), size: textSize)
self.backgroundView.image = generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor.white.cgColor)
addRoundedRectPath(context: context, rect: CGRect(origin: CGPoint(x: 0.0, y: arrowHeight), size: CGSize(width: size.width, height: size.height - arrowHeight)), radius: 14.0)
context.fillPath()
context.translateBy(x: size.width * 0.5 - 10.0, y: 0.0)
let _ = try? drawSvgPath(context, path: "M9.0981,1.1979 C9.547,0.6431 10.453,0.6431 10.9019,1.1979 C12.4041,3.0542 15.6848,6.5616 20,8 H-0.0002 C4.3151,6.5616 7.5959,3.0542 9.0981,1.1978 Z ")
})
self.backgroundView.frame = CGRect(origin: CGPoint(), size: size)
return size
}
}
@@ -0,0 +1,179 @@
import Foundation
import UIKit
import Display
import ComponentFlow
final class KeyEmojiView: HighlightTrackingButton {
private struct Params: Equatable {
var isExpanded: Bool
init(isExpanded: Bool) {
self.isExpanded = isExpanded
}
}
private struct Layout: Equatable {
var params: Params
var size: CGSize
init(params: Params, size: CGSize) {
self.params = params
self.size = size
}
}
private let emoji: [String]
private let emojiViews: [TextView]
var pressAction: (() -> Void)?
private var currentLayout: Layout?
var isExpanded: Bool? {
return self.currentLayout?.params.isExpanded
}
init(emoji: [String]) {
self.emoji = emoji
self.emojiViews = emoji.map { _ in
TextView()
}
super.init(frame: CGRect())
for emojiView in self.emojiViews {
emojiView.contentMode = .scaleToFill
emojiView.isUserInteractionEnabled = false
self.addSubview(emojiView)
}
self.internalHighligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "opacity")
self.layer.removeAnimation(forKey: "transform")
let transition = ComponentTransition(animation: .curve(duration: 0.15, curve: .easeInOut))
transition.setScale(layer: self.layer, scale: topScale)
} else {
let t = self.layer.presentation()?.transform ?? layer.transform
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
let transition = ComponentTransition(animation: .none)
transition.setScale(layer: self.layer, scale: 1.0)
self.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
@objc private func pressed() {
self.pressAction?()
}
func animateIn() {
for i in 0 ..< self.emojiViews.count {
let emojiView = self.emojiViews[i]
emojiView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
//emojiView.layer.animateScale(from: 0.2, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
}
func update(isExpanded: Bool, transition: ComponentTransition) -> CGSize {
let params = Params(isExpanded: isExpanded)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.size
}
let size = self.update(params: params, transition: transition)
self.currentLayout = Layout(params: params, size: size)
return size
}
private func update(params: Params, transition: ComponentTransition) -> CGSize {
let itemSpacing: CGFloat = 0.0
var height: CGFloat = 0.0
var nextX = 0.0
for i in 0 ..< self.emojiViews.count {
if nextX != 0.0 {
nextX += itemSpacing
}
let emojiView = self.emojiViews[i]
let itemSize = emojiView.update(string: emoji[i], fontSize: params.isExpanded ? 40.0 : 15.0, fontWeight: 0.0, color: .white, constrainedWidth: 100.0, transition: transition)
if height == 0.0 {
height = itemSize.height
}
let itemFrame = CGRect(origin: CGPoint(x: nextX, y: 0.0), size: itemSize)
transition.setFrame(view: emojiView, frame: itemFrame)
nextX += itemSize.width
}
return CGSize(width: nextX, height: height)
}
}
func generateParabollicMotionKeyframes(from sourcePoint: CGPoint, to targetPosition: CGPoint, elevation: CGFloat, duration: Double, curve: ComponentTransition.Animation.Curve, reverse: Bool) -> [CGPoint] {
let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - elevation)
let x1 = sourcePoint.x
let y1 = sourcePoint.y
let x2 = midPoint.x
let y2 = midPoint.y
let x3 = targetPosition.x
let y3 = targetPosition.y
let numPoints: Int = Int(ceil(Double(UIScreen.main.maximumFramesPerSecond) * duration))
var keyframes: [CGPoint] = []
if abs(y1 - y3) < 5.0 || abs(x1 - x3) < 5.0 {
for rawI in 0 ..< numPoints {
let i = reverse ? (numPoints - 1 - rawI) : rawI
let ks = CGFloat(i) / CGFloat(numPoints - 1)
var k = curve.solve(at: reverse ? (1.0 - ks) : ks)
if reverse {
k = 1.0 - k
}
let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k
let y = sourcePoint.y * (1.0 - k) + targetPosition.y * k
keyframes.append(CGPoint(x: x, y: y))
}
} else {
let a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
let b = (x1 * x1 * (y2 - y3) + x3 * x3 * (y1 - y2) + x2 * x2 * (y3 - y1)) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
let c = (x2 * x2 * (x3 * y1 - x1 * y3) + x2 * (x1 * x1 * y3 - x3 * x3 * y1) + x1 * x3 * (x3 - x1) * y2) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
for rawI in 0 ..< numPoints {
let i = reverse ? (numPoints - 1 - rawI) : rawI
let ks = CGFloat(i) / CGFloat(numPoints - 1)
var k = curve.solve(at: reverse ? (1.0 - ks) : ks)
if reverse {
k = 1.0 - k
}
let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k
let y = a * x * x + b * x + c
keyframes.append(CGPoint(x: x, y: y))
}
}
return keyframes
}
@@ -0,0 +1,89 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AppBundle
final class NoticeView: OverlayMaskContainerView {
private let backgroundView: RoundedCornersView
private let textContainer: UIView
private let iconView: UIImageView
private let textView: TextView
override init(frame: CGRect) {
self.backgroundView = RoundedCornersView(color: .white)
self.textContainer = UIView()
self.textContainer.clipsToBounds = true
self.iconView = UIImageView()
self.textView = TextView()
super.init(frame: frame)
self.clipsToBounds = true
self.maskContents.addSubview(self.backgroundView)
self.textContainer.addSubview(self.iconView)
self.textContainer.addSubview(self.textView)
self.addSubview(self.textContainer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func animateIn() {
let delay: Double = 0.2
self.layer.animateScale(from: 0.001, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
self.textView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: delay)
self.backgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
self.backgroundView.layer.animateFrame(from: CGRect(origin: CGPoint(x: (self.bounds.width - self.bounds.height) * 0.5, y: 0.0), size: CGSize(width: self.bounds.height, height: self.bounds.height)), to: self.backgroundView.frame, duration: 0.5, delay: delay, timingFunction: kCAMediaTimingFunctionSpring)
self.iconView.layer.animatePosition(from: CGPoint(x: -4.0, y: 0.0), to: CGPoint(), duration: 0.15, delay: delay, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
//self.textContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, delay: delay)
self.textContainer.layer.cornerRadius = self.bounds.height * 0.5
self.textContainer.layer.animateFrame(from: CGRect(origin: CGPoint(x: (self.bounds.width - self.bounds.height) * 0.5, y: 0.0), size: CGSize(width: self.bounds.height, height: self.bounds.height)), to: self.textContainer.frame, duration: 0.5, delay: delay, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.textContainer.layer.cornerRadius = 0.0
})
}
func animateOut(completion: @escaping () -> Void) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
completion()
})
self.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
}
func update(icon: String, text: String, constrainedWidth: CGFloat, transition: ComponentTransition) -> CGSize {
let sideInset: CGFloat = 12.0
let verticalInset: CGFloat = 6.0
let iconSpacing: CGFloat = -3.0
if self.iconView.image == nil {
self.iconView.image = UIImage(bundleImageName: icon)?.withRenderingMode(.alwaysTemplate)
self.iconView.tintColor = .white
}
let iconSize = self.iconView.image?.size ?? CGSize(width: 12.0, height: 12.0)
let textSize = self.textView.update(string: text, fontSize: 15.0, fontWeight: 0.0, color: .white, constrainedWidth: constrainedWidth - sideInset * 2.0, transition: .immediate)
let size = CGSize(width: iconSize.width + iconSpacing + textSize.width + sideInset * 2.0, height: textSize.height + verticalInset * 2.0)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundView.update(cornerRadius: floor(size.height * 0.5), transition: transition)
transition.setFrame(view: self.textContainer, frame: CGRect(origin: CGPoint(), size: size))
transition.setFrame(view: self.iconView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.height - iconSize.height) * 0.5) + 4.0, y: verticalInset + floorToScreenPixels((textSize.height - iconSize.height) * 0.5)), size: iconSize))
transition.setFrame(view: self.textView, frame: CGRect(origin: CGPoint(x: sideInset + iconSize.width + iconSpacing, y: verticalInset), size: textSize))
return size
}
}
@@ -0,0 +1,239 @@
import Foundation
import AVKit
import AVFoundation
import CoreMedia
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
private func sampleBufferFromPixelBuffer(pixelBuffer: CVPixelBuffer) -> CMSampleBuffer? {
var maybeFormat: CMVideoFormatDescription?
let status = CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &maybeFormat)
if status != noErr {
return nil
}
guard let format = maybeFormat else {
return nil
}
var timingInfo = CMSampleTimingInfo(
duration: CMTimeMake(value: 1, timescale: 30),
presentationTimeStamp: CMTimeMake(value: 0, timescale: 30),
decodeTimeStamp: CMTimeMake(value: 0, timescale: 30)
)
var maybeSampleBuffer: CMSampleBuffer?
let bufferStatus = CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: format, sampleTiming: &timingInfo, sampleBufferOut: &maybeSampleBuffer)
if (bufferStatus != noErr) {
return nil
}
guard let sampleBuffer = maybeSampleBuffer else {
return nil
}
let attachments: NSArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true)! as NSArray
let dict: NSMutableDictionary = attachments[0] as! NSMutableDictionary
dict[kCMSampleAttachmentKey_DisplayImmediately as NSString] = true as NSNumber
return sampleBuffer
}
final class PrivateCallPictureInPictureView: UIView {
private final class SampleBufferView: UIView {
override static var layerClass: AnyClass {
return AVSampleBufferDisplayLayer.self
}
}
private final class AnimationTrackingLayer: SimpleLayer {
var onAnimation: ((CAAnimation) -> Void)?
override func add(_ anim: CAAnimation, forKey key: String?) {
super.add(anim, forKey: key)
if key == "bounds" {
self.onAnimation?(anim)
}
}
}
private final class AnimationTrackingView: UIView {
override static var layerClass: AnyClass {
return AnimationTrackingLayer.self
}
var onAnimation: ((CAAnimation) -> Void)? {
didSet {
(self.layer as? AnimationTrackingLayer)?.onAnimation = self.onAnimation
}
}
}
private let animationTrackingView: AnimationTrackingView
private let videoContainerView: UIView
private let sampleBufferView: SampleBufferView
private var videoMetrics: VideoContainerView.VideoMetrics?
private var videoDisposable: Disposable?
var isRenderingEnabled: Bool = false {
didSet {
if self.isRenderingEnabled != oldValue {
self.updateContents()
}
}
}
var video: VideoSource? {
didSet {
if self.video !== oldValue {
self.videoDisposable?.dispose()
if let video = self.video {
self.videoDisposable = video.addOnUpdated({ [weak self] in
guard let self else {
return
}
if self.isRenderingEnabled {
self.updateContents()
}
})
}
}
}
}
override var frame: CGRect {
didSet {
if !self.bounds.isEmpty {
self.updateLayout(size: self.bounds.size)
}
}
}
override static var layerClass: AnyClass {
return AVSampleBufferDisplayLayer.self
}
override init(frame: CGRect) {
self.animationTrackingView = AnimationTrackingView()
self.videoContainerView = UIView()
self.sampleBufferView = SampleBufferView()
super.init(frame: frame)
self.addSubview(self.animationTrackingView)
self.backgroundColor = .black
self.videoContainerView.addSubview(self.sampleBufferView)
self.addSubview(self.videoContainerView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateContents() {
guard let video = self.video, let currentOutput = video.currentOutput else {
return
}
guard let pixelBuffer = currentOutput.dataBuffer.pixelBuffer else {
return
}
let videoMetrics = VideoContainerView.VideoMetrics(resolution: currentOutput.resolution, rotationAngle: currentOutput.rotationAngle, followsDeviceOrientation: currentOutput.followsDeviceOrientation, sourceId: currentOutput.sourceId)
if self.videoMetrics != videoMetrics {
self.videoMetrics = videoMetrics
self.setNeedsLayout()
}
if let sampleBuffer = sampleBufferFromPixelBuffer(pixelBuffer: pixelBuffer) {
(self.sampleBufferView.layer as? AVSampleBufferDisplayLayer)?.enqueue(sampleBuffer)
}
}
func updateLayout(size: CGSize) {
if size.width.isZero || size.height.isZero {
return
}
var interfaceOrientation: UIInterfaceOrientation = .portrait
switch UIDevice.current.orientation {
case .portrait:
interfaceOrientation = .portrait
case .landscapeLeft:
interfaceOrientation = .landscapeLeft
case .landscapeRight:
interfaceOrientation = .landscapeRight
case .portraitUpsideDown:
interfaceOrientation = .portraitUpsideDown
default:
break
}
if let videoMetrics = self.videoMetrics {
let resolvedRotationAngle = resolveCallVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: interfaceOrientation)
var rotatedResolution = videoMetrics.resolution
var videoIsRotated = false
if resolvedRotationAngle == Float.pi * 0.5 || resolvedRotationAngle == Float.pi * 3.0 / 2.0 {
rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width)
videoIsRotated = true
}
var videoSize = rotatedResolution.aspectFitted(size)
let boundingAspectRatio = size.width / size.height
let videoAspectRatio = videoSize.width / videoSize.height
let isFillingBounds = abs(boundingAspectRatio - videoAspectRatio) < 0.15
if isFillingBounds {
videoSize = rotatedResolution.aspectFilled(size)
}
let rotatedBoundingSize = videoIsRotated ? CGSize(width: size.height, height: size.width) : size
let rotatedVideoSize = videoIsRotated ? CGSize(width: videoSize.height, height: videoSize.width) : videoSize
let videoFrame = rotatedVideoSize.centered(around: CGPoint(x: rotatedBoundingSize.width * 0.5, y: rotatedBoundingSize.height * 0.5))
let apply: () -> Void = {
self.videoContainerView.center = CGPoint(x: size.width * 0.5, y: size.height * 0.5)
self.videoContainerView.bounds = CGRect(origin: CGPoint(), size: rotatedBoundingSize)
self.videoContainerView.transform = CGAffineTransformMakeRotation(CGFloat(resolvedRotationAngle))
self.sampleBufferView.center = videoFrame.center
self.sampleBufferView.bounds = CGRect(origin: CGPoint(), size: videoFrame.size)
if let sublayers = self.sampleBufferView.layer.sublayers {
if sublayers.count > 1, !sublayers[0].bounds.isEmpty {
sublayers[0].bounds = CGRect(origin: CGPoint(), size: videoFrame.size)
sublayers[0].position = CGPoint(x: videoFrame.width * 0.5, y: videoFrame.height * 0.5)
}
}
}
apply()
}
}
}
@available(iOS 15.0, *)
final class PrivateCallPictureInPictureController: AVPictureInPictureVideoCallViewController {
var pipView: PrivateCallPictureInPictureView?
override func viewDidLoad() {
super.viewDidLoad()
if let pipView = self.pipView {
pipView.updateLayout(size: self.view.bounds.size)
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { context in
self.pipView?.frame = CGRect(origin: CGPoint(), size: size)
})
}
}
@@ -0,0 +1,348 @@
import Foundation
import UIKit
import MetalKit
import MetalPerformanceShaders
import Accelerate
import MetalEngine
private func makeSharpenKernel(device: MTLDevice, sharpeningStrength: Float) -> MPSImageConvolution {
let centerWeight = 1.0 + 6.0 * sharpeningStrength
let adjacentWeight = -1.0 * sharpeningStrength
let diagonalWeight = -0.5 * sharpeningStrength
let sharpenWeights: [Float] = [
diagonalWeight, adjacentWeight, diagonalWeight,
adjacentWeight, centerWeight, adjacentWeight,
diagonalWeight, adjacentWeight, diagonalWeight
]
let result = MPSImageConvolution(device: device, kernelWidth: 3, kernelHeight: 3, weights: sharpenWeights)
result.edgeMode = .clamp
return result
}
public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
public var internalData: MetalEngineSubjectInternalData?
public let blurredLayer: MetalEngineSubjectLayer
final class BlurState: ComputeState {
let computePipelineStateYUVBiPlanarToRGBA: MTLComputePipelineState
let computePipelineStateYUVTriPlanarToRGBA: MTLComputePipelineState
let computePipelineStateHorizontal: MTLComputePipelineState
let computePipelineStateVertical: MTLComputePipelineState
let downscaleKernel: MPSImageBilinearScale
var sharpeningStrength: Float = 0.0
var sharpenKernel: MPSImageConvolution
required init?(device: MTLDevice) {
guard let library = metalLibrary(device: device) else {
return nil
}
guard let functionVideoBiPlanarToRGBA = library.makeFunction(name: "videoBiPlanarToRGBA") else {
return nil
}
guard let computePipelineStateYUVBiPlanarToRGBA = try? device.makeComputePipelineState(function: functionVideoBiPlanarToRGBA) else {
return nil
}
self.computePipelineStateYUVBiPlanarToRGBA = computePipelineStateYUVBiPlanarToRGBA
guard let functionVideoTriPlanarToRGBA = library.makeFunction(name: "videoTriPlanarToRGBA") else {
return nil
}
guard let computePipelineStateYUVTriPlanarToRGBA = try? device.makeComputePipelineState(function: functionVideoTriPlanarToRGBA) else {
return nil
}
self.computePipelineStateYUVTriPlanarToRGBA = computePipelineStateYUVTriPlanarToRGBA
guard let gaussianBlurHorizontal = library.makeFunction(name: "gaussianBlurHorizontal"), let gaussianBlurVertical = library.makeFunction(name: "gaussianBlurVertical") else {
return nil
}
guard let computePipelineStateHorizontal = try? device.makeComputePipelineState(function: gaussianBlurHorizontal) else {
return nil
}
self.computePipelineStateHorizontal = computePipelineStateHorizontal
guard let computePipelineStateVertical = try? device.makeComputePipelineState(function: gaussianBlurVertical) else {
return nil
}
self.computePipelineStateVertical = computePipelineStateVertical
self.downscaleKernel = MPSImageBilinearScale(device: device)
self.sharpeningStrength = 1.4
self.sharpenKernel = makeSharpenKernel(device: device, sharpeningStrength: self.sharpeningStrength)
}
func updateSharpeningStrength(device: MTLDevice, sharpeningStrength: Float) {
self.sharpeningStrength = sharpeningStrength
self.sharpenKernel = makeSharpenKernel(device: device, sharpeningStrength: self.sharpeningStrength)
}
}
final class RenderState: RenderToLayerState {
let pipelineState: MTLRenderPipelineState
required init?(device: MTLDevice) {
guard let library = metalLibrary(device: device) else {
return nil
}
guard let vertexFunction = library.makeFunction(name: "mainVideoVertex"), let fragmentFunction = library.makeFunction(name: "mainVideoFragment") else {
return nil
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
guard let pipelineState = try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) else {
return nil
}
self.pipelineState = pipelineState
}
}
public var video: VideoSource.Output? {
didSet {
self.setNeedsUpdate()
}
}
private let enableSharpening: Bool
public var renderSpec: RenderLayerSpec?
private var rgbaTexture: PooledTexture?
private var sharpenedTexture: PooledTexture?
private var downscaledTexture: PooledTexture?
private var blurredHorizontalTexture: PooledTexture?
private var blurredVerticalTexture: PooledTexture?
public init(enableSharpening: Bool) {
self.enableSharpening = enableSharpening
self.blurredLayer = MetalEngineSubjectLayer()
super.init()
}
override public init(layer: Any) {
self.enableSharpening = false
self.blurredLayer = MetalEngineSubjectLayer()
super.init(layer: layer)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func update(context: MetalEngineSubjectContext) {
if self.isHidden {
return
}
guard let renderSpec = self.renderSpec else {
return
}
guard let videoTextures = self.video else {
return
}
let rgbaTextureSpec = TextureSpec(width: Int(videoTextures.resolution.width), height: Int(videoTextures.resolution.height), pixelFormat: .rgba8UnsignedNormalized)
if self.rgbaTexture == nil || self.rgbaTexture?.spec != rgbaTextureSpec {
self.rgbaTexture = MetalEngine.shared.pooledTexture(spec: rgbaTextureSpec)
}
if self.sharpenedTexture == nil || self.sharpenedTexture?.spec != rgbaTextureSpec {
self.sharpenedTexture = MetalEngine.shared.pooledTexture(spec: rgbaTextureSpec)
}
if self.downscaledTexture == nil {
self.downscaledTexture = MetalEngine.shared.pooledTexture(spec: TextureSpec(width: 128, height: 128, pixelFormat: .rgba8UnsignedNormalized))
}
if self.blurredHorizontalTexture == nil {
self.blurredHorizontalTexture = MetalEngine.shared.pooledTexture(spec: TextureSpec(width: 128, height: 128, pixelFormat: .rgba8UnsignedNormalized))
}
if self.blurredVerticalTexture == nil {
self.blurredVerticalTexture = MetalEngine.shared.pooledTexture(spec: TextureSpec(width: 128, height: 128, pixelFormat: .rgba8UnsignedNormalized))
}
guard let rgbaTexture = self.rgbaTexture?.get(context: context) else {
return
}
var outputTexture = rgbaTexture
var sharpenedTexture: TexturePlaceholder?
if self.enableSharpening && rgbaTextureSpec.width * rgbaTextureSpec.height >= 800 * 480 {
sharpenedTexture = self.sharpenedTexture?.get(context: context)
if let sharpenedTexture {
outputTexture = sharpenedTexture
}
}
if let sharpenedTexture {
let _ = context.compute(state: BlurState.self, inputs: rgbaTexture.placeholer, sharpenedTexture.placeholer, commands: { commandBuffer, blurState, rgbaTexture, sharpenedTexture in
guard let rgbaTexture else {
return
}
guard let sharpenedTexture else {
return
}
do {
guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
return
}
let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1)
let threadgroupCount = MTLSize(width: (rgbaTexture.width + threadgroupSize.width - 1) / threadgroupSize.width, height: (rgbaTexture.height + threadgroupSize.height - 1) / threadgroupSize.height, depth: 1)
switch videoTextures.textureLayout {
case let .biPlanar(biPlanar):
computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVBiPlanarToRGBA)
computeEncoder.setTexture(biPlanar.y, index: 0)
computeEncoder.setTexture(biPlanar.uv, index: 1)
computeEncoder.setTexture(rgbaTexture, index: 2)
case let .triPlanar(triPlanar):
computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVTriPlanarToRGBA)
computeEncoder.setTexture(triPlanar.y, index: 0)
computeEncoder.setTexture(triPlanar.u, index: 1)
computeEncoder.setTexture(triPlanar.u, index: 2)
computeEncoder.setTexture(rgbaTexture, index: 3)
}
computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize)
computeEncoder.endEncoding()
}
do {
blurState.sharpenKernel.encode(commandBuffer: commandBuffer, sourceTexture: rgbaTexture, destinationTexture: sharpenedTexture)
}
})
} else {
let _ = context.compute(state: BlurState.self, inputs: rgbaTexture.placeholer, commands: { commandBuffer, blurState, rgbaTexture in
guard let rgbaTexture else {
return
}
do {
guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
return
}
let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1)
let threadgroupCount = MTLSize(width: (rgbaTexture.width + threadgroupSize.width - 1) / threadgroupSize.width, height: (rgbaTexture.height + threadgroupSize.height - 1) / threadgroupSize.height, depth: 1)
switch videoTextures.textureLayout {
case let .biPlanar(biPlanar):
computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVBiPlanarToRGBA)
computeEncoder.setTexture(biPlanar.y, index: 0)
computeEncoder.setTexture(biPlanar.uv, index: 1)
computeEncoder.setTexture(rgbaTexture, index: 2)
case let .triPlanar(triPlanar):
computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVTriPlanarToRGBA)
computeEncoder.setTexture(triPlanar.y, index: 0)
computeEncoder.setTexture(triPlanar.u, index: 1)
computeEncoder.setTexture(triPlanar.u, index: 2)
computeEncoder.setTexture(rgbaTexture, index: 3)
}
computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize)
computeEncoder.endEncoding()
}
})
}
if !self.blurredLayer.isHidden {
guard let downscaledTexture = self.downscaledTexture?.get(context: context), let blurredHorizontalTexture = self.blurredHorizontalTexture?.get(context: context), let blurredVerticalTexture = self.blurredVerticalTexture?.get(context: context) else {
return
}
let blurredTexture = context.compute(state: BlurState.self, inputs: rgbaTexture.placeholer, downscaledTexture.placeholer, blurredHorizontalTexture.placeholer, blurredVerticalTexture.placeholer, commands: { commandBuffer, blurState, rgbaTexture, downscaledTexture, blurredHorizontalTexture, blurredVerticalTexture -> MTLTexture? in
guard let rgbaTexture, let downscaledTexture, let blurredHorizontalTexture, let blurredVerticalTexture else {
return nil
}
blurState.downscaleKernel.encode(commandBuffer: commandBuffer, sourceTexture: rgbaTexture, destinationTexture: downscaledTexture)
do {
guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
return nil
}
let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1)
let threadgroupCount = MTLSize(width: (downscaledTexture.width + threadgroupSize.width - 1) / threadgroupSize.width, height: (downscaledTexture.height + threadgroupSize.height - 1) / threadgroupSize.height, depth: 1)
computeEncoder.setComputePipelineState(blurState.computePipelineStateHorizontal)
computeEncoder.setTexture(downscaledTexture, index: 0)
computeEncoder.setTexture(blurredHorizontalTexture, index: 1)
computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize)
computeEncoder.setComputePipelineState(blurState.computePipelineStateVertical)
computeEncoder.setTexture(blurredHorizontalTexture, index: 0)
computeEncoder.setTexture(blurredVerticalTexture, index: 1)
computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize)
computeEncoder.endEncoding()
}
return blurredVerticalTexture
})
context.renderToLayer(spec: renderSpec, state: RenderState.self, layer: self.blurredLayer, inputs: blurredTexture, commands: { encoder, placement, blurredTexture in
guard let blurredTexture else {
return
}
let effectiveRect = placement.effectiveRect
var rect = SIMD4<Float>(Float(effectiveRect.minX), Float(effectiveRect.minY), Float(effectiveRect.width), Float(effectiveRect.height))
encoder.setVertexBytes(&rect, length: 4 * 4, index: 0)
var mirror = SIMD2<UInt32>(
videoTextures.mirrorDirection.contains(.horizontal) ? 1 : 0,
videoTextures.mirrorDirection.contains(.vertical) ? 1 : 0
)
encoder.setVertexBytes(&mirror, length: 2 * 4, index: 1)
encoder.setFragmentTexture(blurredTexture, index: 0)
var brightness: Float = 0.7
var saturation: Float = 1.3
var overlay: SIMD4<Float> = SIMD4<Float>(1.0, 1.0, 1.0, 0.2)
encoder.setFragmentBytes(&brightness, length: 4, index: 0)
encoder.setFragmentBytes(&saturation, length: 4, index: 1)
encoder.setFragmentBytes(&overlay, length: 4 * 4, index: 2)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
})
}
context.renderToLayer(spec: renderSpec, state: RenderState.self, layer: self, inputs: outputTexture.placeholer, commands: { encoder, placement, outputTexture in
guard let outputTexture else {
return
}
let effectiveRect = placement.effectiveRect
var rect = SIMD4<Float>(Float(effectiveRect.minX), Float(effectiveRect.minY), Float(effectiveRect.width), Float(effectiveRect.height))
encoder.setVertexBytes(&rect, length: 4 * 4, index: 0)
var mirror = SIMD2<UInt32>(
videoTextures.mirrorDirection.contains(.horizontal) ? 1 : 0,
videoTextures.mirrorDirection.contains(.vertical) ? 1 : 0
)
encoder.setVertexBytes(&mirror, length: 2 * 4, index: 1)
encoder.setFragmentTexture(outputTexture, index: 0)
var brightness: Float = 1.0
var saturation: Float = 1.0
var overlay: SIMD4<Float> = SIMD4<Float>()
encoder.setFragmentBytes(&brightness, length: 4, index: 0)
encoder.setFragmentBytes(&saturation, length: 4, index: 1)
encoder.setFragmentBytes(&overlay, length: 4 * 4, index: 2)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
})
}
}
@@ -0,0 +1,73 @@
import Foundation
import UIKit
import Display
import ComponentFlow
final class RatingView: OverlayMaskContainerView {
private let backgroundView: RoundedCornersView
private let textContainer: UIView
private let textView: TextView
override init(frame: CGRect) {
self.backgroundView = RoundedCornersView(color: .white)
self.textContainer = UIView()
self.textContainer.clipsToBounds = true
self.textView = TextView()
super.init(frame: frame)
self.clipsToBounds = true
self.maskContents.addSubview(self.backgroundView)
self.textContainer.addSubview(self.textView)
self.addSubview(self.textContainer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func animateIn() {
let delay: Double = 0.2
self.layer.animateScale(from: 0.001, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
self.textView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: delay)
self.backgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
self.backgroundView.layer.animateFrame(from: CGRect(origin: CGPoint(x: (self.bounds.width - self.bounds.height) * 0.5, y: 0.0), size: CGSize(width: self.bounds.height, height: self.bounds.height)), to: self.backgroundView.frame, duration: 0.5, delay: delay, timingFunction: kCAMediaTimingFunctionSpring)
self.textContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, delay: delay)
self.textContainer.layer.cornerRadius = self.bounds.height * 0.5
self.textContainer.layer.animateFrame(from: CGRect(origin: CGPoint(x: (self.bounds.width - self.bounds.height) * 0.5, y: 0.0), size: CGSize(width: self.bounds.height, height: self.bounds.height)), to: self.textContainer.frame, duration: 0.5, delay: delay, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.textContainer.layer.cornerRadius = 0.0
})
}
func animateOut(completion: @escaping () -> Void) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
completion()
})
self.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
}
func update(text: String, constrainedWidth: CGFloat, transition: ComponentTransition) -> CGSize {
let sideInset: CGFloat = 12.0
let verticalInset: CGFloat = 6.0
let textSize = self.textView.update(string: text, fontSize: 15.0, fontWeight: 0.0, color: .white, constrainedWidth: constrainedWidth - sideInset * 2.0, transition: .immediate)
let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height + verticalInset * 2.0)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundView.update(cornerRadius: floor(size.height * 0.5), transition: transition)
transition.setFrame(view: self.textContainer, frame: CGRect(origin: CGPoint(), size: size))
transition.setFrame(view: self.textView, frame: CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: textSize))
return size
}
}
@@ -0,0 +1,93 @@
import Foundation
import UIKit
import Display
import ComponentFlow
final class RoundedCornersView: UIImageView {
private let color: UIColor
private let smoothCorners: Bool
private var currentCornerRadius: CGFloat?
private var cornerImage: UIImage?
init(color: UIColor, smoothCorners: Bool = false) {
self.color = color
self.smoothCorners = smoothCorners
super.init(image: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyStaticCornerRadius() {
guard let cornerRadius = self.currentCornerRadius else {
return
}
if cornerRadius == 0.0 {
if let cornerImage = self.cornerImage, cornerImage.size.width == 1.0 {
} else {
self.cornerImage = generateImage(CGSize(width: 1.0, height: 1.0), rotatedContext: { size, context in
context.setFillColor(self.color.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
})?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5)
}
} else {
if self.smoothCorners {
let size = CGSize(width: cornerRadius * 2.0 + 10.0, height: cornerRadius * 2.0 + 10.0)
if let cornerImage = self.cornerImage, cornerImage.size == size {
} else {
self.cornerImage = generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: cornerRadius).cgPath)
context.setFillColor(self.color.cgColor)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5)
}
} else {
let size = CGSize(width: cornerRadius * 2.0, height: cornerRadius * 2.0)
if let cornerImage = self.cornerImage, cornerImage.size == size {
} else {
self.cornerImage = generateStretchableFilledCircleImage(diameter: size.width, color: self.color)
}
}
}
self.image = self.cornerImage
self.clipsToBounds = false
self.backgroundColor = nil
self.layer.cornerRadius = 0.0
}
func update(cornerRadius: CGFloat, transition: ComponentTransition) {
if self.currentCornerRadius == cornerRadius {
return
}
let previousCornerRadius = self.currentCornerRadius
self.currentCornerRadius = cornerRadius
if transition.animation.isImmediate {
self.applyStaticCornerRadius()
} else {
self.image = nil
self.clipsToBounds = true
self.backgroundColor = self.color
if let previousCornerRadius, self.layer.animation(forKey: "cornerRadius") == nil {
self.layer.cornerRadius = previousCornerRadius
}
if #available(iOS 13.0, *) {
if self.smoothCorners {
self.layer.cornerCurve = .continuous
} else {
self.layer.cornerCurve = .circular
}
}
transition.setCornerRadius(layer: self.layer, cornerRadius: cornerRadius, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.applyStaticCornerRadius()
})
}
}
}
@@ -0,0 +1,400 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
private func addRoundedRectPath(context: CGContext, rect: CGRect, radius: CGFloat) {
context.saveGState()
context.translateBy(x: rect.minX, y: rect.minY)
context.scaleBy(x: radius, y: radius)
let fw = rect.width / radius
let fh = rect.height / radius
context.move(to: CGPoint(x: fw, y: fh / 2.0))
context.addArc(tangent1End: CGPoint(x: fw, y: fh), tangent2End: CGPoint(x: fw/2, y: fh), radius: 1.0)
context.addArc(tangent1End: CGPoint(x: 0, y: fh), tangent2End: CGPoint(x: 0, y: fh/2), radius: 1)
context.addArc(tangent1End: CGPoint(x: 0, y: 0), tangent2End: CGPoint(x: fw/2, y: 0), radius: 1)
context.addArc(tangent1End: CGPoint(x: fw, y: 0), tangent2End: CGPoint(x: fw, y: fh/2), radius: 1)
context.closePath()
context.restoreGState()
}
private func stringForDuration(_ duration: Int) -> String {
let hours = duration / 3600
let minutes = duration / 60 % 60
let seconds = duration % 60
let durationString: String
if hours > 0 {
durationString = String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
durationString = String(format: "%d:%02d", minutes, seconds)
}
return durationString
}
private final class AnimatedDotsLayer: SimpleLayer {
private let dotLayers: [SimpleLayer]
let size: CGSize
override init() {
self.dotLayers = (0 ..< 3).map { _ in
SimpleLayer()
}
let dotSpacing: CGFloat = 1.0
let dotSize = CGSize(width: 5.0, height: 5.0)
self.size = CGSize(width: CGFloat(self.dotLayers.count) * dotSize.width + CGFloat(self.dotLayers.count - 1) * dotSpacing, height: dotSize.height)
super.init()
let dotImage = UIGraphicsImageRenderer(size: dotSize).image(actions: { context in
context.cgContext.setFillColor(UIColor.white.cgColor)
context.cgContext.fillEllipse(in: CGRect(origin: CGPoint(), size: dotSize))
})
var nextX: CGFloat = 0.0
for dotLayer in self.dotLayers {
dotLayer.contents = dotImage.cgImage
dotLayer.frame = CGRect(origin: CGPoint(x: nextX, y: 0.0), size: dotSize)
nextX += dotSpacing + dotSize.width
self.addSublayer(dotLayer)
}
self.didEnterHierarchy = { [weak self] in
self?.updateAnimations()
}
}
override init(layer: Any) {
self.dotLayers = []
self.size = CGSize()
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateAnimations() {
if self.dotLayers[0].animation(forKey: "dotAnimation") != nil {
return
}
let animationDuration: Double = 0.6
for i in 0 ..< self.dotLayers.count {
let dotLayer = self.dotLayers[i]
let animation = CABasicAnimation(keyPath: "transform.scale")
animation.duration = animationDuration
animation.fromValue = 0.3
animation.toValue = 1.0
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.autoreverses = true
animation.repeatCount = .infinity
animation.timeOffset = CGFloat(self.dotLayers.count - 1 - i) * animationDuration * 0.33
dotLayer.add(animation, forKey: "dotAnimation")
}
}
}
private final class SignalStrengthView: UIView {
let barViews: [UIImageView]
let size: CGSize
override init(frame: CGRect) {
self.barViews = (0 ..< 4).map { _ in
return UIImageView()
}
let itemWidth: CGFloat = 3.0
let itemHeight: CGFloat = 12.0
let itemSpacing: CGFloat = 2.0
self.size = CGSize(width: CGFloat(self.barViews.count) * itemWidth + CGFloat(self.barViews.count - 1) * itemSpacing, height: itemHeight)
super.init(frame: frame)
let itemImage = UIGraphicsImageRenderer(size: CGSize(width: itemWidth, height: itemWidth)).image(actions: { context in
context.cgContext.setFillColor(UIColor.white.cgColor)
addRoundedRectPath(context: context.cgContext, rect: CGRect(origin: CGPoint(), size: CGSize(width: itemWidth, height: itemWidth)), radius: 1.0)
context.cgContext.fillPath()
}).stretchableImage(withLeftCapWidth: Int(itemWidth * 0.5), topCapHeight: Int(itemWidth * 0.5))
var nextX: CGFloat = 0.0
for i in 0 ..< self.barViews.count {
let barView = self.barViews[i]
barView.image = itemImage
let barHeight = floor(CGFloat(i + 1) * itemHeight / CGFloat(self.barViews.count))
barView.frame = CGRect(origin: CGPoint(x: nextX, y: itemHeight - barHeight), size: CGSize(width: itemWidth, height: barHeight))
nextX += itemSpacing + itemWidth
self.addSubview(barView)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(value: Double) {
for i in 0 ..< self.barViews.count {
if value >= Double(i + 1) / Double(self.barViews.count) {
self.barViews[i].alpha = 1.0
} else {
self.barViews[i].alpha = 0.5
}
}
}
}
final class StatusView: UIView {
private struct LayoutState: Equatable {
var strings: PresentationStrings
var state: State
var size: CGSize
init(strings: PresentationStrings, state: State, size: CGSize) {
self.strings = strings
self.state = state
self.size = size
}
static func ==(lhs: LayoutState, rhs: LayoutState) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.state != rhs.state {
return false
}
if lhs.size != rhs.size {
return false
}
return true
}
}
enum WaitingState {
case requesting
case ringing
case connecting
case reconnecting
}
struct ActiveState: Equatable {
var startTimestamp: Double
var signalStrength: Double
init(startTimestamp: Double, signalStrength: Double) {
self.startTimestamp = startTimestamp
self.signalStrength = signalStrength
}
}
struct TerminatedState: Equatable {
var duration: Double
init(duration: Double) {
self.duration = duration
}
}
enum State: Equatable {
enum Key: Equatable {
case waiting(WaitingState)
case active
case terminated
}
case waiting(WaitingState)
case active(ActiveState)
case terminated(TerminatedState)
var key: Key {
switch self {
case let .waiting(waitingState):
return .waiting(waitingState)
case .active:
return .active
case .terminated:
return .terminated
}
}
}
private let textView: TextView
private var dotsLayer: AnimatedDotsLayer?
private var signalStrengthView: SignalStrengthView?
private var activeDurationTimer: Foundation.Timer?
private var layoutState: LayoutState?
var state: State? {
return self.layoutState?.state
}
var requestLayout: (() -> Void)?
override init(frame: CGRect) {
self.textView = TextView()
super.init(frame: frame)
self.addSubview(self.textView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.activeDurationTimer?.invalidate()
}
func update(strings: PresentationStrings, state: State, transition: ComponentTransition) -> CGSize {
if let layoutState = self.layoutState, layoutState.strings === strings, layoutState.state == state {
return layoutState.size
}
let size = self.updateInternal(strings: strings, state: state, transition: transition)
self.layoutState = LayoutState(strings: strings, state: state, size: size)
self.updateActiveDurationTimer()
return size
}
private func updateActiveDurationTimer() {
if let layoutState = self.layoutState, case let .active(activeState) = layoutState.state {
if self.activeDurationTimer == nil {
let timestamp = Date().timeIntervalSince1970
let duration = timestamp - activeState.startTimestamp
let nextTickDelay = ceil(duration) - duration + 0.05
self.activeDurationTimer = Foundation.Timer.scheduledTimer(withTimeInterval: nextTickDelay, repeats: false, block: { [weak self] _ in
guard let self else {
return
}
self.activeDurationTimer?.invalidate()
self.activeDurationTimer = nil
if let layoutState = self.layoutState {
let size = self.updateInternal(strings: layoutState.strings, state: layoutState.state, transition: .immediate)
if layoutState.size != size {
self.layoutState = nil
self.requestLayout?()
}
}
self.updateActiveDurationTimer()
})
}
} else {
if let activeDurationTimer = self.activeDurationTimer {
self.activeDurationTimer = nil
activeDurationTimer.invalidate()
}
}
}
private func updateInternal(strings: PresentationStrings, state: State, transition: ComponentTransition) -> CGSize {
let textString: String
var needsDots = false
var monospacedDigits = false
var signalStrength: Double?
switch state {
case let .waiting(waitingState):
needsDots = true
switch waitingState {
case .requesting:
textString = strings.Call_WaitingStatusRequesting
case .ringing:
textString = strings.Call_WaitingStatusRinging
case .connecting:
textString = strings.Call_WaitingStatusConnecting
case .reconnecting:
textString = strings.Call_WaitingStatusReconnecting
}
case let .active(activeState):
monospacedDigits = true
let timestamp = Date().timeIntervalSince1970
let duration = timestamp - activeState.startTimestamp
textString = stringForDuration(Int(duration))
signalStrength = activeState.signalStrength
case let .terminated(terminatedState):
if Int(terminatedState.duration) == 0 {
textString = " "
} else {
textString = stringForDuration(Int(terminatedState.duration))
}
}
var contentSize = CGSize()
if let signalStrength {
let signalStrengthView: SignalStrengthView
if let current = self.signalStrengthView {
signalStrengthView = current
} else {
signalStrengthView = SignalStrengthView(frame: CGRect())
self.signalStrengthView = signalStrengthView
self.addSubview(signalStrengthView)
}
signalStrengthView.update(value: signalStrength)
contentSize.width += signalStrengthView.size.width + 7.0
} else {
if let signalStrengthView = self.signalStrengthView {
self.signalStrengthView = nil
signalStrengthView.removeFromSuperview()
}
}
let textSize = self.textView.update(string: textString, fontSize: 16.0, fontWeight: 0.0, monospacedDigits: monospacedDigits, color: .white, constrainedWidth: 250.0, transition: .immediate)
let textFrame = CGRect(origin: CGPoint(x: contentSize.width, y: 0.0), size: textSize)
if self.textView.bounds.isEmpty {
self.textView.frame = textFrame
} else {
transition.setPosition(view: self.textView, position: textFrame.center)
transition.setBounds(view: self.textView, bounds: CGRect(origin: CGPoint(), size: textFrame.size))
}
contentSize.width += textSize.width
contentSize.height = textSize.height
if let signalStrengthView = self.signalStrengthView {
transition.setFrame(view: signalStrengthView, frame: CGRect(origin: CGPoint(x: 0.0, y: floor((textSize.height - signalStrengthView.size.height) * 0.5)), size: signalStrengthView.size))
}
if needsDots {
let dotsLayer: AnimatedDotsLayer
if let current = self.dotsLayer {
dotsLayer = current
} else {
dotsLayer = AnimatedDotsLayer()
self.dotsLayer = dotsLayer
self.layer.addSublayer(dotsLayer)
transition.animateAlpha(layer: dotsLayer, from: 0.0, to: 1.0)
}
let dotsSpacing: CGFloat = 6.0
let dotsFrame = CGRect(origin: CGPoint(x: textSize.width + dotsSpacing, y: 1.0 + floor((textSize.height - dotsLayer.size.height) * 0.5)), size: dotsLayer.size)
transition.setFrame(layer: dotsLayer, frame: dotsFrame)
contentSize.width += dotsSpacing + dotsFrame.width
} else if let dotsLayer = self.dotsLayer {
self.dotsLayer = nil
transition.setAlpha(layer: dotsLayer, alpha: 0.0, completion: { [weak dotsLayer] _ in
dotsLayer?.removeFromSuperlayer()
})
}
return contentSize
}
}
@@ -0,0 +1,88 @@
import Foundation
import UIKit
import ComponentFlow
final class TextView: UIView {
private struct Params: Equatable {
var string: String
var fontSize: CGFloat
var fontWeight: CGFloat
var monospacedDigits: Bool
var alignment: NSTextAlignment
var constrainedWidth: CGFloat
}
private struct LayoutState: Equatable {
var params: Params
var size: CGSize
var attributedString: NSAttributedString
}
private var layoutState: LayoutState?
private var animateContentsTransition: Bool = false
override init(frame: CGRect) {
super.init(frame: CGRect())
self.isOpaque = false
self.backgroundColor = nil
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
if self.animateContentsTransition && event == "contents" {
self.animateContentsTransition = false
let animation = CABasicAnimation(keyPath: "contents")
animation.duration = 0.15 * UIView.animationDurationFactor()
animation.timingFunction = CAMediaTimingFunction(name: .linear)
return animation
}
return super.action(for: layer, forKey: event)
}
func update(string: String, fontSize: CGFloat, fontWeight: CGFloat, monospacedDigits: Bool = false, alignment: NSTextAlignment = .natural, color: UIColor, constrainedWidth: CGFloat, transition: ComponentTransition) -> CGSize {
let params = Params(string: string, fontSize: fontSize, fontWeight: fontWeight, monospacedDigits: monospacedDigits, alignment: alignment, constrainedWidth: constrainedWidth)
if let layoutState = self.layoutState, layoutState.params == params {
return layoutState.size
}
let font: UIFont
if monospacedDigits {
font = UIFont.monospacedDigitSystemFont(ofSize: fontSize, weight: UIFont.Weight(fontWeight))
} else {
font = UIFont.systemFont(ofSize: fontSize, weight: UIFont.Weight(fontWeight))
}
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = alignment
paragraphStyle.lineSpacing = 0.6
let attributedString = NSAttributedString(string: string, attributes: [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle
])
let stringBounds = attributedString.boundingRect(with: CGSize(width: constrainedWidth, height: 200.0), options: .usesLineFragmentOrigin, context: nil)
let stringSize = CGSize(width: ceil(stringBounds.width), height: ceil(stringBounds.height))
let size = CGSize(width: min(constrainedWidth, stringSize.width), height: stringSize.height)
let layoutState = LayoutState(params: params, size: size, attributedString: attributedString)
if self.layoutState != layoutState {
self.layoutState = layoutState
self.animateContentsTransition = !transition.animation.isImmediate
self.setNeedsDisplay()
}
return size
}
override func draw(_ rect: CGRect) {
guard let layoutState = self.layoutState else {
return
}
layoutState.attributedString.draw(with: rect, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin], context: nil)
}
}
@@ -0,0 +1,822 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MetalEngine
import SwiftSignalKit
private let shadowImage: UIImage? = {
UIImage(named: "Call/VideoGradient")?.precomposed()
}()
public func resolveCallVideoRotationAngle(angle: Float, followsDeviceOrientation: Bool, interfaceOrientation: UIInterfaceOrientation) -> Float {
if !followsDeviceOrientation {
return angle
}
let interfaceAngle: Float
switch interfaceOrientation {
case .portrait, .unknown:
interfaceAngle = 0.0
case .landscapeLeft:
interfaceAngle = Float.pi * 0.5
case .landscapeRight:
interfaceAngle = Float.pi * 3.0 / 2.0
case .portraitUpsideDown:
interfaceAngle = Float.pi
@unknown default:
interfaceAngle = 0.0
}
return (angle + interfaceAngle).truncatingRemainder(dividingBy: Float.pi * 2.0)
}
final class VideoContainerLayer: SimpleLayer {
let contentsLayer: SimpleLayer
override init() {
self.contentsLayer = SimpleLayer()
super.init()
self.addSublayer(self.contentsLayer)
}
override init(layer: Any) {
self.contentsLayer = SimpleLayer()
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, transition: ComponentTransition) {
transition.setFrame(layer: self.contentsLayer, frame: CGRect(origin: CGPoint(), size: size))
}
}
final class VideoContainerView: HighlightTrackingButton {
enum Key {
case background
case foreground
}
private struct Params: Equatable {
var size: CGSize
var insets: UIEdgeInsets
var interfaceOrientation: UIInterfaceOrientation
var cornerRadius: CGFloat
var controlsHidden: Bool
var isMinimized: Bool
var isAnimatedOut: Bool
init(size: CGSize, insets: UIEdgeInsets, interfaceOrientation: UIInterfaceOrientation, cornerRadius: CGFloat, controlsHidden: Bool, isMinimized: Bool, isAnimatedOut: Bool) {
self.size = size
self.insets = insets
self.interfaceOrientation = interfaceOrientation
self.cornerRadius = cornerRadius
self.controlsHidden = controlsHidden
self.isMinimized = isMinimized
self.isAnimatedOut = isAnimatedOut
}
}
struct VideoMetrics: Equatable {
var resolution: CGSize
var rotationAngle: Float
var followsDeviceOrientation: Bool
var sourceId: Int
init(resolution: CGSize, rotationAngle: Float, followsDeviceOrientation: Bool, sourceId: Int) {
self.resolution = resolution
self.rotationAngle = rotationAngle
self.followsDeviceOrientation = followsDeviceOrientation
self.sourceId = sourceId
}
}
private final class FlipAnimationInfo {
let isForward: Bool
let previousRotationAngle: Float
let followsDeviceOrientation: Bool
init(isForward: Bool, previousRotationAngle: Float, followsDeviceOrientation: Bool) {
self.isForward = isForward
self.previousRotationAngle = previousRotationAngle
self.followsDeviceOrientation = followsDeviceOrientation
}
}
private final class DisappearingVideo {
let flipAnimationInfo: FlipAnimationInfo?
let videoLayer: PrivateCallVideoLayer
let videoMetrics: VideoMetrics
var isAlphaAnimationInitiated: Bool = false
init(flipAnimationInfo: FlipAnimationInfo?, videoLayer: PrivateCallVideoLayer, videoMetrics: VideoMetrics) {
self.flipAnimationInfo = flipAnimationInfo
self.videoLayer = videoLayer
self.videoMetrics = videoMetrics
}
}
private enum MinimizedPosition: CaseIterable {
case topLeft
case topRight
case bottomLeft
case bottomRight
}
let key: Key
let enableSharpening: Bool
let videoContainerLayer: VideoContainerLayer
var videoContainerLayerTaken: Bool = false
private var videoLayer: PrivateCallVideoLayer
private var disappearingVideoLayer: DisappearingVideo?
var currentVideoOutput: VideoSource.Output? {
return self.videoLayer.video
}
let blurredContainerLayer: SimpleLayer
private let shadowContainer: SimpleLayer
private let topShadowLayer: SimpleLayer
private let bottomShadowLayer: SimpleLayer
private var params: Params?
private var videoMetrics: VideoMetrics?
private var appliedVideoMetrics: VideoMetrics?
private var highlightedState: Bool = false
private(set) var isFillingBounds: Bool = false
private var minimizedPosition: MinimizedPosition = .bottomRight
private var initialDragPosition: CGPoint?
private var dragPosition: CGPoint?
private var dragVelocity: CGPoint = CGPoint()
private var dragPositionAnimatorLink: SharedDisplayLinkDriver.Link?
private var videoOnUpdatedListener: Disposable?
var video: VideoSource? {
didSet {
if self.video !== oldValue {
self.videoOnUpdatedListener?.dispose()
self.videoOnUpdatedListener = self.video?.addOnUpdated { [weak self] in
guard let self else {
return
}
var videoMetrics: VideoMetrics?
if let currentOutput = self.video?.currentOutput {
if let previousVideo = self.videoLayer.video, previousVideo.sourceId != currentOutput.sourceId {
self.initiateVideoSourceSwitch(flipAnimationInfo: FlipAnimationInfo(isForward: previousVideo.sourceId < currentOutput.sourceId, previousRotationAngle: previousVideo.rotationAngle, followsDeviceOrientation: previousVideo.followsDeviceOrientation))
}
self.videoLayer.video = currentOutput
videoMetrics = VideoMetrics(resolution: currentOutput.resolution, rotationAngle: currentOutput.rotationAngle, followsDeviceOrientation: currentOutput.followsDeviceOrientation, sourceId: currentOutput.sourceId)
} else {
self.videoLayer.video = nil
}
self.videoLayer.setNeedsUpdate()
if self.videoMetrics != videoMetrics {
self.videoMetrics = videoMetrics
self.update(transition: .easeInOut(duration: 0.2))
}
}
if oldValue != nil {
self.initiateVideoSourceSwitch(flipAnimationInfo: nil)
}
var videoMetrics: VideoMetrics?
if let currentOutput = self.video?.currentOutput {
self.videoLayer.video = currentOutput
videoMetrics = VideoMetrics(resolution: currentOutput.resolution, rotationAngle: currentOutput.rotationAngle, followsDeviceOrientation: currentOutput.followsDeviceOrientation, sourceId: currentOutput.sourceId)
} else {
self.videoLayer.video = nil
}
self.videoLayer.setNeedsUpdate()
if self.videoMetrics != videoMetrics || oldValue != nil {
self.videoMetrics = videoMetrics
self.update(transition: .easeInOut(duration: 0.2))
}
}
}
}
var pressAction: (() -> Void)?
init(key: Key, enableSharpening: Bool) {
self.key = key
self.enableSharpening = enableSharpening
self.videoContainerLayer = VideoContainerLayer()
self.videoContainerLayer.backgroundColor = nil
self.videoContainerLayer.isOpaque = false
self.videoContainerLayer.contentsLayer.backgroundColor = nil
self.videoContainerLayer.contentsLayer.isOpaque = false
if #available(iOS 13.0, *) {
self.videoContainerLayer.contentsLayer.cornerCurve = .circular
}
self.videoLayer = PrivateCallVideoLayer(enableSharpening: self.enableSharpening)
self.videoLayer.masksToBounds = true
self.videoLayer.isDoubleSided = false
if #available(iOS 13.0, *) {
self.videoLayer.cornerCurve = .circular
}
self.blurredContainerLayer = SimpleLayer()
self.shadowContainer = SimpleLayer()
self.topShadowLayer = SimpleLayer()
self.topShadowLayer.transform = CATransform3DMakeScale(1.0, -1.0, 1.0)
self.bottomShadowLayer = SimpleLayer()
super.init(frame: CGRect())
self.videoContainerLayer.contentsLayer.addSublayer(self.videoLayer)
self.layer.addSublayer(self.videoContainerLayer)
self.blurredContainerLayer.addSublayer(self.videoLayer.blurredLayer)
self.topShadowLayer.contents = shadowImage?.cgImage
self.bottomShadowLayer.contents = shadowImage?.cgImage
self.shadowContainer.addSublayer(self.topShadowLayer)
self.shadowContainer.addSublayer(self.bottomShadowLayer)
self.layer.addSublayer(self.shadowContainer)
self.highligthedChanged = { [weak self] highlighted in
guard let self, let params = self.params, !self.videoContainerLayer.bounds.isEmpty, !self.videoContainerLayerTaken else {
return
}
var highlightedState = false
if highlighted {
if params.isMinimized {
highlightedState = true
}
} else {
highlightedState = false
}
if self.highlightedState == highlightedState {
return
}
self.highlightedState = highlightedState
let measurementSide = min(self.videoContainerLayer.bounds.width, self.videoContainerLayer.bounds.height)
let topScale: CGFloat = (measurementSide - 8.0) / measurementSide
let maxScale: CGFloat = (measurementSide + 2.0) / measurementSide
if highlightedState {
self.videoContainerLayer.removeAnimation(forKey: "sublayerTransform")
let transition = ComponentTransition(animation: .curve(duration: 0.15, curve: .easeInOut))
transition.setSublayerTransform(layer: self.videoContainerLayer, transform: CATransform3DMakeScale(topScale, topScale, 1.0))
} else {
let t = self.videoContainerLayer.presentation()?.sublayerTransform ?? self.videoContainerLayer.sublayerTransform
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
let transition = ComponentTransition(animation: .none)
transition.setSublayerTransform(layer: self.videoContainerLayer, transform: CATransform3DIdentity)
self.videoContainerLayer.animateSublayerScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.videoContainerLayer.animateSublayerScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let params = self.params else {
return nil
}
if params.isMinimized {
let videoContainerPoint = self.layer.convert(point, to: self.videoContainerLayer)
if self.videoContainerLayer.bounds.contains(videoContainerPoint) {
return self
} else {
return nil
}
} else {
return nil
}
}
@objc private func pressed() {
self.pressAction?()
}
@objc private func panGesture(_ recognizer: UIPanGestureRecognizer) {
if self.videoContainerLayerTaken {
return
}
switch recognizer.state {
case .began, .changed:
self.dragVelocity = CGPoint()
if let dragPositionAnimatorLink = self.dragPositionAnimatorLink {
self.dragPositionAnimatorLink = nil
dragPositionAnimatorLink.invalidate()
}
let translation = recognizer.translation(in: self)
let initialDragPosition: CGPoint
if let current = self.initialDragPosition {
initialDragPosition = current
} else {
initialDragPosition = self.videoContainerLayer.position
self.initialDragPosition = initialDragPosition
}
self.dragPosition = initialDragPosition.offsetBy(dx: translation.x, dy: translation.y)
self.update(transition: .immediate)
case .ended, .cancelled:
self.initialDragPosition = nil
self.dragVelocity = recognizer.velocity(in: self)
if let params = self.params, let dragPosition = self.dragPosition {
let endPosition = CGPoint(
x: dragPosition.x - self.dragVelocity.x / (1000.0 * log(0.99)),
y: dragPosition.y - self.dragVelocity.y / (1000.0 * log(0.99))
)
var minCornerDistance: (corner: MinimizedPosition, distance: CGFloat)?
for corner in MinimizedPosition.allCases {
let cornerPosition: CGPoint
switch corner {
case .topLeft:
cornerPosition = CGPoint(x: params.insets.left, y: params.insets.top)
case .topRight:
cornerPosition = CGPoint(x: params.size.width - params.insets.right, y: params.insets.top)
case .bottomLeft:
cornerPosition = CGPoint(x: params.insets.left, y: params.size.height - params.insets.bottom)
case .bottomRight:
cornerPosition = CGPoint(x: params.size.width - params.insets.right, y: params.size.height - params.insets.bottom)
}
let distance = CGPoint(x: endPosition.x - cornerPosition.x, y: endPosition.y - cornerPosition.y)
let scalarDistance = sqrt(distance.x * distance.x + distance.y * distance.y)
if let (_, minDistance) = minCornerDistance {
if scalarDistance < minDistance {
minCornerDistance = (corner, scalarDistance)
}
} else {
minCornerDistance = (corner, scalarDistance)
}
}
if let minCornerDistance {
self.minimizedPosition = minCornerDistance.corner
}
}
self.dragPositionAnimatorLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] deltaTime in
guard let self else {
return
}
self.updateDragPositionAnimation(deltaTime: deltaTime)
})
default:
break
}
}
private func updateVelocityUsingSpring(currentVelocity: CGPoint, currentPosition: CGPoint, attractor: CGPoint, springConstant: CGFloat, damping: CGFloat, deltaTime: CGFloat) -> CGPoint {
let displacement = CGPoint(x: attractor.x - currentPosition.x, y: attractor.y - currentPosition.y)
let springForce = CGPoint(x: -springConstant * displacement.x, y: -springConstant * displacement.y)
var newVelocity = CGPoint(x: currentVelocity.x + springForce.x * deltaTime, y: currentVelocity.y + springForce.y * deltaTime)
newVelocity = CGPoint(x: newVelocity.x * exp(-damping * deltaTime), y: newVelocity.y * exp(-damping * deltaTime))
return newVelocity
}
private func updateDragPositionAnimation(deltaTime: Double) {
guard let params = self.params, let videoMetrics = self.videoMetrics else {
self.dragPosition = nil
self.dragPositionAnimatorLink = nil
return
}
if !params.isMinimized {
self.dragPosition = nil
self.dragPositionAnimatorLink = nil
return
}
guard var dragPosition = self.dragPosition else {
self.dragPosition = nil
self.dragPositionAnimatorLink = nil
return
}
let videoLayout = self.calculateMinimizedLayout(params: params, videoMetrics: videoMetrics, resolvedRotationAngle: resolveCallVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation), applyDragPosition: false)
let targetPosition = videoLayout.rotatedVideoFrame.center
self.dragVelocity = self.updateVelocityUsingSpring(
currentVelocity: self.dragVelocity,
currentPosition: dragPosition,
attractor: targetPosition,
springConstant: -130.0,
damping: 17.0,
deltaTime: CGFloat(deltaTime)
)
if sqrt(self.dragVelocity.x * self.dragVelocity.x + self.dragVelocity.y * self.dragVelocity.y) <= 0.1 {
self.dragVelocity = CGPoint()
self.dragPosition = nil
self.dragPositionAnimatorLink = nil
} else {
dragPosition.x += self.dragVelocity.x * CGFloat(deltaTime)
dragPosition.y += self.dragVelocity.y * CGFloat(deltaTime)
self.dragPosition = dragPosition
}
self.update(transition: .immediate)
}
private func initiateVideoSourceSwitch(flipAnimationInfo: FlipAnimationInfo?) {
guard let videoMetrics = self.videoMetrics else {
return
}
if let disappearingVideoLayer = self.disappearingVideoLayer {
disappearingVideoLayer.videoLayer.removeFromSuperlayer()
disappearingVideoLayer.videoLayer.blurredLayer.removeFromSuperlayer()
}
let previousVideoLayer = self.videoLayer
self.disappearingVideoLayer = DisappearingVideo(flipAnimationInfo: flipAnimationInfo, videoLayer: self.videoLayer, videoMetrics: videoMetrics)
self.videoLayer = PrivateCallVideoLayer(enableSharpening: self.enableSharpening)
self.videoLayer.opacity = previousVideoLayer.opacity
self.videoLayer.masksToBounds = true
self.videoLayer.isDoubleSided = false
if #available(iOS 13.0, *) {
self.videoLayer.cornerCurve = .circular
}
self.videoLayer.cornerRadius = previousVideoLayer.cornerRadius
self.videoLayer.blurredLayer.opacity = previousVideoLayer.blurredLayer.opacity
self.videoContainerLayer.contentsLayer.addSublayer(self.videoLayer)
self.blurredContainerLayer.addSublayer(self.videoLayer.blurredLayer)
self.dragPosition = nil
self.dragPositionAnimatorLink = nil
}
private func update(transition: ComponentTransition) {
guard let params = self.params else {
return
}
self.update(previousParams: params, params: params, transition: transition)
}
func update(size: CGSize, insets: UIEdgeInsets, interfaceOrientation: UIInterfaceOrientation, cornerRadius: CGFloat, controlsHidden: Bool, isMinimized: Bool, isAnimatedOut: Bool, transition: ComponentTransition) {
let params = Params(size: size, insets: insets, interfaceOrientation: interfaceOrientation, cornerRadius: cornerRadius, controlsHidden: controlsHidden, isMinimized: isMinimized, isAnimatedOut: isAnimatedOut)
if self.params == params {
return
}
let previousParams = self.params
self.params = params
if let previousParams, previousParams.controlsHidden != params.controlsHidden {
self.dragPosition = nil
self.dragPositionAnimatorLink = nil
}
self.update(previousParams: previousParams, params: params, transition: transition)
}
private struct MinimizedLayout {
var videoIsRotated: Bool
var videoSize: CGSize
var rotatedVideoSize: CGSize
var rotatedVideoResolution: CGSize
var rotatedVideoFrame: CGRect
var videoTransform: CATransform3D
var effectiveVideoFrame: CGRect
}
private func calculateMinimizedLayout(params: Params, videoMetrics: VideoMetrics, resolvedRotationAngle: Float, applyDragPosition: Bool) -> MinimizedLayout {
var rotatedResolution = videoMetrics.resolution
var videoIsRotated = false
if resolvedRotationAngle == Float.pi * 0.5 || resolvedRotationAngle == Float.pi * 3.0 / 2.0 {
rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width)
videoIsRotated = true
}
let minimizedBoundingSize: CGFloat = params.controlsHidden ? 140.0 : 240.0
let videoSize = rotatedResolution.aspectFitted(CGSize(width: minimizedBoundingSize, height: minimizedBoundingSize))
let videoResolution = rotatedResolution.aspectFittedOrSmaller(CGSize(width: 1280, height: 1280)).aspectFittedOrSmaller(CGSize(width: videoSize.width * 3.0, height: videoSize.height * 3.0))
let rotatedVideoResolution = videoIsRotated ? CGSize(width: videoResolution.height, height: videoResolution.width) : videoResolution
let rotatedVideoSize = videoIsRotated ? CGSize(width: videoSize.height, height: videoSize.width) : videoSize
let rotatedVideoFrame: CGRect
if applyDragPosition, let dragPosition = self.dragPosition {
rotatedVideoFrame = videoSize.centered(around: dragPosition)
} else {
switch self.minimizedPosition {
case .topLeft:
rotatedVideoFrame = CGRect(origin: CGPoint(x: params.insets.left, y: params.insets.top), size: videoSize)
case .topRight:
rotatedVideoFrame = CGRect(origin: CGPoint(x: params.size.width - params.insets.right - videoSize.width, y: params.insets.top), size: videoSize)
case .bottomLeft:
rotatedVideoFrame = CGRect(origin: CGPoint(x: params.insets.left, y: params.size.height - params.insets.bottom - videoSize.height), size: videoSize)
case .bottomRight:
rotatedVideoFrame = CGRect(origin: CGPoint(x: params.size.width - params.insets.right - videoSize.width, y: params.size.height - params.insets.bottom - videoSize.height), size: videoSize)
}
}
let effectiveVideoFrame = videoSize.centered(around: rotatedVideoFrame.center)
var videoTransform = CATransform3DIdentity
videoTransform.m34 = 1.0 / 600.0
videoTransform = CATransform3DRotate(videoTransform, CGFloat(resolvedRotationAngle), 0.0, 0.0, 1.0)
if params.isAnimatedOut {
videoTransform = CATransform3DScale(videoTransform, 0.6, 0.6, 1.0)
}
return MinimizedLayout(
videoIsRotated: videoIsRotated,
videoSize: videoSize,
rotatedVideoSize: rotatedVideoSize,
rotatedVideoResolution: rotatedVideoResolution,
rotatedVideoFrame: rotatedVideoFrame,
videoTransform: videoTransform,
effectiveVideoFrame: effectiveVideoFrame
)
}
private func update(previousParams: Params?, params: Params, transition: ComponentTransition) {
if self.videoContainerLayerTaken {
return
}
guard let videoMetrics = self.videoMetrics else {
return
}
var transition = transition
if self.appliedVideoMetrics == nil {
transition = .immediate
}
self.appliedVideoMetrics = videoMetrics
let resolvedRotationAngle = resolveCallVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation)
if params.isMinimized {
self.isFillingBounds = false
let videoLayout = self.calculateMinimizedLayout(params: params, videoMetrics: videoMetrics, resolvedRotationAngle: resolvedRotationAngle, applyDragPosition: true)
transition.setPosition(layer: self.videoContainerLayer, position: videoLayout.effectiveVideoFrame.center)
self.videoContainerLayer.contentsLayer.masksToBounds = true
if self.disappearingVideoLayer != nil {
self.videoContainerLayer.contentsLayer.backgroundColor = UIColor.black.cgColor
}
transition.setBounds(layer: self.videoContainerLayer, bounds: CGRect(origin: CGPoint(), size: videoLayout.effectiveVideoFrame.size), completion: { [weak self] completed in
guard let self, completed else {
return
}
self.videoContainerLayer.contentsLayer.masksToBounds = false
self.videoContainerLayer.contentsLayer.backgroundColor = nil
})
self.videoContainerLayer.update(size: videoLayout.effectiveVideoFrame.size, transition: transition)
var videoTransition = transition
if self.videoLayer.bounds.isEmpty {
videoTransition = .immediate
}
var animateFlipDisappearingVideo: DisappearingVideo?
if let disappearingVideoLayer = self.disappearingVideoLayer {
self.disappearingVideoLayer = nil
let disappearingVideoLayout = self.calculateMinimizedLayout(params: params, videoMetrics: disappearingVideoLayer.videoMetrics, resolvedRotationAngle: resolveCallVideoRotationAngle(angle: disappearingVideoLayer.videoMetrics.rotationAngle, followsDeviceOrientation: disappearingVideoLayer.videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation), applyDragPosition: true)
let initialDisappearingVideoSize = disappearingVideoLayout.effectiveVideoFrame.size
if !disappearingVideoLayer.isAlphaAnimationInitiated {
disappearingVideoLayer.isAlphaAnimationInitiated = true
if let flipAnimationInfo = disappearingVideoLayer.flipAnimationInfo {
var videoTransform = self.videoContainerLayer.transform
var axis: (x: CGFloat, y: CGFloat, z: CGFloat) = (0.0, 0.0, 0.0)
let previousVideoScale: CGPoint
axis.y = 1.0
previousVideoScale = CGPoint(x: -1.0, y: 1.0)
videoTransform = CATransform3DRotate(videoTransform, (flipAnimationInfo.isForward ? 1.0 : -1.0) * CGFloat.pi * 0.9999, axis.x, axis.y, axis.z)
self.videoContainerLayer.transform = videoTransform
disappearingVideoLayer.videoLayer.zPosition = 1.0
transition.setZPosition(layer: disappearingVideoLayer.videoLayer, zPosition: -1.0)
disappearingVideoLayer.videoLayer.transform = CATransform3DConcat(disappearingVideoLayout.videoTransform, CATransform3DMakeScale(previousVideoScale.x, previousVideoScale.y, 1.0))
animateFlipDisappearingVideo = disappearingVideoLayer
disappearingVideoLayer.videoLayer.blurredLayer.removeFromSuperlayer()
} else {
let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2)
let disappearingVideoLayerValue = disappearingVideoLayer.videoLayer
alphaTransition.setAlpha(layer: disappearingVideoLayerValue, alpha: 0.0, completion: { [weak self, weak disappearingVideoLayerValue] _ in
guard let self, let disappearingVideoLayerValue else {
return
}
disappearingVideoLayerValue.removeFromSuperlayer()
if self.disappearingVideoLayer?.videoLayer === disappearingVideoLayerValue {
self.disappearingVideoLayer = nil
self.update(transition: .immediate)
}
})
disappearingVideoLayer.videoLayer.blurredLayer.removeFromSuperlayer()
self.videoLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
let mappedDisappearingSize: CGSize
if videoLayout.videoIsRotated {
mappedDisappearingSize = CGSize(width: initialDisappearingVideoSize.height, height: initialDisappearingVideoSize.width)
} else {
mappedDisappearingSize = initialDisappearingVideoSize
}
self.videoLayer.position = disappearingVideoLayer.videoLayer.position
self.videoLayer.bounds = CGRect(origin: CGPoint(), size: videoLayout.rotatedVideoSize.aspectFilled(mappedDisappearingSize))
self.videoLayer.blurredLayer.position = disappearingVideoLayer.videoLayer.blurredLayer.position
self.videoLayer.blurredLayer.bounds = CGRect(origin: CGPoint(), size: videoLayout.rotatedVideoSize.aspectFilled(mappedDisappearingSize))
}
let disappearingFitVideoSize: CGSize
if disappearingVideoLayout.videoIsRotated {
disappearingFitVideoSize = CGSize(width: videoLayout.effectiveVideoFrame.size.height, height: videoLayout.effectiveVideoFrame.size.width)
} else {
disappearingFitVideoSize = videoLayout.effectiveVideoFrame.size
}
let disappearingVideoSize = disappearingVideoLayout.rotatedVideoSize.aspectFilled(disappearingFitVideoSize)
transition.setPosition(layer: disappearingVideoLayer.videoLayer, position: CGPoint(x: videoLayout.effectiveVideoFrame.width * 0.5, y: videoLayout.effectiveVideoFrame.height * 0.5))
transition.setBounds(layer: disappearingVideoLayer.videoLayer, bounds: CGRect(origin: CGPoint(), size: disappearingVideoSize))
transition.setPosition(layer: disappearingVideoLayer.videoLayer.blurredLayer, position: videoLayout.rotatedVideoFrame.center)
transition.setBounds(layer: disappearingVideoLayer.videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: disappearingVideoSize))
}
let animateFlipDisappearingVideoLayer = animateFlipDisappearingVideo?.videoLayer
transition.setTransform(layer: self.videoContainerLayer, transform: CATransform3DIdentity, completion: { [weak animateFlipDisappearingVideoLayer] _ in
animateFlipDisappearingVideoLayer?.removeFromSuperlayer()
})
transition.setPosition(layer: self.videoLayer, position: CGPoint(x: videoLayout.videoSize.width * 0.5, y: videoLayout.videoSize.height * 0.5))
transition.setBounds(layer: self.videoLayer, bounds: CGRect(origin: CGPoint(), size: videoLayout.rotatedVideoSize))
videoTransition.setTransform(layer: self.videoLayer, transform: videoLayout.videoTransform)
transition.setPosition(layer: self.videoLayer.blurredLayer, position: videoLayout.rotatedVideoFrame.center)
transition.setAlpha(layer: self.videoLayer.blurredLayer, alpha: 0.0)
transition.setBounds(layer: self.videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: videoLayout.rotatedVideoSize))
videoTransition.setTransform(layer: self.videoLayer.blurredLayer, transform: videoLayout.videoTransform)
if let previousParams, !previousParams.isMinimized {
self.videoContainerLayer.contentsLayer.cornerRadius = previousParams.cornerRadius
}
transition.setCornerRadius(layer: self.videoContainerLayer.contentsLayer, cornerRadius: 18.0, completion: { [weak self] completed in
guard let self, completed, let params = self.params else {
return
}
if params.isMinimized {
self.videoLayer.cornerRadius = 18.0
}
})
self.videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(videoLayout.rotatedVideoResolution.width), height: Int(videoLayout.rotatedVideoResolution.height)), edgeInset: 2)
} else {
var rotatedResolution = videoMetrics.resolution
var videoIsRotated = false
if resolvedRotationAngle == Float.pi * 0.5 || resolvedRotationAngle == Float.pi * 3.0 / 2.0 {
rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width)
videoIsRotated = true
}
var videoSize: CGSize
if params.isAnimatedOut {
self.isFillingBounds = true
videoSize = rotatedResolution.aspectFilled(params.size)
} else {
videoSize = rotatedResolution.aspectFitted(params.size)
let boundingAspectRatio = params.size.width / params.size.height
let videoAspectRatio = videoSize.width / videoSize.height
self.isFillingBounds = abs(boundingAspectRatio - videoAspectRatio) < 0.15
if self.isFillingBounds {
videoSize = rotatedResolution.aspectFilled(params.size)
}
}
let videoResolution = rotatedResolution.aspectFittedOrSmaller(CGSize(width: 1280, height: 1280)).aspectFittedOrSmaller(CGSize(width: videoSize.width * 3.0, height: videoSize.height * 3.0))
let rotatedVideoResolution = videoIsRotated ? CGSize(width: videoResolution.height, height: videoResolution.width) : videoResolution
let rotatedVideoSize = videoIsRotated ? CGSize(width: videoSize.height, height: videoSize.width) : videoSize
let rotatedVideoBoundingSize = params.size
let rotatedVideoFrame = CGRect(origin: CGPoint(x: floor((rotatedVideoBoundingSize.width - rotatedVideoSize.width) * 0.5), y: floor((rotatedVideoBoundingSize.height - rotatedVideoSize.height) * 0.5)), size: rotatedVideoSize)
self.videoContainerLayer.contentsLayer.masksToBounds = true
if let previousParams, self.videoContainerLayer.contentsLayer.animation(forKey: "cornerRadius") == nil {
if previousParams.isMinimized {
self.videoContainerLayer.contentsLayer.cornerRadius = self.videoLayer.cornerRadius
} else {
self.videoContainerLayer.contentsLayer.cornerRadius = previousParams.cornerRadius
}
}
self.videoLayer.cornerRadius = 0.0
transition.setCornerRadius(layer: self.videoContainerLayer.contentsLayer, cornerRadius: params.cornerRadius, completion: { [weak self] completed in
guard let self, completed, let params = self.params else {
return
}
if !params.isMinimized && !params.isAnimatedOut {
self.videoContainerLayer.contentsLayer.cornerRadius = 0.0
}
})
transition.setPosition(layer: self.videoContainerLayer, position: CGPoint(x: params.size.width * 0.5, y: params.size.height * 0.5))
transition.setBounds(layer: self.videoContainerLayer, bounds: CGRect(origin: CGPoint(), size: params.size))
self.videoContainerLayer.update(size: params.size, transition: transition)
var videoTransition = transition
if self.videoLayer.bounds.isEmpty {
videoTransition = .immediate
if !transition.animation.isImmediate {
self.videoLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.videoLayer.blurredLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
let videoFrame = rotatedVideoSize.centered(around: CGPoint(x: params.size.width * 0.5, y: params.size.height * 0.5))
if let disappearingVideoLayer = self.disappearingVideoLayer {
self.disappearingVideoLayer = nil
if !disappearingVideoLayer.isAlphaAnimationInitiated {
disappearingVideoLayer.isAlphaAnimationInitiated = true
self.videoLayer.position = disappearingVideoLayer.videoLayer.position
self.videoLayer.blurredLayer.position = disappearingVideoLayer.videoLayer.blurredLayer.position
transition.setPosition(layer: disappearingVideoLayer.videoLayer, position: videoFrame.center)
transition.setPosition(layer: disappearingVideoLayer.videoLayer.blurredLayer, position: videoFrame.center)
let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2)
let disappearingVideoLayerValue = disappearingVideoLayer.videoLayer
alphaTransition.setAlpha(layer: disappearingVideoLayerValue, alpha: 0.0, completion: { [weak disappearingVideoLayerValue] _ in
disappearingVideoLayerValue?.removeFromSuperlayer()
})
let disappearingVideoLayerBlurredLayerValue = disappearingVideoLayer.videoLayer.blurredLayer
alphaTransition.setAlpha(layer: disappearingVideoLayerBlurredLayerValue, alpha: 0.0, completion: { [weak disappearingVideoLayerBlurredLayerValue] _ in
disappearingVideoLayerBlurredLayerValue?.removeFromSuperlayer()
})
}
}
transition.setPosition(layer: self.videoLayer, position: videoFrame.center)
videoTransition.setBounds(layer: self.videoLayer, bounds: CGRect(origin: CGPoint(), size: videoFrame.size))
videoTransition.setTransform(layer: self.videoLayer, transform: CATransform3DMakeRotation(CGFloat(resolvedRotationAngle), 0.0, 0.0, 1.0))
transition.setPosition(layer: self.videoLayer.blurredLayer, position: rotatedVideoFrame.center)
videoTransition.setBounds(layer: self.videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoFrame.size))
videoTransition.setAlpha(layer: self.videoLayer.blurredLayer, alpha: 1.0)
videoTransition.setTransform(layer: self.videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(resolvedRotationAngle), 0.0, 0.0, 1.0))
if !params.isAnimatedOut {
self.videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2)
}
}
self.shadowContainer.masksToBounds = true
transition.setCornerRadius(layer: self.shadowContainer, cornerRadius: params.cornerRadius, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.shadowContainer.masksToBounds = false
})
transition.setFrame(layer: self.shadowContainer, frame: CGRect(origin: CGPoint(), size: params.size))
let shadowAlpha: CGFloat = (params.controlsHidden || params.isMinimized || params.isAnimatedOut) ? 0.0 : 1.0
let topShadowHeight: CGFloat = 200.0
let topShadowFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: params.size.width, height: topShadowHeight))
transition.setPosition(layer: self.topShadowLayer, position: topShadowFrame.center)
transition.setBounds(layer: self.topShadowLayer, bounds: CGRect(origin: CGPoint(), size: topShadowFrame.size))
transition.setAlpha(layer: self.topShadowLayer, alpha: shadowAlpha)
let bottomShadowHeight: CGFloat = 200.0
transition.setFrame(layer: self.bottomShadowLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: params.size.height - bottomShadowHeight), size: CGSize(width: params.size.width, height: bottomShadowHeight)))
transition.setAlpha(layer: self.bottomShadowLayer, alpha: shadowAlpha)
}
}
@@ -0,0 +1,17 @@
import Foundation
import UIKit
import Display
import ComponentFlow
final class VideoShadowsView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, transition: ComponentTransition) {
}
}
@@ -0,0 +1,64 @@
import Foundation
import UIKit
import Display
import TelegramPresentationData
final class WeakSignalView: OverlayMaskContainerView {
private struct Params: Equatable {
var constrainedSize: CGSize
init(constrainedSize: CGSize) {
self.constrainedSize = constrainedSize
}
}
private struct Layout {
var params: Params
var size: CGSize
init(params: Params, size: CGSize) {
self.params = params
self.size = size
}
}
private let titleView: TextView
private let overlayBackgroundView: UIImageView
private var currentLayout: Layout?
override init(frame: CGRect) {
self.titleView = TextView()
self.overlayBackgroundView = UIImageView()
super.init(frame: frame)
self.maskContents.addSubview(self.overlayBackgroundView)
self.addSubview(self.titleView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(strings: PresentationStrings, constrainedSize: CGSize) -> CGSize {
let params = Params(constrainedSize: constrainedSize)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.size
}
let sideInset: CGFloat = 11.0
let height: CGFloat = 30.0
let titleSize = self.titleView.update(string: strings.Call_StatusWeakSignal, fontSize: 16.0, fontWeight: 0.0, color: .white, constrainedWidth: constrainedSize.width - sideInset * 2.0, transition: .immediate)
let size = CGSize(width: titleSize.width + sideInset * 2.0, height: height)
self.titleView.frame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize)
if self.overlayBackgroundView.image?.size.height != height {
self.overlayBackgroundView.image = generateStretchableFilledCircleImage(diameter: height, color: .white)
}
self.overlayBackgroundView.frame = CGRect(origin: CGPoint(), size: size)
self.currentLayout = Layout(params: params, size: size)
return size
}
}
@@ -0,0 +1,72 @@
import Foundation
import UIKit
import Display
final class ContentOverlayLayer: SimpleLayer {
private struct Params: Equatable {
var size: CGSize
var contentInsets: UIEdgeInsets
init(size: CGSize, contentInsets: UIEdgeInsets) {
self.size = size
self.contentInsets = contentInsets
}
}
var contentsLayer: CALayer? {
didSet {
if self.contentsLayer !== oldValue {
oldValue?.mask = nil
oldValue?.removeFromSuperlayer()
if let contentsLayer = self.contentsLayer {
contentsLayer.mask = self.maskContentLayer
self.addSublayer(contentsLayer)
if let params = self.params {
let size = params.size
let contentInsets = params.contentInsets
self.params = nil
self.update(size: size, contentInsets: contentInsets)
}
}
}
}
}
let maskContentLayer: SimpleLayer
private var params: Params?
override init() {
self.maskContentLayer = SimpleLayer()
super.init()
}
override init(layer: Any) {
self.maskContentLayer = SimpleLayer()
super.init(layer: layer)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, contentInsets: UIEdgeInsets) {
let params = Params(size: size, contentInsets: contentInsets)
if self.params == params {
return
}
self.params = params
let contentInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
self.maskContentLayer.frame = CGRect(origin: CGPoint(x: contentInsets.left, y: contentInsets.top), size: size)
if let contentsLayer = self.contentsLayer {
contentsLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width - (contentInsets.left + contentInsets.right), height: size.height - (contentInsets.top + contentInsets.bottom)))
}
}
}
@@ -0,0 +1,227 @@
import AVFoundation
import Metal
import CoreVideo
import Display
import SwiftSignalKit
public final class VideoSourceOutput {
public struct MirrorDirection: OptionSet {
public var rawValue: Int32
public init(rawValue: Int32) {
self.rawValue = rawValue
}
public static let horizontal = MirrorDirection(rawValue: 1 << 0)
public static let vertical = MirrorDirection(rawValue: 1 << 1)
}
open class DataBuffer {
open var pixelBuffer: CVPixelBuffer? {
return nil
}
public init() {
}
}
public final class BiPlanarTextureLayout {
public let y: MTLTexture
public let uv: MTLTexture
public init(y: MTLTexture, uv: MTLTexture) {
self.y = y
self.uv = uv
}
}
public final class TriPlanarTextureLayout {
public let y: MTLTexture
public let u: MTLTexture
public let v: MTLTexture
public init(y: MTLTexture, u: MTLTexture, v: MTLTexture) {
self.y = y
self.u = u
self.v = v
}
}
public enum TextureLayout {
case biPlanar(BiPlanarTextureLayout)
case triPlanar(TriPlanarTextureLayout)
}
public final class NativeDataBuffer: DataBuffer {
private let pixelBufferValue: CVPixelBuffer
override public var pixelBuffer: CVPixelBuffer? {
return self.pixelBufferValue
}
public init(pixelBuffer: CVPixelBuffer) {
self.pixelBufferValue = pixelBuffer
}
}
public let resolution: CGSize
public let textureLayout: TextureLayout
public let dataBuffer: DataBuffer
public let rotationAngle: Float
public let followsDeviceOrientation: Bool
public let mirrorDirection: MirrorDirection
public let sourceId: Int
public init(resolution: CGSize, textureLayout: TextureLayout, dataBuffer: DataBuffer, rotationAngle: Float, followsDeviceOrientation: Bool, mirrorDirection: MirrorDirection, sourceId: Int) {
self.resolution = resolution
self.textureLayout = textureLayout
self.dataBuffer = dataBuffer
self.rotationAngle = rotationAngle
self.followsDeviceOrientation = followsDeviceOrientation
self.mirrorDirection = mirrorDirection
self.sourceId = sourceId
}
}
public protocol VideoSource: AnyObject {
typealias Output = VideoSourceOutput
var currentOutput: Output? { get }
func addOnUpdated(_ f: @escaping () -> Void) -> Disposable
}
public final class FileVideoSource: VideoSource {
private let playerLooper: AVPlayerLooper
private let queuePlayer: AVQueuePlayer
private var videoOutput: AVPlayerItemVideoOutput
private var device: MTLDevice
private var textureCache: CVMetalTextureCache?
private var targetItem: AVPlayerItem?
public private(set) var currentOutput: Output?
private var onUpdatedListeners = Bag<() -> Void>()
private var displayLink: SharedDisplayLinkDriver.Link?
public var sourceId: Int = 0
public var fixedRotationAngle: Float?
public var sizeMultiplicator: CGPoint = CGPoint(x: 1.0, y: 1.0)
public init?(device: MTLDevice, url: URL, fixedRotationAngle: Float? = nil) {
self.fixedRotationAngle = fixedRotationAngle
self.device = device
CVMetalTextureCacheCreate(nil, nil, device, nil, &self.textureCache)
let playerItem = AVPlayerItem(url: url)
self.queuePlayer = AVQueuePlayer(playerItem: playerItem)
self.playerLooper = AVPlayerLooper(player: self.queuePlayer, templateItem: playerItem)
let outputSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
kCVPixelBufferMetalCompatibilityKey as String: true
]
self.videoOutput = AVPlayerItemVideoOutput(outputSettings: outputSettings)
self.queuePlayer.play()
self.displayLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .fps(60), { [weak self] _ in
guard let self else {
return
}
if self.updateOutput() {
for onUpdated in self.onUpdatedListeners.copyItems() {
onUpdated()
}
}
})
}
public func addOnUpdated(_ f: @escaping () -> Void) -> Disposable {
let index = self.onUpdatedListeners.add(f)
return ActionDisposable { [weak self] in
DispatchQueue.main.async {
guard let self else {
return
}
self.onUpdatedListeners.remove(index)
}
}
}
private func updateOutput() -> Bool {
if self.targetItem !== self.queuePlayer.currentItem {
self.targetItem?.remove(self.videoOutput)
self.targetItem = self.queuePlayer.currentItem
if let targetItem = self.targetItem {
targetItem.add(self.videoOutput)
}
}
guard let currentItem = self.targetItem else {
return false
}
let currentTime = currentItem.currentTime()
guard self.videoOutput.hasNewPixelBuffer(forItemTime: currentTime) else {
return false
}
var rotationAngle: Float = 0.0
if currentTime.seconds <= currentItem.duration.seconds * 0.25 {
rotationAngle = 0.0
} else if currentTime.seconds <= currentItem.duration.seconds * 0.5 {
rotationAngle = Float.pi * 0.5
} else if currentTime.seconds <= currentItem.duration.seconds * 0.75 {
rotationAngle = Float.pi
} else {
rotationAngle = Float.pi * 3.0 / 2.0
}
var pixelBuffer: CVPixelBuffer?
pixelBuffer = self.videoOutput.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil)
guard let buffer = pixelBuffer else {
return false
}
let width = CVPixelBufferGetWidth(buffer)
let height = CVPixelBufferGetHeight(buffer)
var cvMetalTextureY: CVMetalTexture?
var status = CVMetalTextureCacheCreateTextureFromImage(nil, self.textureCache!, buffer, nil, .r8Unorm, width, height, 0, &cvMetalTextureY)
guard status == kCVReturnSuccess, let yTexture = CVMetalTextureGetTexture(cvMetalTextureY!) else {
return false
}
var cvMetalTextureUV: CVMetalTexture?
status = CVMetalTextureCacheCreateTextureFromImage(nil, self.textureCache!, buffer, nil, .rg8Unorm, width / 2, height / 2, 1, &cvMetalTextureUV)
guard status == kCVReturnSuccess, let uvTexture = CVMetalTextureGetTexture(cvMetalTextureUV!) else {
return false
}
if let fixedRotationAngle = self.fixedRotationAngle {
rotationAngle = fixedRotationAngle
}
var resolution = CGSize(width: CGFloat(yTexture.width), height: CGFloat(yTexture.height))
resolution.width = floor(resolution.width * self.sizeMultiplicator.x)
resolution.height = floor(resolution.height * self.sizeMultiplicator.y)
self.currentOutput = Output(
resolution: resolution,
textureLayout: .biPlanar(Output.BiPlanarTextureLayout(
y: yTexture,
uv: uvTexture
)),
dataBuffer: Output.NativeDataBuffer(pixelBuffer: buffer),
rotationAngle: rotationAngle,
followsDeviceOrientation: false,
mirrorDirection: [],
sourceId: self.sourceId
)
return true
}
}
@@ -0,0 +1,120 @@
import Foundation
import UIKit
import Display
final class MirroringLayer: SimpleLayer {
var targetLayer: CALayer?
override init() {
super.init()
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var position: CGPoint {
get {
return super.position
} set(value) {
if let targetLayer = self.targetLayer {
targetLayer.position = value
}
super.position = value
}
}
override var bounds: CGRect {
get {
return super.bounds
} set(value) {
if let targetLayer = self.targetLayer {
targetLayer.bounds = value
}
super.bounds = value
}
}
override var anchorPoint: CGPoint {
get {
return super.anchorPoint
} set(value) {
if let targetLayer = self.targetLayer {
targetLayer.anchorPoint = value
}
super.anchorPoint = value
}
}
override var anchorPointZ: CGFloat {
get {
return super.anchorPointZ
} set(value) {
if let targetLayer = self.targetLayer {
targetLayer.anchorPointZ = value
}
super.anchorPointZ = value
}
}
override var opacity: Float {
get {
return super.opacity
} set(value) {
if let targetLayer = self.targetLayer {
targetLayer.opacity = value
}
super.opacity = value
}
}
override public var sublayerTransform: CATransform3D {
get {
return super.sublayerTransform
} set(value) {
if let targetLayer = self.targetLayer {
targetLayer.sublayerTransform = value
}
super.sublayerTransform = value
}
}
override public var transform: CATransform3D {
get {
return super.transform
} set(value) {
if let targetLayer = self.targetLayer {
targetLayer.transform = value
}
super.transform = value
}
}
override public func add(_ animation: CAAnimation, forKey key: String?) {
if let targetLayer = self.targetLayer {
targetLayer.add(animation, forKey: key)
}
super.add(animation, forKey: key)
}
override public func removeAllAnimations() {
if let targetLayer = self.targetLayer {
targetLayer.removeAllAnimations()
}
super.removeAllAnimations()
}
override public func removeAnimation(forKey: String) {
if let targetLayer = self.targetLayer {
targetLayer.removeAnimation(forKey: forKey)
}
super.removeAnimation(forKey: forKey)
}
}
@@ -0,0 +1,68 @@
import Foundation
import UIKit
public protocol OverlayMaskContainerViewProtocol: UIView {
var maskContents: UIView { get }
}
public class OverlayMaskContainerView: UIView, OverlayMaskContainerViewProtocol {
override public static var layerClass: AnyClass {
return MirroringLayer.self
}
public let maskContents: UIView
override init(frame: CGRect) {
self.maskContents = UIView()
super.init(frame: frame)
(self.layer as? MirroringLayer)?.targetLayer = self.maskContents.layer
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func addSubview(_ view: UIView) {
super.addSubview(view)
if let view = view as? OverlayMaskContainerViewProtocol {
self.maskContents.addSubview(view.maskContents)
}
}
override public func insertSubview(_ view: UIView, at index: Int) {
super.insertSubview(view, at: index)
if let view = view as? OverlayMaskContainerViewProtocol {
self.maskContents.addSubview(view.maskContents)
}
}
override public func insertSubview(_ view: UIView, aboveSubview siblingSubview: UIView) {
super.insertSubview(view, aboveSubview: siblingSubview)
if let view = view as? OverlayMaskContainerViewProtocol {
self.maskContents.addSubview(view.maskContents)
}
}
override public func insertSubview(_ view: UIView, belowSubview siblingSubview: UIView) {
super.insertSubview(view, belowSubview: siblingSubview)
if let view = view as? OverlayMaskContainerViewProtocol {
self.maskContents.addSubview(view.maskContents)
}
}
override public func willRemoveSubview(_ subview: UIView) {
super.willRemoveSubview(subview)
if let view = subview as? OverlayMaskContainerViewProtocol {
if view.maskContents.superview === self.maskContents {
view.maskContents.removeFromSuperview()
}
}
}
}
@@ -0,0 +1,9 @@
import Foundation
func interpolateFloat(_ value1: Float, _ value2: Float, at factor: Float) -> Float {
return value1 * (1.0 - factor) + value2 * factor
}
func interpolatePoints(_ point1: SIMD2<Float>, _ point2: SIMD2<Float>, at factor: Float) -> SIMD2<Float> {
return SIMD2<Float>(x: interpolateFloat(point1.x, point2.x, at: factor), y: interpolateFloat(point1.y, point2.y, at: factor))
}