diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index d3bfb6ab..285d0cff 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; }; 9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD34822293D06E800DB0EE9 /* Assets.xcassets */; }; 9FD542E72962D2FF0045321A /* Lists in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD542E62962D2FF0045321A /* Lists */; }; + 9FE0346C2ADD5C2100529EA8 /* MediaUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE0346B2ADD5C2100529EA8 /* MediaUI */; }; 9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; }; 9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE3DB56296FEFCA00628CB0 /* AppAccount */; }; 9FFF677C299B7B2C00FE700A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9FFF677B299B7B2C00FE700A /* Notifications */; }; @@ -222,6 +223,8 @@ 9FCBB3D429859615009B77EE /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = ""; }; 9FD34822293D06E800DB0EE9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9FD542E52962D2CE0045321A /* Lists */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Lists; path = Packages/Lists; sourceTree = ""; }; + 9FE034692ADD597100529EA8 /* MediaUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaUI; path = Packages/MediaUI; sourceTree = ""; }; + 9FE0346A2ADD59AC00529EA8 /* MediaUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaUI; path = Packages/MediaUI; sourceTree = ""; }; 9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = ""; }; 9FE3DB55296FEF5800628CB0 /* AppAccount */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AppAccount; path = Packages/AppAccount; sourceTree = ""; }; B0BAB49E29B3D7A9008F54D7 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -287,6 +290,7 @@ 9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */, 9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */, 9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */, + 9FE0346C2ADD5C2100529EA8 /* MediaUI in Frameworks */, 9F398AA92935FFDB00A889F2 /* Account in Frameworks */, 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */, 9FD542E72962D2FF0045321A /* Lists in Frameworks */, @@ -434,6 +438,7 @@ 9F55C68E295598F900F94077 /* Explore */, 9F5E581729545B5500A53960 /* Env */, 9F398AA32935F90100A889F2 /* Models */, + 9FE0346A2ADD59AC00529EA8 /* MediaUI */, 9F29553D292B67B600E0E81B /* Network */, 9FD542E52962D2CE0045321A /* Lists */, 9F35DB4829506F7F00B3281A /* Notifications */, @@ -470,6 +475,7 @@ 9FBFE64C292A72BD00C250E9 /* Frameworks */ = { isa = PBXGroup; children = ( + 9FE034692ADD597100529EA8 /* MediaUI */, 9F2A540D2969A0B0009B2D7C /* StoreKit.framework */, 9F2A5404296995FB009B2D7C /* QuickLookUI.framework */, 9F7335EE29674F7100AFF0BA /* QuickLook.framework */, @@ -612,6 +618,7 @@ 9FE3DB56296FEFCA00628CB0 /* AppAccount */, 065FA1FD29866CD600012EA0 /* LRUCache */, DA0B24FA2A6876D50045BDD7 /* SFSafeSymbols */, + 9FE0346B2ADD5C2100529EA8 /* MediaUI */, ); productName = IceCubesApp; productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */; @@ -1481,6 +1488,10 @@ isa = XCSwiftPackageProductDependency; productName = Lists; }; + 9FE0346B2ADD5C2100529EA8 /* MediaUI */ = { + isa = XCSwiftPackageProductDependency; + productName = MediaUI; + }; 9FE3DB56296FEFCA00628CB0 /* AppAccount */ = { isa = XCSwiftPackageProductDependency; productName = AppAccount; diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 9c1798ea..4c49cfd5 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -9,6 +9,7 @@ import RevenueCat import Status import SwiftUI import Timeline +import MediaUI @main struct IceCubesApp: App { @@ -54,11 +55,14 @@ struct IceCubesApp: App { .environment(watcher) .environment(pushNotificationsService) .environment(\.isSupporter, isSupporter) - .fullScreenCover(item: $quickLook.url, content: { url in - QuickLookPreview(selectedURL: url, urls: quickLook.urls) - .edgesIgnoringSafeArea(.bottom) - .background(TransparentBackground()) - }) + .sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in + MediaUIView(selectedAttachment: selectedMediaAttachment, + attachments: quickLook.mediaAttachments) + .presentationBackground(.ultraThinMaterial) + .presentationCornerRadius(16) + .environment(userPreferences) + .environment(theme) + } .onChange(of: pushNotificationsService.handledNotification) { _, newValue in if newValue != nil { pushNotificationsService.handledNotification = nil @@ -265,8 +269,6 @@ struct IceCubesApp: App { } class AppDelegate: NSObject, UIApplicationDelegate { - let themeObserver = ThemeObserverViewController(nibName: nil, bundle: nil) - func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { @@ -300,11 +302,3 @@ class AppDelegate: NSObject, UIApplicationDelegate { return configuration } } - -class ThemeObserverViewController: UIViewController { - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - print(traitCollection.userInterfaceStyle.rawValue) - } -} diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift index 0cde2f16..f21960e6 100644 --- a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -79,9 +79,8 @@ struct AccountDetailHeaderView: View { guard account.haveHeader else { return } - Task { - await quickLook.prepareFor(urls: [account.header], selectedURL: account.header) - } + let attachement = MediaAttachment.imageWith(url: account.header) + quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement]) } .accessibilityElement(children: .combine) .accessibilityAddTraits([.isImage, .isButton]) @@ -110,9 +109,8 @@ struct AccountDetailHeaderView: View { guard account.haveAvatar else { return } - Task { - await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar) - } + let attachement = MediaAttachment.imageWith(url: account.avatar) + quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement]) } .accessibilityElement(children: .combine) .accessibilityAddTraits([.isImage, .isButton]) diff --git a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift index b55329da..c6b73cbc 100644 --- a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift +++ b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift @@ -48,19 +48,12 @@ public struct AppAccountsSelectorView: View { labelView } .sheet(isPresented: $isPresented, content: { - if #available(iOS 16.4, *) { - accountsView.presentationDetents([.height(preferredHeight), .large]) - .presentationBackground(.thinMaterial) - .presentationCornerRadius(16) - .onAppear { - refreshAccounts() - } - } else { - accountsView.presentationDetents([.height(preferredHeight), .large]) - .onAppear { - refreshAccounts() - } - } + accountsView.presentationDetents([.height(preferredHeight), .large]) + .presentationBackground(.thinMaterial) + .presentationCornerRadius(16) + .onAppear { + refreshAccounts() + } }) .onChange(of: currentAccount.account?.id) { refreshAccounts() diff --git a/Packages/Conversations/Sources/Conversations/Detail/ConversationMessageView.swift b/Packages/Conversations/Sources/Conversations/Detail/ConversationMessageView.swift index d79709bb..8be8fd48 100644 --- a/Packages/Conversations/Sources/Conversations/Detail/ConversationMessageView.swift +++ b/Packages/Conversations/Sources/Conversations/Detail/ConversationMessageView.swift @@ -200,11 +200,7 @@ struct ConversationMessageView: View { .frame(height: 200) .contentShape(Rectangle()) .onTapGesture { - if let url = attachement.url { - Task { - await quickLook.prepareFor(urls: [url], selectedURL: url) - } - } + quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement]) } } diff --git a/Packages/Env/Sources/Env/QuickLook.swift b/Packages/Env/Sources/Env/QuickLook.swift index ec5a90ac..2ad69f1b 100644 --- a/Packages/Env/Sources/Env/QuickLook.swift +++ b/Packages/Env/Sources/Env/QuickLook.swift @@ -1,87 +1,16 @@ import Combine -@preconcurrency import QuickLook import SwiftUI +import Models @MainActor @Observable public class QuickLook { - public var url: URL? { - didSet { - if url == nil { - cleanup(urls: urls) - } - } - } - - public private(set) var urls: [URL] = [] - public private(set) var isPreparing: Bool = false - public private(set) var latestError: Error? - + public var selectedMediaAttachment: MediaAttachment? + public var mediaAttachments: [MediaAttachment] = [] + public init() {} - - public func prepareFor(urls: [URL], selectedURL: URL) async { - var transaction = Transaction(animation: .default) - transaction.disablesAnimations = true - withTransaction(transaction) { - isPreparing = 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 }) - isPreparing = false - } - } catch { - withTransaction(transaction) { - isPreparing = false - self.urls = [] - url = nil - latestError = error - } - } - } - - 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) + + public func prepareFor(selectedMediaAttachment: MediaAttachment?, mediaAttachments: [MediaAttachment]) { + self.selectedMediaAttachment = selectedMediaAttachment + self.mediaAttachments = mediaAttachments } } diff --git a/Packages/MediaUI/.gitignore b/Packages/MediaUI/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/Packages/MediaUI/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/MediaUI/Package.swift b/Packages/MediaUI/Package.swift new file mode 100644 index 00000000..6b3dc0ef --- /dev/null +++ b/Packages/MediaUI/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MediaUI", + defaultLocalization: "en", + platforms: [ + .iOS(.v17), + ], + products: [ + .library( + name: "MediaUI", + targets: ["MediaUI"] + ), + ], + dependencies: [ + .package(name: "Models", path: "../Models"), + ], + targets: [ + .target( + name: "MediaUI", + dependencies: [ + .product(name: "Models", package: "Models"), + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + ] +) diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentImageView.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentImageView.swift new file mode 100644 index 00000000..b3f54a38 --- /dev/null +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentImageView.swift @@ -0,0 +1,27 @@ +import SwiftUI +import Models +import NukeUI + +struct MediaUIAttachmentImageView: View { + let url: URL + + @GestureState private var zoom = 1.0 + + var body: some View { + MediaUIZoomableContainer { + LazyImage(url: url) { state in + if let image = state.image { + image + .resizable() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .scaledToFit() + .padding(.horizontal, 8) + .scaleEffect(zoom) + } else if state.isLoading { + ProgressView() + .progressViewStyle(.circular) + } + } + } + } +} diff --git a/Packages/Status/Sources/Status/Media/VideoPlayerView.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentVideoView.swift similarity index 85% rename from Packages/Status/Sources/Status/Media/VideoPlayerView.swift rename to Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentVideoView.swift index 41e63f60..772b2dd0 100644 --- a/Packages/Status/Sources/Status/Media/VideoPlayerView.swift +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentVideoView.swift @@ -5,11 +5,11 @@ import Observation import SwiftUI @MainActor -@Observable class VideoPlayerViewModel { +@Observable public class MediaUIAttachmentVideoViewModel { var player: AVPlayer? private let url: URL - init(url: URL) { + public init(url: URL) { self.url = url } @@ -48,15 +48,19 @@ import SwiftUI } } -struct VideoPlayerView: View { +public struct MediaUIAttachmentVideoView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.isCompact) private var isCompact @Environment(UserPreferences.self) private var preferences @Environment(Theme.self) private var theme - @State var viewModel: VideoPlayerViewModel + @State var viewModel: MediaUIAttachmentVideoViewModel + + public init(viewModel: MediaUIAttachmentVideoViewModel) { + _viewModel = .init(wrappedValue: viewModel) + } - var body: some View { + public var body: some View { ZStack { VideoPlayer(player: viewModel.player) .accessibilityAddTraits(.startsMediaSession) diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift new file mode 100644 index 00000000..6513ba63 --- /dev/null +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift @@ -0,0 +1,121 @@ +import Foundation +import NukeUI +import Nuke +import SwiftUI +import Models +import QuickLook + +public struct MediaUIView: View { + @Environment(\.dismiss) private var dismiss + + public let selectedAttachment: MediaAttachment + public let attachments: [MediaAttachment] + + @State private var scrollToId: String? + @State private var altTextDisplayed: String? + @State private var isAltAlertDisplayed: Bool = false + @State private var quickLookURL: URL? + + public init(selectedAttachment: MediaAttachment, attachments: [MediaAttachment]) { + self.selectedAttachment = selectedAttachment + self.attachments = attachments + } + + public var body: some View { + NavigationStack { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(attachments) { attachment in + if let url = attachment.url { + switch attachment.supportedType { + case .image: + MediaUIAttachmentImageView(url: url) + .containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0) + .id(attachment.id) + case .video, .gifv, .audio: + MediaUIAttachmentVideoView(viewModel: .init(url: url)) + .containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0) + .containerRelativeFrame(.vertical, count: 1, span: 1, spacing: 0) + .id(attachment.id) + case .none: + EmptyView() + } + } + } + } + .scrollTargetLayout() + } + .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 url = attachments.first(where: { $0.id == scrollToId})?.url { + ShareLink(item: url) + } + } + } + .alert("status.editor.media.image-description", + isPresented: $isAltAlertDisplayed) + { + Button("alert.button.ok", action: {}) + } message: { + Text(altTextDisplayed ?? "") + } + .quickLookPreview($quickLookURL) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + scrollToId = selectedAttachment.id + } + } + } + } + + + 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.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) + return path + } +} diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUIZoomableContainer.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIZoomableContainer.swift new file mode 100644 index 00000000..66cabb5a --- /dev/null +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIZoomableContainer.swift @@ -0,0 +1,107 @@ +import SwiftUI +import UIKit + +// ref: https://stackoverflow.com/questions/74238414/is-there-an-easy-way-to-pinch-to-zoom-and-drag-any-view-in-swiftui + +fileprivate let maxAllowedScale = 4.0 + +@MainActor +struct MediaUIZoomableContainer: View { + let content: Content + @State private var currentScale: CGFloat = 1.0 + @State private var tapLocation: CGPoint = .zero + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + func doubleTapAction(location: CGPoint) { + tapLocation = location + currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0 + } + + var body: some View { + ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) { + content + } + .onTapGesture(count: 2, perform: doubleTapAction) + } + + fileprivate struct ZoomableScrollView: UIViewRepresentable { + private var content: ScollContent + @Binding private var currentScale: CGFloat + @Binding private var tapLocation: CGPoint + + init(scale: Binding, tapLocation: Binding, @ViewBuilder content: () -> ScollContent) { + _currentScale = scale + _tapLocation = tapLocation + self.content = content() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.backgroundColor = .clear + scrollView.delegate = context.coordinator + scrollView.maximumZoomScale = maxAllowedScale + scrollView.minimumZoomScale = 1 + scrollView.bouncesZoom = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.clipsToBounds = false + scrollView.backgroundColor = .clear + + let hostedView = context.coordinator.hostingController.view! + hostedView.translatesAutoresizingMaskIntoConstraints = true + hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + hostedView.frame = scrollView.bounds + hostedView.backgroundColor = .clear + scrollView.addSubview(hostedView) + + return scrollView + } + + func makeCoordinator() -> Coordinator { + return Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale) + } + + func updateUIView(_ uiView: UIScrollView, context: Context) { + context.coordinator.hostingController.rootView = content + + if uiView.zoomScale > uiView.minimumZoomScale { // Scale out + uiView.setZoomScale(currentScale, animated: true) + } else if tapLocation != .zero { // Scale in to a specific point + uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true) + DispatchQueue.main.async { tapLocation = .zero } + } + } + + @MainActor func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect { + let scrollViewSize = scrollView.bounds.size + + let width = scrollViewSize.width / scale + let height = scrollViewSize.height / scale + let x = center.x - (width / 2.0) + let y = center.y - (height / 2.0) + + return CGRect(x: x, y: y, width: width, height: height) + } + + class Coordinator: NSObject, UIScrollViewDelegate { + var hostingController: UIHostingController + @Binding var currentScale: CGFloat + + init(hostingController: UIHostingController, scale: Binding) { + self.hostingController = hostingController + _currentScale = scale + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return hostingController.view + } + + func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + currentScale = scale + } + } + } +} diff --git a/Packages/Models/Sources/Models/MediaAttachement.swift b/Packages/Models/Sources/Models/MediaAttachement.swift index 322245b6..39986a5c 100644 --- a/Packages/Models/Sources/Models/MediaAttachement.swift +++ b/Packages/Models/Sources/Models/MediaAttachement.swift @@ -44,6 +44,15 @@ public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable { public let previewUrl: URL? public let description: String? public let meta: MetaContainer? + + public static func imageWith(url: URL) -> MediaAttachment{ + return .init(id: UUID().uuidString, + type: "image", + url: url, + previewUrl: url, + description: nil, + meta: nil) + } } extension MediaAttachment: Sendable {} diff --git a/Packages/Status/Package.swift b/Packages/Status/Package.swift index bfe05744..fa572d43 100644 --- a/Packages/Status/Package.swift +++ b/Packages/Status/Package.swift @@ -18,6 +18,7 @@ let package = Package( dependencies: [ .package(name: "AppAccount", path: "../AppAccount"), .package(name: "Models", path: "../Models"), + .package(name: "MediaUI", path: "../MediaUI"), .package(name: "Network", path: "../Network"), .package(name: "Env", path: "../Env"), .package(name: "DesignSystem", path: "../DesignSystem"), @@ -28,6 +29,7 @@ let package = Package( dependencies: [ .product(name: "AppAccount", package: "AppAccount"), .product(name: "Models", package: "Models"), + .product(name: "MediaUI", package: "MediaUI"), .product(name: "Network", package: "Network"), .product(name: "Env", package: "Env"), .product(name: "DesignSystem", package: "DesignSystem"), diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index 37efc7db..5f5e1a5c 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -207,10 +207,8 @@ public struct StatusRowView: View { if viewModel.finalStatus.mediaAttachments.isEmpty == false { Button("accessibility.status.media-viewer-action.label") { HapticManager.shared.fireHaptic(of: .notification(.success)) - Task { - let attachments = viewModel.finalStatus.mediaAttachments - await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachments[0].url!) - } + let attachments = viewModel.finalStatus.mediaAttachments + quickLook.prepareFor(selectedMediaAttachment: attachments[0], mediaAttachments: attachments) } } diff --git a/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift b/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift index fdc3f236..7d398727 100644 --- a/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift +++ b/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift @@ -4,6 +4,7 @@ import Models import Nuke import NukeUI import SwiftUI +import MediaUI @MainActor public struct StatusRowMediaPreviewView: View { @@ -87,9 +88,7 @@ public struct StatusRowMediaPreviewView: View { if attachments.count == 1, let attachment = attachments.first { makeFeaturedImagePreview(attachment: attachment) .onTapGesture { - Task { - await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachment.url!) - } + quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments) } .accessibilityElement(children: .ignore) .accessibilityLabel(Self.accessibilityLabel(for: attachment)) @@ -117,11 +116,6 @@ public struct StatusRowMediaPreviewView: View { } } .overlay { - if quickLook.isPreparing { - quickLookLoadingView - .transition(.opacity) - } - if isHidingMedia { sensitiveMediaOverlay .transition(.opacity) @@ -182,7 +176,7 @@ public struct StatusRowMediaPreviewView: View { case .gifv, .video, .audio: if let url = attachment.url { - VideoPlayerView(viewModel: .init(url: url)) + MediaUIAttachmentVideoView(viewModel: .init(url: url)) .frame(width: newSize.width, height: newSize.height) } case .none: @@ -265,7 +259,7 @@ public struct StatusRowMediaPreviewView: View { } case .gifv, .video, .audio: if let url = attachment.url { - VideoPlayerView(viewModel: .init(url: url)) + MediaUIAttachmentVideoView(viewModel: .init(url: url)) .frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width) .frame(height: imageMaxHeight) .accessibilityAddTraits(.startsMediaSession) @@ -278,9 +272,7 @@ public struct StatusRowMediaPreviewView: View { // #965: do not create overlapping tappable areas, when multiple images are shown .contentShape(Rectangle()) .onTapGesture { - Task { - await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachment.url!) - } + quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments) } .accessibilityElement(children: .ignore) .accessibilityLabel(Self.accessibilityLabel(for: attachment)) @@ -288,21 +280,6 @@ public struct StatusRowMediaPreviewView: View { } } - private var quickLookLoadingView: some View { - ZStack(alignment: .center) { - VStack { - Spacer() - HStack { - Spacer() - ProgressView() - Spacer() - } - Spacer() - } - } - .background(.ultraThinMaterial) - } - private var sensitiveMediaOverlay: some View { ZStack { Rectangle()