0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,243 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
|
||||
@MainActor
|
||||
struct CallView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let call: CallSnapshot
|
||||
|
||||
private var conversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
|
||||
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
|
||||
}
|
||||
|
||||
private var remoteTrack: RTCVideoTrack? {
|
||||
model.remoteCallVideoTrack(for: call.id)
|
||||
}
|
||||
|
||||
private var localTrack: RTCVideoTrack? {
|
||||
model.localCallVideoTrack(for: call.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
background
|
||||
|
||||
VStack(spacing: 24) {
|
||||
callHeader
|
||||
.padding(.top, 22)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if remoteTrack == nil {
|
||||
contactIdentity
|
||||
}
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
controls
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if call.isVideoCall, let localTrack, call.isCameraEnabled {
|
||||
RTCVideoRendererView(track: localTrack, mirrored: true)
|
||||
.frame(width: 118, height: 168)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.stroke(.white.opacity(0.24), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.38), radius: 16, y: 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.padding(.top, 92)
|
||||
.padding(.trailing, 18)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
if call.isVideoCall, let remoteTrack {
|
||||
RTCVideoRendererView(track: remoteTrack)
|
||||
.ignoresSafeArea()
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.black.opacity(0.48),
|
||||
.clear,
|
||||
.black.opacity(0.62)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.11, green: 0.17, blue: 0.24), .black],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.overlay {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.24))
|
||||
.frame(width: 440, height: 440)
|
||||
.blur(radius: 90)
|
||||
.offset(x: 170, y: -260)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
private var callHeader: some View {
|
||||
VStack(spacing: 5) {
|
||||
Text(conversation.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
Text(statusText(at: context.date))
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var contactIdentity: some View {
|
||||
VStack(spacing: 18) {
|
||||
AvatarView(
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: call.peerJID),
|
||||
size: 132
|
||||
)
|
||||
.overlay {
|
||||
Circle().stroke(.white.opacity(0.14), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.32), radius: 28, y: 14)
|
||||
|
||||
Text(conversation.displayName)
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(call.isVideoCall ? "Видеозвонок Luma" : "Аудиозвонок Luma")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.66))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var controls: some View {
|
||||
if call.isIncomingRinging {
|
||||
HStack(spacing: 52) {
|
||||
CallControlButton(
|
||||
title: "Отклонить",
|
||||
systemImage: "phone.down.fill",
|
||||
color: .red,
|
||||
action: model.rejectCall
|
||||
)
|
||||
CallControlButton(
|
||||
title: "Ответить",
|
||||
systemImage: call.isVideoCall ? "video.fill" : "phone.fill",
|
||||
color: .green
|
||||
) {
|
||||
Task { await model.answerCall() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 22) {
|
||||
HStack(spacing: call.isVideoCall ? 10 : 24) {
|
||||
CallControlButton(
|
||||
title: call.isMuted ? "Включить" : "Микрофон",
|
||||
systemImage: call.isMuted ? "mic.slash.fill" : "mic.fill",
|
||||
color: call.isMuted ? .white.opacity(0.34) : .white.opacity(0.18)
|
||||
) {
|
||||
model.setCallMuted(!call.isMuted)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
CallControlButton(
|
||||
title: "Динамик",
|
||||
systemImage: call.isSpeakerEnabled ? "speaker.wave.3.fill" : "speaker.fill",
|
||||
color: call.isSpeakerEnabled ? .white.opacity(0.34) : .white.opacity(0.18)
|
||||
) {
|
||||
model.setCallSpeakerEnabled(!call.isSpeakerEnabled)
|
||||
}
|
||||
#endif
|
||||
|
||||
if call.isVideoCall {
|
||||
CallControlButton(
|
||||
title: call.isCameraEnabled ? "Камера" : "Включить",
|
||||
systemImage: call.isCameraEnabled ? "video.fill" : "video.slash.fill",
|
||||
color: call.isCameraEnabled ? .white.opacity(0.18) : .white.opacity(0.34)
|
||||
) {
|
||||
model.setCallCameraEnabled(!call.isCameraEnabled)
|
||||
}
|
||||
CallControlButton(
|
||||
title: "Повернуть",
|
||||
systemImage: "camera.rotate.fill",
|
||||
color: .white.opacity(0.18),
|
||||
action: model.switchCallCamera
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CallControlButton(
|
||||
title: "Завершить",
|
||||
systemImage: "phone.down.fill",
|
||||
color: .red,
|
||||
action: model.endCall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusText(at date: Date) -> String {
|
||||
switch call.phase {
|
||||
case .ringing:
|
||||
return call.direction == .incoming ? "Входящий звонок" : "Вызов…"
|
||||
case .connecting:
|
||||
return "Соединение…"
|
||||
case .connected:
|
||||
guard let connectedAt = call.connectedAt else { return "Соединено" }
|
||||
let total = max(0, Int(date.timeIntervalSince(connectedAt)))
|
||||
let hours = total / 3_600
|
||||
let minutes = (total % 3_600) / 60
|
||||
let seconds = total % 60
|
||||
return hours > 0
|
||||
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
: String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CallControlButton: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let color: Color
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 58, height: 58)
|
||||
.overlay {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
}
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(minWidth: 58)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user