import AudioSessionManager
import CocoaLumberjackSwift
import Foundation
import SwiftUI
import ThreemaFramework
import ThreemaMacros
import UIKit
protocol MWPhotoBrowserWrapperDelegate: AnyObject {
/// Called before the browser deletes messages
/// - Parameter objectIDs: Object IDs of messages that will be deleted
func willDeleteMessages(with objectIDs: [NSManagedObjectID])
}
final class MWPhotoBrowserWrapper: NSObject, MWPhotoBrowserDelegate, MWVideoDelegate, MWFileDelegate,
ModalNavigationControllerDelegate {
let conversation: ConversationEntity
weak var parentViewController: UIViewController?
let entityManager: EntityManager
private weak var delegate: MWPhotoBrowserWrapperDelegate?
private let audioSessionManager: any AudioSessionManagerProtocol
/// Held from the first video played until the browser goes away.
private var audioSessionClaim: AudioSessionClaim?
// MARK: - Lifecycle
init(
for conversation: ConversationEntity,
in parentViewController: UIViewController?,
entityManager: EntityManager,
delegate: MWPhotoBrowserWrapperDelegate,
audioSessionManager: any AudioSessionManagerProtocol = BusinessInjector.ui.audioSessionManager
) {
self.conversation = conversation
self.parentViewController = parentViewController
self.entityManager = entityManager
self.delegate = delegate
self.audioSessionManager = audioSessionManager
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(colorThemeChanged(notification:)),
name: NSNotification.Name(rawValue: kNotificationColorThemeChanged),
object: nil
)
}
deinit {
guard let claim = audioSessionClaim else {
return
}
// Copied into a local: the `Task` outlives this object.
let manager = audioSessionManager
Task {
await manager.release(claim)
}
}
private lazy var photoBrowser: MWPhotoBrowser? = createPhotoBrowser()
private var mediaMessages = [BaseMessageEntity]()
private var selectedMediaMessages = Set<UInt>()
func openPhotoBrowser(for message: BaseMessageEntity?) {
guard let message else {
return
}
prepareMedia()
guard let index = mediaMessages.firstIndex(of: message) else {
DDLogError("Tapped media not found in fetched ones.")
return
}
var autoPlayOnAppear = false
if let fileMessageProvider = message as? FileMessageProvider {
switch fileMessageProvider.fileMessageType {
case .video:
autoPlayOnAppear = true
default:
autoPlayOnAppear = false
}
}
openPhotoBrowser(currentMediaIndex: UInt(index), showGrid: false, autoPlayOnAppear: autoPlayOnAppear)
}
func openPhotoBrowser() {
openPhotoBrowser(currentMediaIndex: nil, showGrid: true)
}
// MARK: - MWPhotoBrowserDelegate
func numberOfPhotos(in photoBrowser: MWPhotoBrowser!) -> UInt {
UInt(mediaMessages.count)
}
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, photoAt index: UInt) -> MWPhotoProtocol! {
photoAtIndex(at: Int(index), thumbnail: false)
}
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, objectIDAt index: UInt) -> NSManagedObjectID! {
objectIDAtIndex(at: Int(index), thumbnail: false)
}
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, thumbPhotoAt index: UInt) -> MWPhotoProtocol! {
let media = photoAtIndex(at: Int(index), thumbnail: true)
media?.loadUnderlyingImageAndNotify()
return media
}
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, captionViewForPhotoAt index: UInt) -> MWCaptionView! {
guard let media = photoBrowser.photo(at: index) else {
return PhotoCaptionView()
}
switch media {
case let photo as MediaBrowserPhoto:
return PhotoCaptionView(photo: photo)
case let video as MediaBrowserVideo:
return VideoCaptionView(photo: video)
case let file as MediaBrowserFile:
return FileCaptionView(photo: file)
default:
DDLogError("Could not create caption view for photo browser")
fatalError()
}
}
// MARK: Deleting
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, deleteButton: UIBarButtonItem!, pressedForPhotoAt index: UInt) {
let media = photoBrowser.photo(at: index)
let deleteButtonTitle: String
switch media {
case _ as MediaBrowserPhoto:
deleteButtonTitle = #localize("delete_photo")
case _ as MediaBrowserVideo:
deleteButtonTitle = #localize("delete_video")
case _ as MediaBrowserFile:
deleteButtonTitle = #localize("delete_file")
default:
DDLogError("Could not create caption view for photo browser")
fatalError()
}
UIAlertTemplate.showDestructiveAlert(
owner: photoBrowser,
title: deleteButtonTitle,
message: nil,
titleDestructive: #localize("delete")
) { _ in
let indexSet: Set<UInt> = [index]
self.deleteMedia(for: indexSet) {
self.prepareMedia()
if self.mediaMessages.isEmpty {
self.parentViewController?.dismiss(animated: true)
}
else {
self.photoBrowser?.reloadData(true)
}
}
}
}
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, deleteButton: UIBarButtonItem!) {
if !selectedMediaMessages.isEmpty {
deleteMedia(for: selectedMediaMessages) {
self.handleDeletion()
}
}
else {
let indexes = Set(0..<mediaMessages.count) as! Set<UInt>
deleteMedia(for: indexes) {
self.handleDeletion()
}
}
}
// MARK: Selected
func photoBrowserResetSelection(_ photoBrowser: MWPhotoBrowser!) {
selectedMediaMessages.removeAll()
}
func photoBrowserSelectAll(_ photoBrowser: MWPhotoBrowser!) {
selectedMediaMessages.removeAll()
let setOfSelectedIndexes = Set(0..<mediaMessages.count)
selectedMediaMessages = Set(setOfSelectedIndexes.compactMap {
UInt($0)
})
}
@objc func mediaPhotoSelection() -> Set<AnyHashable>! {
selectedMediaMessages
}
@objc func mediaSelectionCount() -> UInt {
UInt(selectedMediaMessages.count)
}
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, isPhotoSelectedAt index: UInt) -> Bool {
selectedMediaMessages.contains(index)
}
func photoBrowser(_ photoBrowser: MWPhotoBrowser!, photoAt index: UInt, selectedChanged selected: Bool) {
if selected {
selectedMediaMessages.insert(index)
}
else {
selectedMediaMessages.remove(index)
}
}
// MARK: MWVideoDelegate
func play(_ video: MediaBrowserVideo!) {
acquireAudioSessionClaim()
}
// MARK: MWFileDelegate
func showFile(_ fileMessageEntity: FileMessageEntity) {
let controller = UIHostingController(rootView: FileMessagePreviewView(fileMessageEntity: fileMessageEntity))
controller.modalPresentationStyle = .overFullScreen
photoBrowser?.present(controller, animated: true)
}
func playFileVideo(_ fileMessageEntity: FileMessageEntity) {
acquireAudioSessionClaim()
}
// MARK: - Audio session claim
private func acquireAudioSessionClaim() {
guard audioSessionClaim == nil else {
return
}
Task { [weak self] in
let claim = try? await self?.audioSessionManager.acquire(.mediaPlayback)
guard let self, let claim else {
return
}
audioSessionClaim = claim
}
}
func toggleControls() {
photoBrowser?.toggleControls()
}
// MARK: - Helpers
private func createPhotoBrowser() -> MWPhotoBrowser? {
let photoBrowser = MWPhotoBrowser(delegate: self)
photoBrowser?.displayDeleteButton = true
photoBrowser?.zoomPhotosToFill = true
return photoBrowser
}
private func photoAtIndex(at index: Int, thumbnail: Bool) -> MWPhotoProtocol? {
guard index < mediaMessages.count else {
return nil
}
let message: BaseMessageEntity = mediaMessages[index]
switch message {
case let imageMessageEntity as ImageMessageEntity:
let photo = MediaBrowserPhoto(imageMessageEntity: imageMessageEntity, thumbnail: thumbnail)
photo?.caption = DateFormatter.shortStyleDateTime(message.remoteSentDate)
return photo
case let videoMessageEntity as VideoMessageEntity:
let video = MediaBrowserVideo(thumbnail: videoMessageEntity.thumbnail?.uiImage())
video?.delegate = self
video?.sourceReference = videoMessageEntity
video?.caption = DateFormatter.shortStyleDateTime(message.remoteSentDate)
return video
case let fileMessageEntity as FileMessageEntity:
let file = MediaBrowserFile(fileMessageEntity: fileMessageEntity, thumbnail: thumbnail)
file?.delegate = self
file?.caption = DateFormatter.shortStyleDateTime(message.remoteSentDate)
return file
default:
return nil
}
}
private func objectIDAtIndex(at index: Int, thumbnail: Bool) -> NSManagedObjectID? {
guard index < mediaMessages.count else {
return nil
}
let message: BaseMessageEntity = mediaMessages[index]
return message.objectID
}
func openPhotoBrowser(currentMediaIndex: UInt?, showGrid: Bool = true, autoPlayOnAppear: Bool = false) {
photoBrowser = createPhotoBrowser()
guard let photoBrowser else {
DDLogError("Could not create MWPhotoBrowser")
return
}
if currentMediaIndex == nil {
prepareMedia()
}
photoBrowser.autoPlayOnAppear = autoPlayOnAppear
photoBrowser.startOnGrid = showGrid
// Set opening index
if let index = currentMediaIndex {
photoBrowser.setCurrentPhotoIndex(index)
}
else {
photoBrowser.setCurrentPhotoIndex(UInt(mediaMessages.count))
}
// Open photo browser
let navCon = ModalNavigationController(navigationBarClass: StatusNavigationBar.self, toolbarClass: nil)
navCon.setViewControllers([photoBrowser], animated: false)
navCon.modalDelegate = self
if UIDevice.current.userInterfaceIdiom == .pad {
navCon.modalPresentationStyle = .overFullScreen
navCon.showFullScreenOnIPad = true
}
parentViewController?.present(navCon, animated: true)
}
func prepareMedia() {
var finalMessages = [BaseMessageEntity]()
let imageMessages = entityManager.entityFetcher.imageMessages(for: conversation) ?? [ImageMessage]()
let videoMessages = entityManager.entityFetcher.videoMessages(for: conversation) ?? [VideoMessage]()
let gifMimeType = UTIConverter.mimeType(fromUTI: UTType.gif.identifier)
let audioMimeTypes = UTIConverter.renderingAudioMimeTypes()
var filters = audioMimeTypes
if let gifMimeType {
filters.append(gifMimeType)
}
let fileMessages = entityManager.entityFetcher.fileMessagesForPhotoBrowser(
for: conversation,
filteredMimeTypes: filters
) ?? []
finalMessages.append(contentsOf: imageMessages)
finalMessages.append(contentsOf: videoMessages)
finalMessages.append(contentsOf: fileMessages)
mediaMessages = finalMessages.sorted { $0.date ?? .now < $1.date ?? .now }
}
private func deleteMedia(for indexes: Set<UInt>, completion: @escaping () -> Void) {
let mediaEntitiesToDelete = indexes.map { index in
self.mediaMessages[Int(index)]
}
delegate?.willDeleteMessages(with: mediaEntitiesToDelete.map(\.objectID))
entityManager.performAndWaitSave {
for mediaEntity in mediaEntitiesToDelete {
self.entityManager.entityDestroyer.delete(baseMessage: mediaEntity)
}
completion()
}
}
private func handleDeletion() {
prepareMedia()
if mediaMessages.isEmpty {
parentViewController?.dismiss(animated: true)
}
else {
photoBrowser?.finishedDeleteMedia()
}
}
// MARK: - Notifications
@objc func colorThemeChanged(notification: NSNotification) {
photoBrowser?.reloadData(true)
}
// MARK: - ModalNavigationControllerDelegate
func didDismissModalNavigationController() {
guard parentViewController?.navigationController?.presentedViewController != nil else {
photoBrowser = nil
return
}
}
}
¤ Dauer der Verarbeitung: 0.5 Sekunden
¤
*© Formatika GbR, Deutschland