Files
simplex-chat/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
T
EvgenyGitHubEvgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>shum
ad23da63d0 core: supporter badges using anonymous BBS credentials (#7040)
* core: supporter badges using anonymous BBS credentials

* badges in profiles

* badge in profiles

* process badges

* update simplexmq

* update simplexmq

* change types

* fix migration

* migration

* update simplexmq

* fix bot API, schema

* fix postgresql build

* refactor

* postgresql schema

* correctly set badges in all cases

* badges ffi

* plan, bot types

* FFI

* FFI: export badge symbols

* add extra field

* refactor badge types to GADT

* configurable badge key

* add badge to profile, test

* ui: badge images

* generate badge key and sign badge

* badge sign in CLI

* fix commands, ui

* rename badges

* Binary

* image size, migration

* update badge images, add public key

* send badges in more cases

* update UI, tests

* bot types, schema

* postgres schema

* tone down badges

* revert formula

* refactor badges

* smaller badges

* badge position

* better badge position

* simpler

* position

* move position

* update simplexmq

* show badge after name

* badge layout

* fix badge

* debug badge height

* shift badge

* fix badge in member name

* bigger badge

* badge layout

* differentiate badge colors

* more avatars for the user's profiles

* refactor

* remove color filter

* alerts

* multiple keys, old expired

* use new BBS api

* update badge keys, bot api

* presentation header

* simplify

* parser

* update iOS images

* update public keys

* query plans

* update simplexmq

* refactor badge types

* simplexmq

* bot api types

* update simplexmq - commoncrypto flag

* update simplexmq

* pass commoncrypto flag to simplexmq in nix iOS build

* ios ui

* update core library, fixes

* badge layout

* badge size

* badge gap

* remove extensions

* simplify

* share badge in more events, reverify badge if verification failed

* larger files with badges

* allow sending larger files

* simpler

* update simplexmq

* better decoder for badge keys

* update simplexmq

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
2026-06-15 22:25:08 +01:00

541 lines
22 KiB
Swift

//
// CIVideoView.swift
// SimpleX
//
// Created by Avently on 30/03/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
// Spec: spec/client/chat-view.md
import SwiftUI
import AVKit
import SimpleXChat
import Combine
// Spec: spec/client/chat-view.md#CIVideoView
struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
private let chatItem: ChatItem
private let senderProfile: LocalProfile?
private let preview: UIImage?
@State private var duration: Int
@State private var progress: Int = 0
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
private var videoWidth: CGFloat?
private let smallView: Bool
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
self.chatItem = chatItem
self.senderProfile = senderProfile
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self.videoWidth = videoWidth
self.smallView = smallView
self._showFullScreenPlayer = showFullscreenPlayer
}
var body: some View {
let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) {
if let file, let preview {
if let urlDecrypted {
if smallView {
smallVideoView(urlDecrypted, file, preview)
} else if let player {
videoView(player, urlDecrypted, file, preview, duration)
}
} else if file.loaded {
if smallView {
smallVideoViewEncrypted(file, preview)
} else {
videoViewEncrypted(file, preview, duration)
}
} else {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.simultaneousGesture(TapGesture().onEnded {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted:
switch file.fileProtocol {
case .xftp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact completes uploading it."
)
case .smp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact is online, please wait or check later!"
)
case .local: ()
}
case .rcvTransfer: () // ?
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
default: ()
}
})
}
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView || !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
.simultaneousGesture(TapGesture().onEnded {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
})
}
}
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
}
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View {
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
.simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
})
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !blurred {
if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
}
})
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
}
}
}
}
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * heightRatio(preview.size))
.clipped()
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
if playingUrl != url {
player.pause()
videoPlaying = false
}
}
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
.if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded {
switch player.timeControlStatus {
case .playing:
player.pause()
videoPlaying = false
case .paused:
if canBePlayed {
showFullScreenPlayer = true
}
default: ()
}
})
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying && !blurred {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.simultaneousGesture(TapGesture().onEnded {
m.stopPreviousRecPlay = url
player.play()
})
.disabled(!canBePlayed)
}
}
fileStatusIcon()
}
.onAppear {
addObserver(player, url)
}
.onDisappear {
removeObserver()
player.pause()
videoPlaying = false
}
}
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
.foregroundColor(color)
.padding(.leading, smallView ? 0 : 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
.tint(color)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
private var fileSizeString: String {
if let file = chatItem.file, !videoPlaying {
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
} else {
""
}
}
private func durationProgress() -> some View {
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
.invertedForegroundStyle()
.font(.caption)
.padding(.vertical, 6)
.padding(.horizontal, 12)
}
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
return ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: w, height: w * heightRatio(img.size))
.clipped()
.modifier(PrivacyBlur(blurred: $blurred))
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
}
}
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
}
}
@ViewBuilder private func fileStatusIcon() -> some View {
if let file = chatItem.file {
switch file.fileStatus {
case .sndStored:
switch file.fileProtocol {
case .xftp: progressView()
case .smp: EmptyView()
case .local: EmptyView()
}
case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView()
case .local: EmptyView()
}
case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError):
fileIcon("xmark", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError)
})
case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError, temporary: true)
})
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case let .rcvTransfer(rcvProgress, rcvTotal):
if file.fileProtocol == .xftp && rcvProgress < rcvTotal {
progressCircle(rcvProgress, rcvTotal)
} else {
progressView()
}
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError):
fileIcon("xmark", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError)
})
case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError, temporary: true)
})
case .invalid: fileIcon("questionmark", 10, 13)
}
}
}
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon)
.resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.padding(smallView ? 0 : padding)
}
private func progressView() -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
.padding(smallView ? 0 : 11)
}
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle()
.trim(from: 0, to: Double(progress) / Double(total))
.stroke(style: StrokeStyle(lineWidth: 2))
.invertedForegroundStyle()
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11)
}
// TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
if fileSizeValid(file, senderProfile) {
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false)
}
}
} else {
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
)
}
}
private func fullScreenPlayer(_ url: URL) -> some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: fullPlayer)
.overlay(alignment: .topLeading, content: {
Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here
label: {
Image(systemName: "multiply")
.resizable()
.tint(.white)
.frame(width: 15, height: 15)
.padding(.leading, 15)
.padding(.top, 13)
}
)
})
.gesture(
DragGesture(minimumDistance: 80)
.onChanged { gesture in
let t = gesture.translation
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showFullScreenPlayer = false
}
}
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
if let player = fullPlayer {
player.play()
var played = false
publisher = player.publisher(for: \.timeControlStatus).sink { status in
if played || status == .playing {
AppDelegate.keepScreenOn(status == .playing)
AudioPlayer.changeAudioSession(status == .playing)
}
played = status == .playing
}
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
player.seek(to: CMTime.zero)
player.play()
}
}
}
}
.onDisappear {
if let fullScreenTimeObserver = fullScreenTimeObserver {
NotificationCenter.default.removeObserver(fullScreenTimeObserver)
}
fullScreenTimeObserver = nil
fullPlayer?.pause()
fullPlayer?.seek(to: CMTime.zero)
publisher?.cancel()
}
}
}
private func decrypt(file: CIFile, completed: (() -> Void)? = nil) {
if decryptionInProgress { return }
decryptionInProgress = true
Task {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run {
if let decrypted = urlDecrypted {
if !smallView {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
fullPlayer = AVPlayer(url: decrypted)
}
decryptionInProgress = false
completed?()
}
}
}
private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem {
let dur = CMTimeGetSeconds(item.duration)
if !dur.isInfinite && !dur.isNaN {
duration = Int(dur)
}
progress = Int(CMTimeGetSeconds(player.currentTime()))
// `if` prevents showing Play button while the playback seeks to start and then plays
if player.currentTime() != player.currentItem?.duration && player.currentTime() != .zero {
videoPlaying = player.timeControlStatus == .playing || player.timeControlStatus == .waitingToPlayAtSpecifiedRate
}
}
}
}
private func removeObserver() {
if let timeObserver = timeObserver {
player?.removeTimeObserver(timeObserver)
}
timeObserver = nil
}
}