mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
* ios: fix XCode 16 regressions (tap not working on files, quotes, images, voice messages, etc.), open link previews on tap * fix voice recording * fix video, accepting calls from chat, preference toggles in chat * WIP message and meta * handle links in attributed strings * custom attribute for links to prevent race conditions with default tap handler
524 lines
21 KiB
Swift
524 lines
21 KiB
Swift
//
|
|
// CIVideoView.swift
|
|
// SimpleX
|
|
//
|
|
// Created by Avently on 30/03/2023.
|
|
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import AVKit
|
|
import SimpleXChat
|
|
import Combine
|
|
|
|
struct CIVideoView: View {
|
|
@EnvironmentObject var m: ChatModel
|
|
private let chatItem: ChatItem
|
|
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, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
|
self.chatItem = chatItem
|
|
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 * preview.size.height / preview.size.width)
|
|
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
|
|
if playingUrl != url {
|
|
player.pause()
|
|
videoPlaying = false
|
|
}
|
|
}
|
|
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
|
.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()
|
|
.scaledToFit()
|
|
.frame(width: w)
|
|
.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) {
|
|
Task {
|
|
if let user = m.currentUser {
|
|
await receiveFile(user, file.fileId, false, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|