diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 4c49cfd5..a0fd9a39 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -63,6 +63,11 @@ struct IceCubesApp: App { .environment(userPreferences) .environment(theme) } + .fullScreenCover(item: $quickLook.url, content: { url in + QuickLookPreview(selectedURL: url, urls: quickLook.urls) + .edgesIgnoringSafeArea(.bottom) + .background(TransparentBackground()) + }) .onChange(of: pushNotificationsService.handledNotification) { _, newValue in if newValue != nil { pushNotificationsService.handledNotification = nil diff --git a/Packages/Env/Sources/Env/QuickLook.swift b/Packages/Env/Sources/Env/QuickLook.swift index 2ad69f1b..6c7132eb 100644 --- a/Packages/Env/Sources/Env/QuickLook.swift +++ b/Packages/Env/Sources/Env/QuickLook.swift @@ -1,16 +1,95 @@ import Combine -import SwiftUI +@preconcurrency import SwiftUI import Models +import QuickLook @MainActor @Observable public class QuickLook { public var selectedMediaAttachment: MediaAttachment? public var mediaAttachments: [MediaAttachment] = [] + public var url: URL? { + didSet { + if url == nil { + cleanup(urls: urls) + } + } + } + public private(set) var urls: [URL] = [] + + public init() {} - public func prepareFor(selectedMediaAttachment: MediaAttachment?, mediaAttachments: [MediaAttachment]) { - self.selectedMediaAttachment = selectedMediaAttachment - self.mediaAttachments = mediaAttachments + public func prepareFor(selectedMediaAttachment: MediaAttachment, mediaAttachments: [MediaAttachment]) { + if ProcessInfo.processInfo.isiOSAppOnMac, let selectedURL = selectedMediaAttachment.url { + let urls = mediaAttachments.compactMap{ $0.url } + Task { + await prepareFor(urls: urls, selectedURL: selectedURL) + } + } else { + self.selectedMediaAttachment = selectedMediaAttachment + self.mediaAttachments = mediaAttachments + } + } + + private func prepareFor(urls: [URL], selectedURL: URL) async { + var transaction = Transaction(animation: .default) + transaction.disablesAnimations = true + do { + var order = 0 + let pathOrderMap = urls.reduce(into: [String: Int]()) { result, url in + result[url.lastPathComponent] = order + order += 1 + } + let paths: [URL] = try await withThrowingTaskGroup(of: URL.self, body: { group in + var paths: [URL] = [] + for url in urls { + group.addTask { + try await self.localPathFor(url: url) + } + } + for try await path in group { + paths.append(path) + } + return paths.sorted { url1, url2 in + pathOrderMap[url1.lastPathComponent] ?? 0 < pathOrderMap[url2.lastPathComponent] ?? 0 + } + }) + withTransaction(transaction) { + self.urls = paths + url = paths.first(where: { $0.lastPathComponent == selectedURL.lastPathComponent }) + } + } catch { + withTransaction(transaction) { + self.urls = [] + url = nil + } + } + } + + private var quickLookDir: URL { + try! FileManager.default.url(for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false) + .appending(component: "quicklook") + } + + private func localPathFor(url: URL) async throws -> URL { + try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true) + let path = quickLookDir.appendingPathComponent(url.lastPathComponent) + + // Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated + // context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor + // boundary. + // This is on the defaulted-to-nil second parameter of `.data(from:delegate:)`. + // There is a Radar tracking this & others like it. + let data = try await URLSession.shared.data(from: url).0 + try data.write(to: path) + return path + } + + private func cleanup(urls _: [URL]) { + try? FileManager.default.removeItem(at: quickLookDir) } } diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentVideoView.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentVideoView.swift index 550979b9..cd0472bd 100644 --- a/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentVideoView.swift +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentVideoView.swift @@ -17,7 +17,7 @@ import SwiftUI func preparePlayer(autoPlay: Bool) { player = .init(url: url) - player?.isMuted = true + player?.isMuted = !forceAutoPlay player?.audiovisualBackgroundPlaybackPolicy = .pauses if autoPlay || forceAutoPlay { player?.play() diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUITransferableImage.swift b/Packages/MediaUI/Sources/MediaUI/MediaUITransferableImage.swift new file mode 100644 index 00000000..cc7ba426 --- /dev/null +++ b/Packages/MediaUI/Sources/MediaUI/MediaUITransferableImage.swift @@ -0,0 +1,21 @@ +import SwiftUI +import UIKit +import CoreTransferable + +struct MediaUIImageTransferable: Codable, Transferable { + let url: URL + + func fetchAsImage() async -> Image { + let data = try? await URLSession.shared.data(from: url).0 + guard let data, let uiimage = UIImage(data: data) else { + return Image(systemName: "photo") + } + return Image(uiImage: uiimage) + } + + static var transferRepresentation: some TransferRepresentation { + ProxyRepresentation { media in + await media.fetchAsImage() + } + } +} diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift index 6baf998f..cb2ef98d 100644 --- a/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift @@ -5,7 +5,7 @@ import SwiftUI import Models import QuickLook -public struct MediaUIView: View { +public struct MediaUIView: View, @unchecked Sendable { @Environment(\.dismiss) private var dismiss public let selectedAttachment: MediaAttachment @@ -51,69 +51,7 @@ public struct MediaUIView: View { .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $scrollToId) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle") - } - } - ToolbarItem(placement: .topBarTrailing) { - if let url = attachments.first(where: { $0.id == scrollToId})?.url { - Button { - Task { - quickLookURL = try? await localPathFor(url: url) - } - } label: { - Image(systemName: "info.circle") - } - } - } - ToolbarItem(placement: .topBarTrailing) { - if let alt = attachments.first(where: { $0.id == scrollToId})?.description { - Button { - altTextDisplayed = alt - isAltAlertDisplayed = true - } label: { - Text("status.image.alt-text.abbreviation") - } - } - } - ToolbarItem(placement: .topBarTrailing) { - if let attachment = attachments.first(where: { $0.id == scrollToId}), - let url = attachment.url, - attachment.supportedType == .image { - Button { - Task { - isSavingPhoto = true - if await saveImage(url: url) { - withAnimation { - isSavingPhoto = false - didSavePhoto = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - didSavePhoto = false - } - } - } else { - isSavingPhoto = false - } - } - } label: { - if isSavingPhoto { - ProgressView() - } else if didSavePhoto { - Image(systemName: "checkmark.circle.fill") - } else { - Image(systemName: "arrow.down.circle") - } - } - } - } - ToolbarItem(placement: .topBarTrailing) { - if let url = attachments.first(where: { $0.id == scrollToId})?.url { - ShareLink(item: url) - } - } + toolbarView } .alert("status.editor.media.image-description", isPresented: $isAltAlertDisplayed) @@ -131,6 +69,80 @@ public struct MediaUIView: View { } } + @ToolbarContentBuilder + private var toolbarView: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle") + } + } + ToolbarItem(placement: .topBarTrailing) { + if let url = attachments.first(where: { $0.id == scrollToId})?.url { + Button { + Task { + quickLookURL = await localPathFor(url: url) + } + } label: { + Image(systemName: "info.circle") + } + } + } + ToolbarItem(placement: .topBarTrailing) { + if let alt = attachments.first(where: { $0.id == scrollToId})?.description { + Button { + altTextDisplayed = alt + isAltAlertDisplayed = true + } label: { + Text("status.image.alt-text.abbreviation") + } + } + } + ToolbarItem(placement: .topBarTrailing) { + if let attachment = attachments.first(where: { $0.id == scrollToId}), + let url = attachment.url, + attachment.supportedType == .image { + Button { + Task { + isSavingPhoto = true + if await saveImage(url: url) { + withAnimation { + isSavingPhoto = false + didSavePhoto = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + didSavePhoto = false + } + } + } else { + isSavingPhoto = false + } + } + } label: { + if isSavingPhoto { + ProgressView() + } else if didSavePhoto { + Image(systemName: "checkmark.circle.fill") + } else { + Image(systemName: "arrow.down.circle") + } + } + } + } + ToolbarItem(placement: .topBarTrailing) { + if let attachment = attachments.first(where: { $0.id == scrollToId}), + let url = attachment.url { + switch attachment.supportedType { + case .image: + let transferable = MediaUIImageTransferable(url: url) + ShareLink(item: transferable, preview: .init("status.media.contextmenu.share", + image: transferable)) + default: + ShareLink(item: url) + } + } + } + } private var quickLookDir: URL { try! FileManager.default.url(for: .cachesDirectory, @@ -140,23 +152,25 @@ public struct MediaUIView: View { .appending(component: "quicklook") } - private func localPathFor(url: URL) async throws -> URL { + private func imageData(_ url: URL) async -> Data? { + var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url)) + if data == nil { + data = try? await URLSession.shared.data(from: url).0 + } + return data + } + + private func localPathFor(url: URL) async -> URL { try? FileManager.default.removeItem(at: quickLookDir) try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true) let path = quickLookDir.appendingPathComponent(url.lastPathComponent) - var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url)) - if data == nil { - data = try await URLSession.shared.data(from: url).0 - } - try data?.write(to: path) + let data = await imageData(url) + try? data?.write(to: path) return path } private func uiimageFor(url: URL) async throws -> UIImage? { - var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url)) - if data == nil { - data = try await URLSession.shared.data(from: url).0 - } + let data = await imageData(url) if let data { return UIImage(data: data) }