GLEGram 12.5 — Initial public release

Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
This commit is contained in:
Leeksov
2026-04-06 09:48:12 +03:00
commit 4647310322
39685 changed files with 11052678 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: navigationBarBackArrowImage(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))
}
@@ -0,0 +1,30 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "VideoChatMicButtonComponent",
module_name = "VideoChatMicButtonComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramPresentationData",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/Calls/VoiceChatActionButton",
"//submodules/TelegramUI/Components/Calls/CallScreen",
"//submodules/MetalEngine",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AccountContext",
"//submodules/RadialStatusNode",
"//submodules/TelegramUI/Components/GlassBackgroundComponent",
"//submodules/TelegramCore",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,803 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import LottieComponent
import VoiceChatActionButton
import CallScreen
import MetalEngine
import SwiftSignalKit
import AccountContext
import RadialStatusNode
import GlassBackgroundComponent
import TelegramCore
private extension VideoChatCall {
var myAudioLevelAndSpeaking: Signal<(Float, Bool), NoError> {
switch self {
case let .group(group):
return group.myAudioLevelAndSpeaking
case let .conferenceSource(conferenceSource):
return conferenceSource.audioLevel |> map { value in
return (value, false)
}
}
}
var audioLevels: Signal<[(EnginePeer.Id, UInt32, Float, Bool)], NoError> {
switch self {
case let .group(group):
return group.audioLevels
case let .conferenceSource(conferenceSource):
let peerId = conferenceSource.peerId
return conferenceSource.audioLevel |> map { value in
return [(peerId, 0, value, false)]
}
}
}
}
private let blueColor = UIColor(rgb: 0x55b3f8)
private final class BlobView: UIView {
let blobsLayer: CallBlobsLayer
private let maxLevel: CGFloat
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
private var audioLevel: CGFloat = 0.0
var presentationAudioLevel: CGFloat = 0.0
var scaleUpdated: ((CGFloat) -> Void)? {
didSet {
}
}
private(set) var isAnimating = false
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = true
init(
frame: CGRect,
maxLevel: CGFloat
) {
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
self.maxLevel = maxLevel
self.blobsLayer = CallBlobsLayer()
super.init(frame: frame)
self.addSubnode(self.hierarchyTrackingNode)
self.layer.addSublayer(self.blobsLayer)
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
guard let self else {
return
}
if !self.isCurrentlyInHierarchy {
return
}
self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + self.audioLevel * 0.1
self.updateAudioLevel()
}
updateInHierarchy = { [weak self] value in
guard let self else {
return
}
self.isCurrentlyInHierarchy = value
if value {
self.startAnimating()
} else {
self.stopAnimating()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setColor(_ color: UIColor) {
}
public func updateLevel(_ level: CGFloat, immediately: Bool) {
let normalizedLevel = min(1, max(level / maxLevel, 0))
self.audioLevel = normalizedLevel
if immediately {
self.presentationAudioLevel = normalizedLevel
}
}
private func updateAudioLevel() {
let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05)
let blobAmplificationFactor: CGFloat = 2.0
let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor
self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0)
self.scaleUpdated?(blobScale)
}
public func startAnimating() {
guard !self.isAnimating else { return }
self.isAnimating = true
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = false
}
public func stopAnimating() {
self.stopAnimating(duration: 0.15)
}
public func stopAnimating(duration: Double) {
guard isAnimating else { return }
self.isAnimating = false
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = true
}
private func updateBlobsState() {
/*if self.isAnimating {
if self.mediumBlob.frame.size != .zero {
self.mediumBlob.startAnimating()
self.bigBlob.startAnimating()
}
} else {
self.mediumBlob.stopAnimating()
self.bigBlob.stopAnimating()
}*/
}
override public func layoutSubviews() {
super.layoutSubviews()
//self.mediumBlob.frame = bounds
//self.bigBlob.frame = bounds
let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12))
self.blobsLayer.position = blobsFrame.center
self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size)
self.updateBlobsState()
}
}
private final class GlowView: UIView {
let maskGradientLayer: SimpleGradientLayer
override init(frame: CGRect) {
self.maskGradientLayer = SimpleGradientLayer()
self.maskGradientLayer.type = .radial
self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
super.init(frame: frame)
self.layer.addSublayer(self.maskGradientLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, color: UIColor, transition: ComponentTransition, colorTransition: ComponentTransition) {
transition.setFrame(layer: self.maskGradientLayer, frame: CGRect(origin: CGPoint(), size: size))
colorTransition.setGradientColors(layer: self.maskGradientLayer, colors: [color.withMultipliedAlpha(1.0), color.withMultipliedAlpha(0.0)])
}
}
public final class VideoChatMicButtonComponent: Component {
public enum ScheduledState: Equatable {
case start
case toggleSubscription(isSubscribed: Bool)
}
public enum Content: Equatable {
case connecting
case muted(forced: Bool)
case unmuted(pushToTalk: Bool)
case raiseHand(isRaised: Bool)
case scheduled(state: ScheduledState)
}
let call: VideoChatCall?
let strings: PresentationStrings
let content: Content
let isCollapsed: Bool
let isCompact: Bool
let customIconScale: CGFloat?
let updateUnmutedStateIsPushToTalk: (Bool?) -> Void
let raiseHand: () -> Void
let scheduleAction: () -> Void
public init(
call: VideoChatCall?,
strings: PresentationStrings,
content: Content,
isCollapsed: Bool,
isCompact: Bool,
customIconScale: CGFloat? = nil,
updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void,
raiseHand: @escaping () -> Void,
scheduleAction: @escaping () -> Void
) {
self.call = call
self.strings = strings
self.content = content
self.isCollapsed = isCollapsed
self.isCompact = isCompact
self.customIconScale = customIconScale
self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk
self.raiseHand = raiseHand
self.scheduleAction = scheduleAction
}
public static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool {
if lhs.call != rhs.call {
return false
}
if lhs.content != rhs.content {
return false
}
if lhs.isCollapsed != rhs.isCollapsed {
return false
}
if lhs.isCompact != rhs.isCompact {
return false
}
if lhs.customIconScale != rhs.customIconScale {
return false
}
return true
}
public final class View: HighlightTrackingButton {
private let background: UIImageView
private var disappearingBackgrounds: [UIImageView] = []
private var progressIndicator: RadialStatusNode?
private let title = ComponentView<Empty>()
private var subtitle: ComponentView<Empty>?
private let icon: VoiceChatActionButtonIconNode
private var glowView: GlowView?
private var blobView: BlobView?
private var chromeView: UIImageView?
private var component: VideoChatMicButtonComponent?
private var isUpdating: Bool = false
private var beginTrackingTimestamp: Double = 0.0
private var beginTrackingWasPushToTalk: Bool = false
private var audioLevelDisposable: Disposable?
override init(frame: CGRect) {
self.background = UIImageView()
self.icon = VoiceChatActionButtonIconNode(isColored: false)
super.init(frame: frame)
}
deinit {
self.audioLevelDisposable?.dispose()
}
override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
self.beginTrackingTimestamp = CFAbsoluteTimeGetCurrent()
if let component = self.component {
switch component.content {
case .connecting, .unmuted, .raiseHand, .scheduled:
self.beginTrackingWasPushToTalk = false
case let .muted(forced):
if forced {
self.beginTrackingWasPushToTalk = false
} else {
self.beginTrackingWasPushToTalk = true
component.updateUnmutedStateIsPushToTalk(true)
}
}
}
return super.beginTracking(touch, with: event)
}
override public func endTracking(_ touch: UITouch?, with event: UIEvent?) {
performEndOrCancelTracking()
return super.endTracking(touch, with: event)
}
override public func cancelTracking(with event: UIEvent?) {
performEndOrCancelTracking()
return super.cancelTracking(with: event)
}
private func performEndOrCancelTracking() {
if let component = self.component {
let timestamp = CFAbsoluteTimeGetCurrent()
switch component.content {
case .connecting:
break
case let .muted(forced):
if forced {
} else {
component.updateUnmutedStateIsPushToTalk(false)
}
case .unmuted:
if self.beginTrackingWasPushToTalk {
if timestamp < self.beginTrackingTimestamp + 0.15 {
component.updateUnmutedStateIsPushToTalk(false)
} else {
component.updateUnmutedStateIsPushToTalk(nil)
}
} else {
component.updateUnmutedStateIsPushToTalk(nil)
}
case .raiseHand:
self.icon.playRandomAnimation()
component.raiseHand()
case .scheduled:
component.scheduleAction()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: VideoChatMicButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
if let previousComponent, previousComponent.call != component.call {
self.audioLevelDisposable?.dispose()
self.audioLevelDisposable = nil
}
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2)
var titleText: String
var subtitleText: String?
var isEnabled = true
switch component.content {
case .connecting:
titleText = component.strings.VoiceChat_Connecting
isEnabled = false
case let .muted(forced):
if forced {
titleText = component.strings.VoiceChat_MutedByAdmin
} else {
titleText = component.strings.VoiceChat_Unmute
}
case let .unmuted(isPushToTalk):
titleText = isPushToTalk ? component.strings.VoiceChat_Live : component.strings.VoiceChat_MuteShort
case let .raiseHand(isRaised):
if isRaised {
titleText = component.strings.VoiceChat_AskedToSpeak
subtitleText = component.strings.VoiceChat_AskedToSpeakHelp
} else {
titleText = component.strings.VoiceChat_MutedByAdmin
subtitleText = component.strings.VoiceChat_MutedByAdminHelp
}
case let .scheduled(state):
switch state {
case .start:
titleText = component.strings.VoiceChat_StartNow
case let .toggleSubscription(isSubscribed):
if isSubscribed {
titleText = component.strings.VoiceChat_CancelReminder
} else {
titleText = component.strings.VoiceChat_SetReminder
}
}
}
self.isEnabled = isEnabled
if component.isCompact {
titleText = titleText.lowercased()
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: titleText, font: Font.regular(13.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: 180.0, height: 100.0)
)
let size = CGSize(width: availableSize.width, height: availableSize.height)
if self.background.superview == nil {
self.background.isUserInteractionEnabled = false
self.addSubview(self.background)
self.background.frame = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0))
}
if case .connecting = component.content {
let progressIndicator: RadialStatusNode
if let current = self.progressIndicator {
progressIndicator = current
} else {
progressIndicator = RadialStatusNode(backgroundNodeColor: .clear)
self.progressIndicator = progressIndicator
}
progressIndicator.transitionToState(.progress(color: blueColor, lineWidth: 3.0, value: nil, cancelEnabled: false, animateRotation: true))
let progressIndicatorView = progressIndicator.view
if progressIndicatorView.superview == nil {
self.addSubview(progressIndicatorView)
progressIndicatorView.center = CGRect(origin: CGPoint(), size: size).center
progressIndicatorView.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0))
progressIndicatorView.layer.transform = CATransform3DMakeScale(size.width / 116.0, size.width / 116.0, 1.0)
} else {
transition.setPosition(view: progressIndicatorView, position: CGRect(origin: CGPoint(), size: size).center)
transition.setScale(view: progressIndicatorView, scale: size.width / 116.0)
}
} else if let progressIndicator = self.progressIndicator {
self.progressIndicator = nil
if !transition.animation.isImmediate {
let progressIndicatorView = progressIndicator.view
progressIndicatorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak progressIndicatorView] _ in
progressIndicatorView?.removeFromSuperview()
})
} else {
progressIndicator.view.removeFromSuperview()
}
}
if previousComponent?.content != component.content {
let backgroundContentsTransition: ComponentTransition
if !transition.animation.isImmediate {
backgroundContentsTransition = .easeInOut(duration: 0.2)
} else {
backgroundContentsTransition = .immediate
}
let backgroundImage = generateImage(CGSize(width: 200.0, height: 200.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.addEllipse(in: CGRect(origin: CGPoint(), size: size))
context.clip()
switch component.content {
case .connecting:
context.setFillColor(UIColor(white: 0.1, alpha: 1.0).cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
case .muted, .unmuted, .raiseHand, .scheduled:
let colors: [UIColor]
if case .muted(forced: true) = component.content {
colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)]
} else if case .muted(forced: false) = component.content {
colors = [blueColor, UIColor(rgb: 0x55adfe)]
} else if case .raiseHand = component.content {
colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)]
} else if case .scheduled = component.content {
colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)]
} else {
colors = [UIColor(rgb: 0x33C659), UIColor(rgb: 0x0BA8A5)]
}
let gradientColors = colors.map { $0.cgColor } as CFArray
let colorSpace = DeviceGraphicsContextSettings.shared.colorSpace
var locations: [CGFloat] = [0.0, 1.0]
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: size.height), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
}
})!
if let previousImage = self.background.image {
let previousBackground = UIImageView()
previousBackground.center = self.background.center
previousBackground.bounds = self.background.bounds
previousBackground.layer.transform = self.background.layer.transform
previousBackground.image = previousImage
self.insertSubview(previousBackground, aboveSubview: self.background)
self.disappearingBackgrounds.append(previousBackground)
self.background.image = backgroundImage
backgroundContentsTransition.setAlpha(view: previousBackground, alpha: 0.0, completion: { [weak self, weak previousBackground] _ in
guard let self, let previousBackground else {
return
}
previousBackground.removeFromSuperview()
self.disappearingBackgrounds.removeAll(where: { $0 === previousBackground })
})
} else {
self.background.image = backgroundImage
}
if !transition.animation.isImmediate, let previousComponent, case .connecting = previousComponent.content {
self.layer.animateSublayerScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak self] completed in
if let self, completed {
self.layer.removeAnimation(forKey: "sublayerTransform.scale")
self.layer.animateSublayerScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: true)
}
})
}
}
transition.setPosition(view: self.background, position: CGRect(origin: CGPoint(), size: size).center)
transition.setScale(view: self.background, scale: size.width / 116.0)
for disappearingBackground in self.disappearingBackgrounds {
transition.setPosition(view: disappearingBackground, position: CGRect(origin: CGPoint(), size: size).center)
transition.setScale(view: disappearingBackground, scale: size.width / 116.0)
}
var titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 16.0), size: titleSize)
if component.isCompact {
titleFrame.origin.y -= 11.0
} else {
if subtitleText != nil {
titleFrame.origin.y -= 5.0
}
}
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.center)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
alphaTransition.setAlpha(view: titleView, alpha: component.isCollapsed ? 0.0 : 1.0)
}
if let subtitleText {
let subtitle: ComponentView<Empty>
var subtitleTransition = transition
if let current = self.subtitle {
subtitle = current
} else {
subtitleTransition = subtitleTransition.withAnimation(.none)
subtitle = ComponentView()
self.subtitle = subtitle
}
let subtitleSize = subtitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: subtitleText, font: Font.regular(13.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: 180.0, height: 100.0)
)
let subtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) * 0.5), y: titleFrame.maxY + 1.0), size: subtitleSize)
if let subtitleView = subtitle.view {
if subtitleView.superview == nil {
subtitleView.isUserInteractionEnabled = false
self.addSubview(subtitleView)
subtitleView.alpha = 0.0
transition.animateScale(view: subtitleView, from: 0.001, to: 1.0)
}
subtitleTransition.setPosition(view: subtitleView, position: subtitleFrame.center)
subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size)
alphaTransition.setAlpha(view: subtitleView, alpha: component.isCollapsed || component.isCompact ? 0.0 : 1.0)
}
} else if let subtitle = self.subtitle {
self.subtitle = nil
if let subtitleView = subtitle.view {
transition.setScale(view: subtitleView, scale: 0.001)
alphaTransition.setAlpha(view: subtitleView, alpha: 0.0, completion: { [weak subtitleView] _ in
subtitleView?.removeFromSuperview()
})
}
}
if self.icon.view.superview == nil {
self.icon.view.isUserInteractionEnabled = false
self.addSubview(self.icon.view)
}
let iconSize = CGSize(width: 100.0, height: 100.0)
let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize)
transition.setPosition(view: self.icon.view, position: iconFrame.center)
transition.setBounds(view: self.icon.view, bounds: CGRect(origin: CGPoint(), size: iconFrame.size))
let iconScale: CGFloat
if let customIconScale = component.customIconScale {
iconScale = customIconScale
} else {
iconScale = component.isCollapsed || component.isCompact ? ((iconSize.width - 34.0) / iconSize.width) : 1.0
}
transition.setScale(view: self.icon.view, scale: iconScale)
switch component.content {
case .connecting:
self.icon.enqueueState(.mute)
case .muted:
self.icon.enqueueState(.mute)
case .unmuted:
self.icon.enqueueState(.unmute)
case .raiseHand:
self.icon.enqueueState(.hand)
case let .scheduled(state):
switch state {
case .start:
self.icon.enqueueState(.start)
case let .toggleSubscription(isSubscribed):
if isSubscribed {
self.icon.enqueueState(.unsubscribe)
} else {
self.icon.enqueueState(.subscribe)
}
}
}
switch component.content {
case .muted, .unmuted, .raiseHand, .scheduled:
let blobSize = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)).insetBy(dx: -40.0, dy: -40.0).size
let blobTintTransition: ComponentTransition
let blobView: BlobView
if let current = self.blobView {
blobView = current
blobTintTransition = .easeInOut(duration: 0.2)
} else {
blobTintTransition = .immediate
blobView = BlobView(frame: CGRect(), maxLevel: 1.5)
blobView.isUserInteractionEnabled = false
self.blobView = blobView
self.insertSubview(blobView, at: 0)
blobView.center = CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5)
blobView.bounds = CGRect(origin: CGPoint(), size: blobSize)
ComponentTransition.immediate.setScale(view: blobView, scale: 0.001)
if !transition.animation.isImmediate {
blobView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
transition.setPosition(view: blobView, position: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5))
transition.setScale(view: blobView, scale: availableSize.width / 116.0)
let blobsColor: UIColor
if case .muted(forced: true) = component.content {
blobsColor = UIColor(rgb: 0x914BAD)
} else if case .muted(forced: false) = component.content {
blobsColor = UIColor(rgb: 0x397cd8)
} else if case .raiseHand = component.content {
blobsColor = UIColor(rgb: 0x914BAD)
} else if case .scheduled = component.content {
blobsColor = UIColor(rgb: 0x914BAD)
} else {
blobsColor = UIColor(rgb: 0x33C758)
}
blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: blobsColor)
switch component.content {
case .unmuted:
if self.audioLevelDisposable == nil, let call = component.call {
self.audioLevelDisposable = (call.myAudioLevelAndSpeaking
|> deliverOnMainQueue).startStrict(next: { [weak self] value, _ in
guard let self, let blobView = self.blobView else {
return
}
blobView.updateLevel(CGFloat(value), immediately: false)
})
} else if let audioLevelDisposable = self.audioLevelDisposable {
self.audioLevelDisposable = nil
audioLevelDisposable.dispose()
}
case .connecting, .muted, .raiseHand, .scheduled:
if let audioLevelDisposable = self.audioLevelDisposable {
self.audioLevelDisposable = nil
audioLevelDisposable.dispose()
blobView.updateLevel(0.0, immediately: false)
}
}
var glowFrame = CGRect(origin: CGPoint(), size: availableSize)
if component.isCollapsed {
glowFrame = glowFrame.insetBy(dx: -20.0, dy: -20.0)
} else {
glowFrame = glowFrame.insetBy(dx: -60.0, dy: -60.0)
}
let glowView: GlowView
if let current = self.glowView {
glowView = current
} else {
glowView = GlowView(frame: CGRect())
glowView.isUserInteractionEnabled = false
self.glowView = glowView
//self.insertSubview(glowView, aboveSubview: blobView)
transition.animateScale(view: glowView, from: 0.001, to: 1.0)
glowView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
let glowColor: UIColor
if case .muted(forced: true) = component.content {
glowColor = UIColor(rgb: 0x3252EF)
} else if case .muted(forced: false) = component.content {
glowColor = blueColor
} else if case .raiseHand = component.content {
glowColor = UIColor(rgb: 0x3252EF)
} else if case .scheduled = component.content {
glowColor = UIColor(rgb: 0x3252EF)
} else {
glowColor = UIColor(rgb: 0x33C758)
}
glowView.update(size: glowFrame.size, color: glowColor.withMultipliedAlpha(component.isCollapsed ? 0.5 : 0.7), transition: transition, colorTransition: blobTintTransition)
transition.setFrame(view: glowView, frame: glowFrame)
default:
if let blobView = self.blobView {
self.blobView = nil
transition.setScale(view: blobView, scale: 0.001, completion: { [weak blobView] _ in
blobView?.removeFromSuperview()
})
}
if let glowView = self.glowView {
self.glowView = nil
transition.setScale(view: glowView, scale: 0.001, completion: { [weak glowView] _ in
glowView?.removeFromSuperview()
})
}
if let audioLevelDisposable = self.audioLevelDisposable {
self.audioLevelDisposable = nil
audioLevelDisposable.dispose()
}
}
if component.isCompact {
let chromeView: UIImageView
var chromeTransition = transition
if let current = self.chromeView {
chromeView = current
} else {
chromeTransition = .immediate
chromeView = UIImageView()
self.chromeView = chromeView
self.addSubview(chromeView)
chromeView.layer.compositingFilter = "overlayBlendMode"
chromeView.alpha = 0.8
chromeView.image = GlassBackgroundView.generateForegroundImage(size: size, isDark: false, fillColor: .clear)
}
chromeTransition.setFrame(view: chromeView, frame: CGRect(origin: .zero, size: size))
} else if let chromeView = self.chromeView {
self.chromeView = nil
chromeView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { _ in
chromeView.removeFromSuperview()
})
}
return size
}
}
public func makeView() -> View {
return View()
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -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 = "VoiceChatActionButtonMetalSources",
srcs = glob([
"Metal/**/*.metal",
]),
visibility = ["//visibility:public"],
)
plist_fragment(
name = "VoiceChatActionButtonMetalSourcesBundleInfoPlist",
extension = "plist",
template =
"""
<key>CFBundleIdentifier</key>
<string>org.telegram.VoiceChatActionButtonMetalSources</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleName</key>
<string>VoiceChatActionButton</string>
"""
)
apple_resource_bundle(
name = "VoiceChatActionButtonMetalSourcesBundle",
infoplists = [
":VoiceChatActionButtonMetalSourcesBundleInfoPlist",
],
resources = [
":VoiceChatActionButtonMetalSources",
],
)
filegroup(
name = "Assets",
srcs = glob(["VoiceChatActionButtonAssets.xcassets/**"]),
visibility = ["//visibility:public"],
)
swift_library(
name = "VoiceChatActionButton",
module_name = "VoiceChatActionButton",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
data = [
":VoiceChatActionButtonMetalSourcesBundle",
":Assets",
],
deps = [
"//submodules/Display",
"//submodules/MetalEngine",
"//submodules/ComponentFlow",
"//submodules/UIKitRuntimeUtils",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AppBundle",
"//submodules/ManagedAnimationNode",
"//submodules/AnimationUI",
"//submodules/TelegramUI/Components/Calls/CallScreen",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,22 @@
#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;
};
@@ -0,0 +1,168 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
final class BlobView: UIView {
let pointsCount: Int
let smoothness: CGFloat
let minRandomness: CGFloat
let maxRandomness: CGFloat
let minSpeed: CGFloat
let maxSpeed: CGFloat
let minScale: CGFloat
let maxScale: CGFloat
var scaleUpdated: ((CGFloat) -> Void)?
var level: CGFloat = 0 {
didSet {
if abs(self.level - oldValue) > 0.01 {
CATransaction.begin()
CATransaction.setDisableActions(true)
let lv = self.minScale + (self.maxScale - self.minScale) * self.level
self.shapeLayer.transform = CATransform3DMakeScale(lv, lv, 1)
self.scaleUpdated?(self.level)
CATransaction.commit()
}
}
}
private var speedLevel: CGFloat = 0
private var lastSpeedLevel: CGFloat = 0
private let shapeLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.strokeColor = nil
return layer
}()
init(
pointsCount: Int,
minRandomness: CGFloat,
maxRandomness: CGFloat,
minSpeed: CGFloat,
maxSpeed: CGFloat,
minScale: CGFloat,
maxScale: CGFloat
) {
self.pointsCount = pointsCount
self.minRandomness = minRandomness
self.maxRandomness = maxRandomness
self.minSpeed = minSpeed
self.maxSpeed = maxSpeed
self.minScale = minScale
self.maxScale = maxScale
let angle = (CGFloat.pi * 2) / CGFloat(pointsCount)
self.smoothness = ((4 / 3) * tan(angle / 4)) / sin(angle / 2) / 2
super.init(frame: .zero)
self.layer.addSublayer(self.shapeLayer)
self.shapeLayer.transform = CATransform3DMakeScale(minScale, minScale, 1)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setColor(_ color: UIColor) {
self.shapeLayer.fillColor = color.cgColor
}
func updateSpeedLevel(to newSpeedLevel: CGFloat) {
self.speedLevel = max(self.speedLevel, newSpeedLevel)
}
func startAnimating() {
self.animateToNewShape()
}
func stopAnimating() {
self.shapeLayer.removeAnimation(forKey: "path")
}
private func animateToNewShape() {
if self.shapeLayer.path == nil {
let points = generateNextBlob(for: self.bounds.size)
self.shapeLayer.path = UIBezierPath.smoothCurve(through: points, length: bounds.width, smoothness: smoothness).cgPath
}
let nextPoints = generateNextBlob(for: self.bounds.size)
let nextPath = UIBezierPath.smoothCurve(through: nextPoints, length: bounds.width, smoothness: smoothness).cgPath
let animation = CABasicAnimation(keyPath: "path")
let previousPath = self.shapeLayer.path
self.shapeLayer.path = nextPath
animation.duration = CFTimeInterval(1.0 / (minSpeed + (maxSpeed - minSpeed) * speedLevel))
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animation.fromValue = previousPath
animation.toValue = nextPath
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
animation.completion = { [weak self] finished in
if finished {
self?.animateToNewShape()
}
}
self.shapeLayer.add(animation, forKey: "path")
self.lastSpeedLevel = self.speedLevel
self.speedLevel = 0
}
// MARK: Helpers
private func generateNextBlob(for size: CGSize) -> [CGPoint] {
let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel
return blob(pointsCount: pointsCount, randomness: randomness)
.map {
return CGPoint(
x: $0.x * CGFloat(size.width),
y: $0.y * CGFloat(size.height)
)
}
}
func blob(pointsCount: Int, randomness: CGFloat) -> [CGPoint] {
let angle = (CGFloat.pi * 2) / CGFloat(pointsCount)
let rgen = { () -> CGFloat in
let accuracy: UInt32 = 1000
let random = arc4random_uniform(accuracy)
return CGFloat(random) / CGFloat(accuracy)
}
let rangeStart: CGFloat = 1 / (1 + randomness / 10)
let startAngle = angle * CGFloat(arc4random_uniform(100)) / CGFloat(100)
let points = (0 ..< pointsCount).map { i -> CGPoint in
let randPointOffset = (rangeStart + CGFloat(rgen()) * (1 - rangeStart)) / 2
let angleRandomness: CGFloat = angle * 0.1
let randAngle = angle + angle * ((angleRandomness * CGFloat(arc4random_uniform(100)) / CGFloat(100)) - angleRandomness * 0.5)
let pointX = sin(startAngle + CGFloat(i) * randAngle)
let pointY = cos(startAngle + CGFloat(i) * randAngle)
return CGPoint(
x: pointX * randPointOffset,
y: pointY * randPointOffset
)
}
return points
}
override func layoutSubviews() {
super.layoutSubviews()
CATransaction.begin()
CATransaction.setDisableActions(true)
self.shapeLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
CATransaction.commit()
}
}
@@ -0,0 +1,174 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import CallScreen
import MetalEngine
final class VoiceBlobView: UIView {
//private let mediumBlob: BlobView
//private let bigBlob: BlobView
private let blobsLayer: CallBlobsLayer
private let maxLevel: CGFloat
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
private var audioLevel: CGFloat = 0.0
var presentationAudioLevel: CGFloat = 0.0
var scaleUpdated: ((CGFloat) -> Void)? {
didSet {
//self.bigBlob.scaleUpdated = self.scaleUpdated
}
}
private(set) var isAnimating = false
public typealias BlobRange = (min: CGFloat, max: CGFloat)
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = true
init(
frame: CGRect,
maxLevel: CGFloat,
mediumBlobRange: BlobRange,
bigBlobRange: BlobRange
) {
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
self.maxLevel = maxLevel
/*self.mediumBlob = BlobView(
pointsCount: 8,
minRandomness: 1,
maxRandomness: 1,
minSpeed: 0.9,
maxSpeed: 4.0,
minScale: mediumBlobRange.min,
maxScale: mediumBlobRange.max
)
self.bigBlob = BlobView(
pointsCount: 8,
minRandomness: 1,
maxRandomness: 1,
minSpeed: 1.0,
maxSpeed: 4.4,
minScale: bigBlobRange.min,
maxScale: bigBlobRange.max
)*/
self.blobsLayer = CallBlobsLayer()
super.init(frame: frame)
self.addSubnode(self.hierarchyTrackingNode)
//self.addSubview(self.bigBlob)
//self.addSubview(self.mediumBlob)
self.layer.addSublayer(self.blobsLayer)
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
guard let strongSelf = self else { return }
if !strongSelf.isCurrentlyInHierarchy {
return
}
strongSelf.presentationAudioLevel = strongSelf.presentationAudioLevel * 0.9 + strongSelf.audioLevel * 0.1
strongSelf.updateAudioLevel()
//strongSelf.mediumBlob.level = strongSelf.presentationAudioLevel
//strongSelf.bigBlob.level = strongSelf.presentationAudioLevel
}
updateInHierarchy = { [weak self] value in
if let strongSelf = self {
strongSelf.isCurrentlyInHierarchy = value
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setColor(_ color: UIColor) {
//self.mediumBlob.setColor(color.withAlphaComponent(0.5))
//self.bigBlob.setColor(color.withAlphaComponent(0.21))
}
public func updateLevel(_ level: CGFloat, immediately: Bool) {
let normalizedLevel = min(1, max(level / maxLevel, 0))
//self.mediumBlob.updateSpeedLevel(to: normalizedLevel)
//self.bigBlob.updateSpeedLevel(to: normalizedLevel)
self.audioLevel = normalizedLevel
if immediately {
self.presentationAudioLevel = normalizedLevel
}
}
private func updateAudioLevel() {
let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05)
let blobAmplificationFactor: CGFloat = 2.0
let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor
self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0)
self.scaleUpdated?(blobScale)
}
public func startAnimating() {
guard !self.isAnimating else { return }
self.isAnimating = true
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = false
}
public func stopAnimating() {
self.stopAnimating(duration: 0.15)
}
public func stopAnimating(duration: Double) {
guard isAnimating else { return }
self.isAnimating = false
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = true
}
private func updateBlobsState() {
/*if self.isAnimating {
if self.mediumBlob.frame.size != .zero {
self.mediumBlob.startAnimating()
self.bigBlob.startAnimating()
}
} else {
self.mediumBlob.stopAnimating()
self.bigBlob.stopAnimating()
}*/
}
override public func layoutSubviews() {
super.layoutSubviews()
//self.mediumBlob.frame = bounds
//self.bigBlob.frame = bounds
let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12))
self.blobsLayer.position = blobsFrame.center
self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size)
self.updateBlobsState()
}
}
@@ -0,0 +1,521 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import AnimationUI
import AppBundle
import ManagedAnimationNode
import ComponentFlow
private let titleFont = Font.regular(15.0)
private let subtitleFont = Font.regular(13.0)
private let smallScale: CGFloat = 0.48
private let smallIconScale: CGFloat = 0.69
public final class VoiceChatActionButton: HighlightTrackingButtonNode {
static let buttonHeight: CGFloat = 52.0
public enum State: Equatable {
public enum ActiveState: Equatable {
case cantSpeak
case muted
case on
}
public enum ScheduledState: Equatable {
case start
case subscribe
case unsubscribe
}
case button(text: String)
case scheduled(state: ScheduledState)
case connecting
case active(state: ActiveState)
}
public var stateValue: State {
return self.currentParams?.state ?? .connecting
}
public var statePromise = ValuePromise<State>()
public var state: Signal<State, NoError> {
return self.statePromise.get()
}
public let bottomNode: ASDisplayNode
private let containerNode: ASDisplayNode
private let backgroundNode: VoiceChatActionButtonBackgroundNode
private let iconNode: VoiceChatActionButtonIconNode
private let labelContainerNode: ASDisplayNode
public let titleLabel: ImmediateTextNode
private let subtitleLabel: ImmediateTextNode
private let buttonTitleLabel: ImmediateTextNode
private var currentParams: (size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, dark: Bool, small: Bool, title: String, subtitle: String, snap: Bool)?
private var activePromise = ValuePromise<Bool>(false)
private var outerColorPromise = Promise<(UIColor?, UIColor?)>((nil, nil))
public var outerColor: Signal<(UIColor?, UIColor?), NoError> {
return self.outerColorPromise.get()
}
public var connectingColor: UIColor = UIColor(rgb: 0xb6b6bb) {
didSet {
self.backgroundNode.connectingColor = self.connectingColor
}
}
public var activeDisposable = MetaDisposable()
public var isDisabled: Bool = false
public var ignoreHierarchyChanges: Bool {
get {
return self.backgroundNode.ignoreHierarchyChanges
} set {
self.backgroundNode.ignoreHierarchyChanges = newValue
}
}
public var wasActiveWhenPressed = false
public var pressing: Bool = false {
didSet {
guard let (_, _, state, _, small, _, _, snap) = self.currentParams, !self.isDisabled else {
return
}
if self.pressing {
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
if small {
transition.updateTransformScale(node: self.backgroundNode, scale: smallScale * 0.9)
transition.updateTransformScale(node: self.iconNode, scale: smallIconScale * 0.9)
} else {
transition.updateTransformScale(node: self.iconNode, scale: snap ? 0.5 : 0.9)
}
switch state {
case let .active(state):
switch state {
case .on:
self.wasActiveWhenPressed = true
default:
break
}
case .connecting, .button, .scheduled:
break
}
} else {
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
if small {
transition.updateTransformScale(node: self.backgroundNode, scale: smallScale)
transition.updateTransformScale(node: self.iconNode, scale: smallIconScale)
} else {
transition.updateTransformScale(node: self.iconNode, scale: snap ? 0.5 : 1.0)
}
self.wasActiveWhenPressed = false
}
}
}
public var animationsEnabled: Bool = true {
didSet {
self.backgroundNode.animationsEnabled = self.animationsEnabled
}
}
public init() {
self.bottomNode = ASDisplayNode()
self.bottomNode.isUserInteractionEnabled = false
self.containerNode = ASDisplayNode()
self.containerNode.isUserInteractionEnabled = false
self.backgroundNode = VoiceChatActionButtonBackgroundNode()
self.iconNode = VoiceChatActionButtonIconNode(isColored: false)
self.labelContainerNode = ASDisplayNode()
self.titleLabel = ImmediateTextNode()
self.subtitleLabel = ImmediateTextNode()
self.buttonTitleLabel = ImmediateTextNode()
self.buttonTitleLabel.isUserInteractionEnabled = false
self.buttonTitleLabel.alpha = 0.0
super.init()
self.addSubnode(self.bottomNode)
self.labelContainerNode.addSubnode(self.titleLabel)
self.labelContainerNode.addSubnode(self.subtitleLabel)
self.addSubnode(self.labelContainerNode)
self.addSubnode(self.containerNode)
self.containerNode.addSubnode(self.backgroundNode)
self.containerNode.addSubnode(self.iconNode)
self.containerNode.addSubnode(self.buttonTitleLabel)
self.highligthedChanged = { [weak self] pressing in
if let strongSelf = self {
guard let (_, _, state, _, small, _, _, snap) = strongSelf.currentParams else {
return
}
if pressing {
if case .button = state {
strongSelf.containerNode.layer.removeAnimation(forKey: "opacity")
strongSelf.containerNode.alpha = 0.4
} else {
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
if small {
transition.updateTransformScale(node: strongSelf.backgroundNode, scale: smallScale * 0.9)
transition.updateTransformScale(node: strongSelf.iconNode, scale: smallIconScale * 0.9)
} else {
transition.updateTransformScale(node: strongSelf.iconNode, scale: snap ? 0.5 : 0.9)
}
}
} else if !strongSelf.pressing {
if case .button = state {
strongSelf.containerNode.alpha = 1.0
strongSelf.containerNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
} else {
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
if small {
transition.updateTransformScale(node: strongSelf.backgroundNode, scale: smallScale)
transition.updateTransformScale(node: strongSelf.iconNode, scale: smallIconScale)
} else {
transition.updateTransformScale(node: strongSelf.iconNode, scale: snap ? 0.5 : 1.0)
}
}
}
}
}
self.backgroundNode.updatedActive = { [weak self] active in
self?.activePromise.set(active)
}
self.backgroundNode.updatedColors = { [weak self] outerColor, activeColor in
self?.outerColorPromise.set(.single((outerColor, activeColor)))
}
}
deinit {
self.activeDisposable.dispose()
}
public func updateLevel(_ level: CGFloat, immediately: Bool = false) {
self.backgroundNode.audioLevel = level
}
private func applyParams(animated: Bool) {
guard let (size, _, state, _, small, title, subtitle, snap) = self.currentParams else {
return
}
let updatedTitle = self.titleLabel.attributedText?.string != title
let updatedSubtitle = self.subtitleLabel.attributedText?.string != subtitle
self.titleLabel.attributedText = NSAttributedString(string: title, font: titleFont, textColor: .white)
self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: subtitleFont, textColor: .white)
if animated && self.titleLabel.alpha > 0.0 {
if let snapshotView = self.titleLabel.view.snapshotContentTree(), updatedTitle {
self.titleLabel.view.superview?.insertSubview(snapshotView, belowSubview: self.titleLabel.view)
snapshotView.frame = self.titleLabel.frame
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
self.titleLabel.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
}
if let snapshotView = self.subtitleLabel.view.snapshotContentTree(), updatedSubtitle {
self.subtitleLabel.view.superview?.insertSubview(snapshotView, belowSubview: self.subtitleLabel.view)
snapshotView.frame = self.subtitleLabel.frame
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
self.subtitleLabel.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
}
}
let titleSize = self.titleLabel.updateLayout(CGSize(width: size.width, height: .greatestFiniteMagnitude))
let subtitleSize = self.subtitleLabel.updateLayout(CGSize(width: size.width, height: .greatestFiniteMagnitude))
let totalHeight = titleSize.height + subtitleSize.height + 1.0
self.labelContainerNode.frame = CGRect(origin: CGPoint(), size: size)
let titleLabelFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - totalHeight) / 2.0) + 84.0), size: titleSize)
let subtitleLabelFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) / 2.0), y: titleLabelFrame.maxY + 1.0), size: subtitleSize)
self.titleLabel.bounds = CGRect(origin: CGPoint(), size: titleLabelFrame.size)
self.titleLabel.position = titleLabelFrame.center
self.subtitleLabel.bounds = CGRect(origin: CGPoint(), size: subtitleLabelFrame.size)
self.subtitleLabel.position = subtitleLabelFrame.center
self.bottomNode.frame = CGRect(origin: CGPoint(), size: size)
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
self.backgroundNode.bounds = CGRect(origin: CGPoint(), size: size)
self.backgroundNode.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
var active = false
switch state {
case let .active(state):
switch state {
case .on:
active = self.pressing && !self.wasActiveWhenPressed
default:
break
}
case .connecting, .button, .scheduled:
break
}
if snap {
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate
transition.updateTransformScale(node: self.backgroundNode, scale: active ? 0.9 : 0.625)
transition.updateTransformScale(node: self.iconNode, scale: 0.625)
transition.updateAlpha(node: self.titleLabel, alpha: 0.0)
transition.updateAlpha(node: self.subtitleLabel, alpha: 0.0)
transition.updateAlpha(layer: self.backgroundNode.maskProgressLayer, alpha: 0.0)
} else {
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate
if small {
transition.updateTransformScale(node: self.backgroundNode, scale: self.pressing ? smallScale * 0.9 : smallScale, delay: 0.0)
transition.updateTransformScale(node: self.iconNode, scale: self.pressing ? smallIconScale * 0.9 : smallIconScale, delay: 0.0)
transition.updateAlpha(node: self.titleLabel, alpha: 0.0)
transition.updateAlpha(node: self.subtitleLabel, alpha: 0.0)
transition.updateSublayerTransformOffset(layer: self.labelContainerNode.layer, offset: CGPoint(x: 0.0, y: -43.0))
transition.updateTransformScale(node: self.titleLabel, scale: 0.8)
transition.updateTransformScale(node: self.subtitleLabel, scale: 0.8)
} else {
transition.updateTransformScale(node: self.backgroundNode, scale: 1.0, delay: 0.0)
transition.updateTransformScale(node: self.iconNode, scale: self.pressing ? 0.9 : 1.0, delay: 0.0)
transition.updateAlpha(node: self.titleLabel, alpha: 1.0, delay: 0.05)
transition.updateAlpha(node: self.subtitleLabel, alpha: 1.0, delay: 0.05)
transition.updateSublayerTransformOffset(layer: self.labelContainerNode.layer, offset: CGPoint())
transition.updateTransformScale(node: self.titleLabel, scale: 1.0)
transition.updateTransformScale(node: self.subtitleLabel, scale: 1.0)
}
transition.updateAlpha(layer: self.backgroundNode.maskProgressLayer, alpha: 1.0)
}
let iconSize = CGSize(width: 100.0, height: 100.0)
self.iconNode.bounds = CGRect(origin: CGPoint(), size: iconSize)
self.iconNode.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
}
private var previousIcon: VoiceChatActionButtonIconAnimationState?
private func applyIconParams() {
guard let (_, _, state, _, _, _, _, _) = self.currentParams else {
return
}
let icon: VoiceChatActionButtonIconAnimationState
switch state {
case .button:
icon = .empty
case let .scheduled(state):
switch state {
case .start:
icon = .start
case .subscribe:
icon = .subscribe
case .unsubscribe:
icon = .unsubscribe
}
case let .active(state):
switch state {
case .on:
icon = .unmute
case .muted:
icon = .mute
case .cantSpeak:
icon = .hand
}
case .connecting:
if let previousIcon = previousIcon {
icon = previousIcon
} else {
icon = .mute
}
}
self.previousIcon = icon
self.iconNode.enqueueState(icon)
}
public func update(snap: Bool, animated: Bool) {
if let previous = self.currentParams {
self.currentParams = (previous.size, previous.buttonSize, previous.state, previous.dark, previous.small, previous.title, previous.subtitle, snap)
self.backgroundNode.isSnap = snap
self.backgroundNode.glowHidden = snap || previous.small
self.backgroundNode.updateColors()
self.applyParams(animated: animated)
self.applyIconParams()
}
}
public func update(size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, title: String, subtitle: String, dark: Bool, small: Bool, animated: Bool = false) {
let previous = self.currentParams
let previousState = previous?.state
self.currentParams = (size, buttonSize, state, dark, small, title, subtitle, previous?.snap ?? false)
self.statePromise.set(state)
if let previousState = previousState, case .button = previousState, case .scheduled = state {
self.buttonTitleLabel.alpha = 0.0
self.buttonTitleLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.buttonTitleLabel.layer.animateScale(from: 1.0, to: 0.001, duration: 0.24)
self.iconNode.alpha = 1.0
self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.iconNode.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.42, damping: 104.0)
}
var backgroundState: VoiceChatActionButtonBackgroundNode.State
var animated = true
switch state {
case let .button(text):
backgroundState = .button
self.buttonTitleLabel.alpha = 1.0
self.buttonTitleLabel.attributedText = NSAttributedString(string: text, font: Font.semibold(17.0), textColor: .white)
let titleSize = self.buttonTitleLabel.updateLayout(CGSize(width: size.width, height: 100.0))
self.buttonTitleLabel.frame = CGRect(origin: CGPoint(x: floor((self.bounds.width - titleSize.width) / 2.0), y: floor((self.bounds.height - titleSize.height) / 2.0)), size: titleSize)
case .scheduled:
backgroundState = .disabled
if previousState == .connecting {
animated = false
}
case let .active(state):
switch state {
case .on:
backgroundState = .blob(true)
case .muted:
backgroundState = .blob(false)
case .cantSpeak:
backgroundState = .disabled
}
case .connecting:
backgroundState = .connecting
}
self.applyIconParams()
self.backgroundNode.glowHidden = (self.currentParams?.snap ?? false) || small
self.backgroundNode.isDark = dark
self.backgroundNode.update(state: backgroundState, animated: animated)
if case .active = state, let previousState = previousState, case .connecting = previousState, animated {
self.activeDisposable.set((self.activePromise.get()
|> deliverOnMainQueue).start(next: { [weak self] active in
if active {
self?.activeDisposable.set(nil)
self?.applyParams(animated: true)
}
}))
} else {
self.applyParams(animated: animated)
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
var hitRect = self.bounds
if let (_, buttonSize, state, _, _, _, _, _) = self.currentParams {
if case .button = state {
hitRect = CGRect(x: 0.0, y: floor((self.bounds.height - VoiceChatActionButton.buttonHeight) / 2.0), width: self.bounds.width, height: VoiceChatActionButton.buttonHeight)
} else {
hitRect = self.bounds.insetBy(dx: (self.bounds.width - buttonSize.width) / 2.0, dy: (self.bounds.height - buttonSize.height) / 2.0)
}
}
let result = super.hitTest(point, with: event)
if !hitRect.contains(point) {
return nil
}
return result
}
public func playAnimation() {
self.iconNode.playRandomAnimation()
}
}
public extension UIBezierPath {
static func smoothCurve(through points: [CGPoint], length: CGFloat, smoothness: CGFloat, curve: Bool = false) -> UIBezierPath {
var smoothPoints = [SmoothPoint]()
for index in (0 ..< points.count) {
let prevIndex = index - 1
let prev = points[prevIndex >= 0 ? prevIndex : points.count + prevIndex]
let curr = points[index]
let next = points[(index + 1) % points.count]
let angle: CGFloat = {
let dx = next.x - prev.x
let dy = -next.y + prev.y
let angle = atan2(dy, dx)
if angle < 0 {
return abs(angle)
} else {
return 2 * .pi - angle
}
}()
smoothPoints.append(
SmoothPoint(
point: curr,
inAngle: angle + .pi,
inLength: smoothness * distance(from: curr, to: prev),
outAngle: angle,
outLength: smoothness * distance(from: curr, to: next)
)
)
}
let resultPath = UIBezierPath()
if curve {
resultPath.move(to: CGPoint())
resultPath.addLine(to: smoothPoints[0].point)
} else {
resultPath.move(to: smoothPoints[0].point)
}
let smoothCount = curve ? smoothPoints.count - 1 : smoothPoints.count
for index in (0 ..< smoothCount) {
let curr = smoothPoints[index]
let next = smoothPoints[(index + 1) % points.count]
let currSmoothOut = curr.smoothOut()
let nextSmoothIn = next.smoothIn()
resultPath.addCurve(to: next.point, controlPoint1: currSmoothOut, controlPoint2: nextSmoothIn)
}
if curve {
resultPath.addLine(to: CGPoint(x: length, y: 0.0))
}
resultPath.close()
return resultPath
}
static private func distance(from fromPoint: CGPoint, to toPoint: CGPoint) -> CGFloat {
return sqrt((fromPoint.x - toPoint.x) * (fromPoint.x - toPoint.x) + (fromPoint.y - toPoint.y) * (fromPoint.y - toPoint.y))
}
struct SmoothPoint {
let point: CGPoint
let inAngle: CGFloat
let inLength: CGFloat
let outAngle: CGFloat
let outLength: CGFloat
func smoothIn() -> CGPoint {
return smooth(angle: inAngle, length: inLength)
}
func smoothOut() -> CGPoint {
return smooth(angle: outAngle, length: outLength)
}
private func smooth(angle: CGFloat, length: CGFloat) -> CGPoint {
return CGPoint(
x: point.x + length * cos(angle),
y: point.y + length * sin(angle)
)
}
}
}
@@ -0,0 +1,744 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
private let progressLineWidth: CGFloat = 3.0 + UIScreenPixel
private let buttonSize = CGSize(width: 112.0, height: 112.0)
private let radius = buttonSize.width / 2.0
private let areaSize = CGSize(width: 300.0, height: 300.0)
private let blobSize = CGSize(width: 190.0, height: 190.0)
private let secondaryGreyColor = UIColor(rgb: 0x1c1c1e)
private let whiteColor = UIColor(rgb: 0xffffff)
private let greyColor = UIColor(rgb: 0x2c2c2e)
private let blue = UIColor(rgb: 0x007fff)
private let lightBlue = UIColor(rgb: 0x00affe)
private let green = UIColor(rgb: 0x33c659)
private let activeBlue = UIColor(rgb: 0x00a0b9)
private let purple = UIColor(rgb: 0x3252ef)
private let pink = UIColor(rgb: 0xef436c)
final class VoiceChatActionButtonBackgroundNode: ASDisplayNode {
enum State: Equatable {
case connecting
case disabled
case button
case blob(Bool)
}
private var state: State
private var hasState = false
private var transition: State?
var audioLevel: CGFloat = 0.0 {
didSet {
self.maskBlobView.updateLevel(self.audioLevel, immediately: false)
}
}
var updatedActive: ((Bool) -> Void)?
var updatedColors: ((UIColor?, UIColor?) -> Void)?
private let backgroundCircleLayer = CAShapeLayer()
private let foregroundCircleLayer = CAShapeLayer()
private let growingForegroundCircleLayer = CAShapeLayer()
private let foregroundView = UIView()
private let foregroundGradientLayer = CAGradientLayer()
private let maskView = UIView()
private let maskGradientLayer = CAGradientLayer()
private let maskBlobView: VoiceBlobView
private let maskCircleLayer = CAShapeLayer()
let maskProgressLayer = CAShapeLayer()
private let maskMediumBlobLayer = CAShapeLayer()
private let maskBigBlobLayer = CAShapeLayer()
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = false
var ignoreHierarchyChanges = false
override init() {
self.state = .connecting
self.maskBlobView = VoiceBlobView(frame: CGRect(origin: CGPoint(x: (areaSize.width - blobSize.width) / 2.0, y: (areaSize.height - blobSize.height) / 2.0), size: blobSize), maxLevel: 1.5, mediumBlobRange: (0.69, 0.87), bigBlobRange: (0.71, 1.0))
self.maskBlobView.setColor(whiteColor)
self.maskBlobView.isHidden = true
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
super.init()
self.addSubnode(self.hierarchyTrackingNode)
let circlePath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: buttonSize)).cgPath
self.backgroundCircleLayer.fillColor = greyColor.cgColor
self.backgroundCircleLayer.path = circlePath
let smallerCirclePath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: CGSize(width: buttonSize.width - progressLineWidth, height: buttonSize.height - progressLineWidth))).cgPath
self.foregroundCircleLayer.fillColor = greyColor.cgColor
self.foregroundCircleLayer.path = smallerCirclePath
self.foregroundCircleLayer.transform = CATransform3DMakeScale(0.0, 0.0, 1)
self.foregroundCircleLayer.isHidden = true
self.growingForegroundCircleLayer.fillColor = greyColor.cgColor
self.growingForegroundCircleLayer.path = smallerCirclePath
self.growingForegroundCircleLayer.transform = CATransform3DMakeScale(1.0, 1.0, 1)
self.growingForegroundCircleLayer.isHidden = true
self.foregroundGradientLayer.type = .radial
self.foregroundGradientLayer.colors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
self.foregroundGradientLayer.locations = [0.0, 0.55, 1.0]
self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
self.maskView.backgroundColor = .clear
self.maskGradientLayer.type = .radial
self.maskGradientLayer.colors = [UIColor(rgb: 0xffffff, alpha: 0.4).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.maskGradientLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1.0)
self.maskGradientLayer.isHidden = true
let path = CGMutablePath()
path.addArc(center: CGPoint(x: (buttonSize.width + 6.0) / 2.0, y: (buttonSize.height + 6.0) / 2.0), radius: radius, startAngle: 0.0, endAngle: CGFloat.pi * 2.0, clockwise: true)
self.maskProgressLayer.strokeColor = whiteColor.cgColor
self.maskProgressLayer.fillColor = UIColor.clear.cgColor
self.maskProgressLayer.lineWidth = progressLineWidth
self.maskProgressLayer.lineCap = .round
self.maskProgressLayer.path = path
let circleFrame = CGRect(origin: CGPoint(x: (areaSize.width - buttonSize.width) / 2.0, y: (areaSize.height - buttonSize.height) / 2.0), size: buttonSize).insetBy(dx: -progressLineWidth / 2.0, dy: -progressLineWidth / 2.0)
let largerCirclePath = UIBezierPath(roundedRect: CGRect(x: circleFrame.minX, y: circleFrame.minY, width: circleFrame.width, height: circleFrame.height), cornerRadius: circleFrame.width / 2.0).cgPath
self.maskCircleLayer.path = largerCirclePath
self.maskCircleLayer.fillColor = whiteColor.cgColor
self.maskCircleLayer.isHidden = true
updateInHierarchy = { [weak self] value in
if let strongSelf = self, !strongSelf.ignoreHierarchyChanges {
strongSelf.isCurrentlyInHierarchy = value
strongSelf.updateAnimations()
}
}
}
override func didLoad() {
super.didLoad()
self.layer.addSublayer(self.backgroundCircleLayer)
self.view.addSubview(self.foregroundView)
self.layer.addSublayer(self.foregroundCircleLayer)
self.layer.addSublayer(self.growingForegroundCircleLayer)
self.foregroundView.mask = self.maskView
self.foregroundView.layer.addSublayer(self.foregroundGradientLayer)
self.maskView.layer.addSublayer(self.maskGradientLayer)
self.maskView.layer.addSublayer(self.maskProgressLayer)
self.maskView.addSubview(self.maskBlobView)
self.maskView.layer.addSublayer(self.maskCircleLayer)
self.maskBlobView.scaleUpdated = { [weak self] scale in
if let strongSelf = self {
strongSelf.updateGlowScale(strongSelf.isActive ? scale : nil)
}
}
}
private func setupGradientAnimations() {
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
} else {
let previousValue = self.foregroundGradientLayer.startPoint
let newValue: CGPoint
if self.maskBlobView.presentationAudioLevel > 0.22 {
newValue = CGPoint(x: CGFloat.random(in: 0.9 ..< 1.0), y: CGFloat.random(in: 0.15 ..< 0.35))
} else if self.maskBlobView.presentationAudioLevel > 0.01 {
newValue = CGPoint(x: CGFloat.random(in: 0.57 ..< 0.85), y: CGFloat.random(in: 0.15 ..< 0.45))
} else {
newValue = CGPoint(x: CGFloat.random(in: 0.6 ..< 0.75), y: CGFloat.random(in: 0.25 ..< 0.45))
}
self.foregroundGradientLayer.startPoint = newValue
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "startPoint")
animation.duration = Double.random(in: 0.8 ..< 1.4)
animation.fromValue = previousValue
animation.toValue = newValue
CATransaction.setCompletionBlock { [weak self] in
if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy {
self?.setupGradientAnimations()
}
}
self.foregroundGradientLayer.add(animation, forKey: "movement")
CATransaction.commit()
}
}
private func setupProgressAnimations() {
if let _ = self.maskProgressLayer.animation(forKey: "progressRotation") {
} else {
self.maskProgressLayer.isHidden = false
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
animation.duration = 1.0
animation.fromValue = NSNumber(value: Float(0.0))
animation.toValue = NSNumber(value: Float.pi * 2.0)
animation.repeatCount = Float.infinity
animation.beginTime = 0.0
self.maskProgressLayer.add(animation, forKey: "progressRotation")
let shrinkAnimation = CABasicAnimation(keyPath: "strokeEnd")
shrinkAnimation.fromValue = 1.0
shrinkAnimation.toValue = 0.0
shrinkAnimation.duration = 1.0
shrinkAnimation.beginTime = 0.0
let growthAnimation = CABasicAnimation(keyPath: "strokeEnd")
growthAnimation.fromValue = 0.0
growthAnimation.toValue = 1.0
growthAnimation.duration = 1.0
growthAnimation.beginTime = 1.0
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = CGFloat.pi * 2
rotateAnimation.isAdditive = true
rotateAnimation.duration = 1.0
rotateAnimation.beginTime = 1.0
let groupAnimation = CAAnimationGroup()
groupAnimation.repeatCount = Float.infinity
groupAnimation.animations = [shrinkAnimation, growthAnimation, rotateAnimation]
groupAnimation.duration = 2.0
self.maskProgressLayer.add(groupAnimation, forKey: "progressGrowth")
}
}
var glowHidden: Bool = false {
didSet {
if self.glowHidden != oldValue {
let initialAlpha = CGFloat(self.maskProgressLayer.opacity)
let targetAlpha: CGFloat = self.glowHidden ? 0.0 : 1.0
self.maskGradientLayer.opacity = Float(targetAlpha)
self.maskGradientLayer.animateAlpha(from: initialAlpha, to: targetAlpha, duration: 0.2)
}
}
}
var disableGlowAnimations = false
func updateGlowScale(_ scale: CGFloat?) {
if self.disableGlowAnimations {
return
}
if let scale = scale {
self.maskGradientLayer.transform = CATransform3DMakeScale(0.89 + 0.11 * scale, 0.89 + 0.11 * scale, 1.0)
} else {
let initialScale: CGFloat = ((self.maskGradientLayer.value(forKeyPath: "presentationLayer.transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (((self.maskGradientLayer.value(forKeyPath: "transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (0.89))
let targetScale: CGFloat = self.isActive ? 0.89 : 0.85
if abs(targetScale - initialScale) > 0.03 {
self.maskGradientLayer.transform = CATransform3DMakeScale(targetScale, targetScale, 1.0)
self.maskGradientLayer.animateScale(from: initialScale, to: targetScale, duration: 0.3)
}
}
}
enum Gradient {
case speaking
case active
case connecting
case muted
}
func updateGlowAndGradientAnimations(type: Gradient, previousType: Gradient? = nil, animated: Bool = true) {
let effectivePreviousTyoe = previousType ?? .active
let scale: CGFloat
if case .speaking = effectivePreviousTyoe {
scale = 0.95
} else {
scale = 0.8
}
let initialScale: CGFloat = ((self.maskGradientLayer.value(forKeyPath: "presentationLayer.transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (((self.maskGradientLayer.value(forKeyPath: "transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? scale)
let initialColors = self.foregroundGradientLayer.colors
let outerColor: UIColor?
let activeColor: UIColor?
let targetColors: [CGColor]
let targetScale: CGFloat
switch type {
case .speaking:
targetColors = [activeBlue.cgColor, green.cgColor, green.cgColor]
targetScale = 0.89
outerColor = UIColor(rgb: 0x134b22)
activeColor = green
case .active:
targetColors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
targetScale = 0.85
outerColor = UIColor(rgb: 0x002e5d)
activeColor = blue
case .connecting:
targetColors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
targetScale = 0.3
outerColor = nil
activeColor = blue
case .muted:
targetColors = [pink.cgColor, purple.cgColor, purple.cgColor]
targetScale = 0.85
outerColor = UIColor(rgb: 0x24306b)
activeColor = purple
}
self.updatedColors?(outerColor, activeColor)
self.maskGradientLayer.transform = CATransform3DMakeScale(targetScale, targetScale, 1.0)
if let _ = previousType {
self.maskGradientLayer.animateScale(from: initialScale, to: targetScale, duration: 0.3)
} else if animated {
self.maskGradientLayer.animateSpring(from: initialScale as NSNumber, to: targetScale as NSNumber, keyPath: "transform.scale", duration: 0.45)
}
self.foregroundGradientLayer.colors = targetColors
if animated {
self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
}
}
private func playMuteAnimation() {
if self.animationsEnabled {
self.maskBlobView.startAnimating()
}
self.maskBlobView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
if strongSelf.state != .connecting {
return
}
strongSelf.maskBlobView.isHidden = true
strongSelf.maskBlobView.stopAnimating()
strongSelf.maskBlobView.layer.removeAllAnimations()
})
}
var animatingDisappearance = false
private func playDeactivationAnimation() {
if self.animatingDisappearance {
return
}
self.animatingDisappearance = true
CATransaction.begin()
CATransaction.setDisableActions(true)
self.growingForegroundCircleLayer.isHidden = false
CATransaction.commit()
self.disableGlowAnimations = true
self.maskGradientLayer.removeAllAnimations()
self.updateGlowAndGradientAnimations(type: .connecting, previousType: nil)
if self.animationsEnabled {
self.maskBlobView.startAnimating()
}
self.maskBlobView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
if strongSelf.state != .connecting {
return
}
strongSelf.maskBlobView.isHidden = true
strongSelf.maskBlobView.stopAnimating()
strongSelf.maskBlobView.layer.removeAllAnimations()
})
CATransaction.begin()
let growthAnimation = CABasicAnimation(keyPath: "transform.scale")
growthAnimation.fromValue = 0.0
growthAnimation.toValue = 1.0
growthAnimation.duration = 0.15
growthAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
growthAnimation.isRemovedOnCompletion = false
growthAnimation.fillMode = .forwards
CATransaction.setCompletionBlock {
self.animatingDisappearance = false
self.growingForegroundCircleLayer.isHidden = true
self.disableGlowAnimations = false
if self.state != .connecting {
return
}
CATransaction.begin()
CATransaction.setDisableActions(true)
self.maskGradientLayer.isHidden = true
self.maskCircleLayer.isHidden = true
self.growingForegroundCircleLayer.removeAllAnimations()
CATransaction.commit()
}
self.growingForegroundCircleLayer.add(growthAnimation, forKey: "insideGrowth")
CATransaction.commit()
}
private func playActivationAnimation(active: Bool) {
CATransaction.begin()
CATransaction.setDisableActions(true)
self.maskCircleLayer.isHidden = false
self.maskProgressLayer.isHidden = true
self.maskGradientLayer.isHidden = false
CATransaction.commit()
self.maskGradientLayer.removeAllAnimations()
self.updateGlowAndGradientAnimations(type: active ? .speaking : .active, previousType: nil)
self.maskBlobView.isHidden = false
if self.animationsEnabled {
self.maskBlobView.startAnimating()
}
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
}
private func playConnectionAnimation(type: Gradient, completion: @escaping () -> Void) {
CATransaction.begin()
let initialRotation: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.transform.rotation.z") as? NSNumber)?.floatValue ?? 0.0)
let initialStrokeEnd: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.strokeEnd") as? NSNumber)?.floatValue ?? 1.0)
self.maskProgressLayer.removeAnimation(forKey: "progressGrowth")
self.maskProgressLayer.removeAnimation(forKey: "progressRotation")
let duration: Double = (1.0 - Double(initialStrokeEnd)) * 0.3
let growthAnimation = CABasicAnimation(keyPath: "strokeEnd")
growthAnimation.fromValue = initialStrokeEnd
growthAnimation.toValue = 1.0
growthAnimation.duration = duration
growthAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotateAnimation.fromValue = initialRotation
rotateAnimation.toValue = initialRotation + CGFloat.pi * 2
rotateAnimation.isAdditive = true
rotateAnimation.duration = duration
rotateAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [growthAnimation, rotateAnimation]
groupAnimation.duration = duration
CATransaction.setCompletionBlock {
var active = true
if case .connecting = self.state {
active = false
}
if active {
CATransaction.begin()
CATransaction.setDisableActions(true)
self.foregroundCircleLayer.isHidden = false
self.foregroundCircleLayer.transform = CATransform3DMakeScale(1.0, 1.0, 1.0)
self.maskCircleLayer.isHidden = false
self.maskProgressLayer.isHidden = true
self.maskGradientLayer.isHidden = false
CATransaction.commit()
completion()
self.updateGlowAndGradientAnimations(type: type, previousType: nil)
if case .connecting = self.state {
} else {
self.maskBlobView.isHidden = false
if self.animationsEnabled {
self.maskBlobView.startAnimating()
}
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
}
self.updatedActive?(true)
CATransaction.begin()
let shrinkAnimation = CABasicAnimation(keyPath: "transform.scale")
shrinkAnimation.fromValue = 1.0
shrinkAnimation.toValue = 0.00001
shrinkAnimation.duration = 0.15
shrinkAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
shrinkAnimation.isRemovedOnCompletion = false
shrinkAnimation.fillMode = .forwards
CATransaction.setCompletionBlock {
CATransaction.begin()
CATransaction.setDisableActions(true)
self.foregroundCircleLayer.isHidden = true
self.foregroundCircleLayer.transform = CATransform3DMakeScale(0.0, 0.0, 1.0)
self.foregroundCircleLayer.removeAllAnimations()
CATransaction.commit()
}
self.foregroundCircleLayer.add(shrinkAnimation, forKey: "insideShrink")
CATransaction.commit()
}
}
self.maskProgressLayer.add(groupAnimation, forKey: "progressCompletion")
CATransaction.commit()
}
private var maskIsCircle = true
private func setupButtonAnimation() {
CATransaction.begin()
CATransaction.setDisableActions(true)
self.backgroundCircleLayer.isHidden = true
self.foregroundCircleLayer.isHidden = true
self.maskCircleLayer.isHidden = false
self.maskProgressLayer.isHidden = true
self.maskGradientLayer.isHidden = true
let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: floor((self.bounds.height - VoiceChatActionButton.buttonHeight) / 2.0), width: self.bounds.width, height: VoiceChatActionButton.buttonHeight), cornerRadius: 10.0).cgPath
self.maskCircleLayer.path = path
self.maskIsCircle = false
CATransaction.commit()
self.updateGlowAndGradientAnimations(type: .muted, previousType: nil)
self.updatedActive?(true)
}
private func playScheduledAnimation() {
CATransaction.begin()
CATransaction.setDisableActions(true)
self.maskGradientLayer.isHidden = false
CATransaction.commit()
let circleFrame = CGRect(origin: CGPoint(x: (self.bounds.width - buttonSize.width) / 2.0, y: (self.bounds.height - buttonSize.height) / 2.0), size: buttonSize).insetBy(dx: -progressLineWidth / 2.0, dy: -progressLineWidth / 2.0)
let largerCirclePath = UIBezierPath(roundedRect: CGRect(x: circleFrame.minX, y: circleFrame.minY, width: circleFrame.width, height: circleFrame.height), cornerRadius: circleFrame.width / 2.0).cgPath
let previousPath = self.maskCircleLayer.path
self.maskCircleLayer.path = largerCirclePath
self.maskIsCircle = true
self.maskCircleLayer.animateSpring(from: previousPath as AnyObject, to: largerCirclePath as AnyObject, keyPath: "path", duration: 0.6, initialVelocity: 0.0, damping: 100.0)
self.maskBlobView.isHidden = false
if self.animationsEnabled {
self.maskBlobView.startAnimating()
}
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, damping: 100.0)
self.disableGlowAnimations = true
self.maskGradientLayer.removeAllAnimations()
self.maskGradientLayer.animateSpring(from: 0.3 as NSNumber, to: 0.85 as NSNumber, keyPath: "transform.scale", duration: 0.45, completion: { [weak self] _ in
self?.disableGlowAnimations = false
})
}
var animationsEnabled: Bool = true {
didSet {
self.updateAnimations()
}
}
var isActive = false
func updateAnimations() {
if !self.isCurrentlyInHierarchy {
self.foregroundGradientLayer.removeAllAnimations()
self.growingForegroundCircleLayer.removeAllAnimations()
self.maskGradientLayer.removeAllAnimations()
self.maskProgressLayer.removeAllAnimations()
self.maskBlobView.stopAnimating()
return
}
if !self.animationsEnabled {
self.foregroundGradientLayer.removeAllAnimations()
self.maskBlobView.stopAnimating()
} else {
self.setupGradientAnimations()
}
switch self.state {
case .connecting:
self.updatedActive?(false)
if let transition = self.transition {
self.updateGlowScale(nil)
if case .blob = transition {
self.playDeactivationAnimation()
} else if case .disabled = transition {
self.playDeactivationAnimation()
}
self.transition = nil
}
self.setupProgressAnimations()
self.isActive = false
case let .blob(newActive):
if let transition = self.transition {
let type: Gradient = newActive ? .speaking : .active
if transition == .connecting {
self.playConnectionAnimation(type: type) { [weak self] in
self?.isActive = newActive
}
} else if transition == .disabled {
self.playActivationAnimation(active: newActive)
self.transition = nil
self.isActive = newActive
self.updatedActive?(true)
} else if case let .blob(previousActive) = transition {
self.updateGlowAndGradientAnimations(type: type, previousType: previousActive ? .speaking : .active)
self.transition = nil
self.isActive = newActive
}
self.transition = nil
} else {
if self.animationsEnabled {
self.maskBlobView.startAnimating()
}
}
case .disabled:
self.updatedActive?(true)
self.isActive = false
if let transition = self.transition {
if case .button = transition {
self.playScheduledAnimation()
} else if case .connecting = transition {
self.playConnectionAnimation(type: .muted) { [weak self] in
self?.isActive = false
}
} else if case let .blob(previousActive) = transition {
self.updateGlowAndGradientAnimations(type: .muted, previousType: previousActive ? .speaking : .active)
self.playMuteAnimation()
}
self.transition = nil
} else {
if self.maskBlobView.isHidden {
self.updateGlowAndGradientAnimations(type: .muted, previousType: nil, animated: false)
self.maskCircleLayer.isHidden = false
self.maskProgressLayer.isHidden = true
self.maskGradientLayer.isHidden = false
self.maskBlobView.isHidden = false
if self.animationsEnabled {
self.maskBlobView.startAnimating()
}
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
}
}
case .button:
self.updatedActive?(true)
self.isActive = false
self.setupButtonAnimation()
}
}
var isDark: Bool = false {
didSet {
if self.isDark != oldValue {
self.updateColors()
}
}
}
var isSnap: Bool = false {
didSet {
if self.isSnap != oldValue {
self.updateColors()
}
}
}
var connectingColor: UIColor = UIColor(rgb: 0xb6b6bb) {
didSet {
if self.connectingColor.rgb != oldValue.rgb {
self.updateColors()
}
}
}
func updateColors() {
let previousColor: CGColor = self.backgroundCircleLayer.fillColor ?? greyColor.cgColor
let targetColor: CGColor
if self.isSnap {
targetColor = self.connectingColor.cgColor
} else if self.isDark {
targetColor = secondaryGreyColor.cgColor
} else {
targetColor = greyColor.cgColor
}
self.backgroundCircleLayer.fillColor = targetColor
self.foregroundCircleLayer.fillColor = targetColor
self.growingForegroundCircleLayer.fillColor = targetColor
self.backgroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
self.foregroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
self.growingForegroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
}
func update(state: State, animated: Bool) {
var animated = animated
var hadState = true
if !self.hasState {
hadState = false
self.hasState = true
animated = false
}
if state != self.state || !hadState {
if animated {
self.transition = self.state
}
self.state = state
}
self.updateAnimations()
}
var previousSize: CGSize?
override func layout() {
super.layout()
let sizeUpdated = self.previousSize != self.bounds.size
self.previousSize = self.bounds.size
let bounds = CGRect(x: (self.bounds.width - areaSize.width) / 2.0, y: (self.bounds.height - areaSize.height) / 2.0, width: areaSize.width, height: areaSize.height)
let center = bounds.center
self.maskBlobView.frame = CGRect(origin: CGPoint(x: bounds.minX + (bounds.width - blobSize.width) / 2.0, y: bounds.minY + (bounds.height - blobSize.height) / 2.0), size: blobSize)
let circleFrame = CGRect(origin: CGPoint(x: bounds.minX + (bounds.width - buttonSize.width) / 2.0, y: bounds.minY + (bounds.height - buttonSize.height) / 2.0), size: buttonSize)
self.backgroundCircleLayer.frame = circleFrame
self.foregroundCircleLayer.position = center
self.foregroundCircleLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: circleFrame.width - progressLineWidth, height: circleFrame.height - progressLineWidth))
self.growingForegroundCircleLayer.position = center
self.growingForegroundCircleLayer.bounds = self.foregroundCircleLayer.bounds
self.maskCircleLayer.frame = self.bounds
if sizeUpdated && self.maskIsCircle {
CATransaction.begin()
CATransaction.setDisableActions(true)
let circleFrame = CGRect(origin: CGPoint(x: (self.bounds.width - buttonSize.width) / 2.0, y: (self.bounds.height - buttonSize.height) / 2.0), size: buttonSize).insetBy(dx: -progressLineWidth / 2.0, dy: -progressLineWidth / 2.0)
let largerCirclePath = UIBezierPath(roundedRect: CGRect(x: circleFrame.minX, y: circleFrame.minY, width: circleFrame.width, height: circleFrame.height), cornerRadius: circleFrame.width / 2.0).cgPath
self.maskCircleLayer.path = largerCirclePath
CATransaction.commit()
}
self.maskProgressLayer.frame = circleFrame.insetBy(dx: -3.0, dy: -3.0)
self.foregroundView.frame = self.bounds
self.foregroundGradientLayer.frame = self.bounds
self.maskGradientLayer.position = center
self.maskGradientLayer.bounds = bounds
self.maskView.frame = self.bounds
}
}
@@ -0,0 +1,143 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ManagedAnimationNode
public enum VoiceChatActionButtonIconAnimationState: Equatable {
case empty
case start
case subscribe
case unsubscribe
case unmute
case mute
case hand
}
public final class VoiceChatActionButtonIconNode: ManagedAnimationNode {
private let isColored: Bool
private var iconState: VoiceChatActionButtonIconAnimationState = .mute
public init(isColored: Bool) {
self.isColored = isColored
super.init(size: CGSize(width: 100.0, height: 100.0))
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceUnmute"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.1))
}
public func enqueueState(_ state: VoiceChatActionButtonIconAnimationState) {
guard self.iconState != state else {
return
}
let previousState = self.iconState
self.iconState = state
if state != .empty {
self.alpha = 1.0
}
switch previousState {
case .empty:
switch state {
case .start:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceStart"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
default:
break
}
case .subscribe:
switch state {
case .unsubscribe:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminder")))
case .mute:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminderToMute")))
case .hand:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminderToRaiseHand")))
default:
break
}
case .unsubscribe:
switch state {
case .subscribe:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminder")))
case .mute:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminderToMute")))
case .hand:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminderToRaiseHand")))
default:
break
}
case .start:
switch state {
case .mute:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceStart")))
default:
break
}
case .unmute:
switch state {
case .mute:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceMute")))
case .hand:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceUnmuteToRaiseHand")))
default:
break
}
case .mute:
switch state {
case .start:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceStart"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
case .unmute:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceUnmute")))
case .hand:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceMuteToRaiseHand")))
case .subscribe:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminderToRaiseHand"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
case .unsubscribe:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminderToRaiseHand"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
case .empty:
self.alpha = 0.0
default:
break
}
case .hand:
switch state {
case .mute, .unmute:
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceRaiseHandToMute")))
default:
break
}
}
}
public func playRandomAnimation() {
if case .hand = self.iconState {
if let next = self.trackStack.first, case let .local(name) = next.source, name.hasPrefix("VoiceHand_") {
return
}
var useTiredAnimation = false
var useAngryAnimation = false
let val = Float.random(in: 0.0..<1.0)
if val <= 0.01 {
useTiredAnimation = true
} else if val <= 0.05 {
useAngryAnimation = true
}
let normalAnimations = ["VoiceHand_1", "VoiceHand_2", "VoiceHand_3", "VoiceHand_4", "VoiceHand_7", "VoiceHand_8"]
let tiredAnimations = ["VoiceHand_5", "VoiceHand_6"]
let angryAnimations = ["VoiceHand_9", "VoiceHand_10"]
let animations: [String]
if useTiredAnimation {
animations = tiredAnimations
} else if useAngryAnimation {
animations = angryAnimations
} else {
animations = normalAnimations
}
if let animationName = animations.randomElement() {
self.trackTo(item: ManagedAnimationItem(source: .local(animationName)))
}
}
}
}
@@ -0,0 +1,53 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import AnimationUI
import AppBundle
public final class VoiceChatRaiseHandNode: ASDisplayNode {
private let animationNode: AnimationNode
private let color: UIColor?
private var playedOnce = false
public init(color: UIColor?) {
self.color = color
if let color = color, let url = getAppBundle().url(forResource: "anim_hand1", withExtension: "json"), let data = try? Data(contentsOf: url) {
self.animationNode = AnimationNode(animationData: transformedWithColors(data: data, colors: [(UIColor(rgb: 0xffffff), color)]))
} else {
self.animationNode = AnimationNode(animation: "anim_hand1", colors: nil, scale: 0.5)
}
super.init()
self.addSubnode(self.animationNode)
}
public func playRandomAnimation() {
guard self.playedOnce else {
self.playedOnce = true
self.animationNode.play()
return
}
guard !self.animationNode.isPlaying else {
self.animationNode.completion = { [weak self] in
self?.playRandomAnimation()
}
return
}
self.animationNode.completion = nil
if let animationName = ["anim_hand1", "anim_hand2", "anim_hand3", "anim_hand4"].randomElement() {
if let color = color, let url = getAppBundle().url(forResource: animationName, withExtension: "json"), let data = try? Data(contentsOf: url) {
self.animationNode.setAnimation(data: transformedWithColors(data: data, colors: [(UIColor(rgb: 0xffffff), color)]))
} else {
self.animationNode.setAnimation(name: animationName)
}
self.animationNode.play()
}
}
override public func layout() {
super.layout()
self.animationNode.frame = self.bounds
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}