diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 2ff02b4..ac2668e 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; }; 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; }; 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; }; - 6FB105602E26DD0A00185A5C /* Giffy in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB1055F2E26DD0A00185A5C /* Giffy */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -35,7 +34,6 @@ files = ( 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */, 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */, - 6FB105602E26DD0A00185A5C /* Giffy in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -82,7 +80,6 @@ 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, 6F09BA322E00808300CDE89C /* Dynamic */, 6FB105542E26D3EC00185A5C /* CachedAsyncImage */, - 6FB1055F2E26DD0A00185A5C /* Giffy */, ); productName = "Pocket Poster"; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; @@ -116,7 +113,6 @@ 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */, 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, - 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; @@ -276,7 +272,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -308,7 +304,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -382,14 +378,6 @@ minimumVersion = 2.1.1; }; }; - 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tfmart/Giffy.git"; - requirement = { - branch = main; - kind = branch; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -408,11 +396,6 @@ package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; productName = CachedAsyncImage; }; - 6FB1055F2E26DD0A00185A5C /* Giffy */ = { - isa = XCSwiftPackageProductDependency; - package = 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */; - productName = Giffy; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6F09B7952DEB694B00CDE89C /* Project object */; diff --git a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 450dfbe..d3b1c82 100644 --- a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9d3a39dff9bda7be6921bb553210388e3a68059324656c0ace5f2eb2378819b1", + "originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb", "pins" : [ { "identity" : "dynamic", @@ -10,24 +10,6 @@ "version" : "1.2.0" } }, - { - "identity" : "flanimatedimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Flipboard/FLAnimatedImage.git", - "state" : { - "revision" : "d4f07b6f164d53c1212c3e54d6460738b1981e9f", - "version" : "1.0.17" - } - }, - { - "identity" : "giffy", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tfmart/Giffy.git", - "state" : { - "branch" : "main", - "revision" : "595bd0a0bc292d32da4b26b8bca4347235ab2556" - } - }, { "identity" : "swiftui-cached-async-image", "kind" : "remoteSourceControl", diff --git a/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist b/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist index f3372ed..d7a896f 100644 --- a/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ Pocket Poster.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/Pocket Poster/Extensions/CachedAsyncGiffy.swift b/Pocket Poster/Extensions/CachedAsyncGiffy.swift deleted file mode 100644 index 85ceb48..0000000 --- a/Pocket Poster/Extensions/CachedAsyncGiffy.swift +++ /dev/null @@ -1,408 +0,0 @@ -// -// CachedAsyncGiffy.swift -// Pocket Poster -// -// Created by lemin on 7/18/25. -// - - -// -// Created by Lorenzo Fiamingo on 04/11/20. -// - -import SwiftUI -import Giffy - -/// A view that asynchronously loads, cache and displays an image. -/// -/// This view uses a custom default -/// -/// instance to load an image from the specified URL, and then display it. -/// For example, you can display an icon that's stored on a server: -/// -/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) -/// .frame(width: 200, height: 200) -/// -/// Until the image loads, the view displays a standard placeholder that -/// fills the available space. After the load completes successfully, the view -/// updates to display the image. In the example above, the icon is smaller -/// than the frame, and so appears smaller than the placeholder. -/// -/// ![A diagram that shows a grey box on the left, the SwiftUI icon on the -/// right, and an arrow pointing from the first to the second. The icon -/// is about half the size of the grey box.](AsyncImage-1) -/// -/// You can specify a custom placeholder using -/// ``init(url:urlCache:scale:content:placeholder:)``. With this initializer, you can -/// also use the `content` parameter to manipulate the loaded image. -/// For example, you can add a modifier to make the loaded image resizable: -/// -/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { image in -/// image.resizable() -/// } placeholder: { -/// ProgressView() -/// } -/// .frame(width: 50, height: 50) -/// -/// For this example, SwiftUI shows a ``ProgressView`` first, and then the -/// image scaled to fit in the specified frame: -/// -/// ![A diagram that shows a progress view on the left, the SwiftUI icon on the -/// right, and an arrow pointing from the first to the second.](AsyncImage-2) -/// -/// > Important: You can't apply image-specific modifiers, like -/// ``Image/resizable(capInsets:resizingMode:)``, directly to a `CachedAsyncImage`. -/// Instead, apply them to the ``Image`` instance that your `content` -/// closure gets when defining the view's appearance. -/// -/// To gain more control over the loading process, use the -/// ``init(url:urlCache:scale:transaction:content:)`` initializer, which takes a -/// `content` closure that receives an ``AsyncImagePhase`` to indicate -/// the state of the loading operation. Return a view that's appropriate -/// for the current phase: -/// -/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { phase in -/// if let image = phase.image { -/// image // Displays the loaded image. -/// } else if phase.error != nil { -/// Color.red // Indicates an error. -/// } else { -/// Color.blue // Acts as a placeholder. -/// } -/// } -/// -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct CachedAsyncGiffy: View where Content: View { - - @State private var phase: AsyncGiffyPhase - - private let urlRequest: URLRequest? - - private let urlSession: URLSession - - private let scale: CGFloat - - private let transaction: Transaction - - private let content: (AsyncGiffyPhase) -> Content - - public var body: some View { - content(phase) - .task(id: urlRequest, load) - } - - /// Loads and displays an image from the specified URL. - /// - /// Until the image loads, SwiftUI displays a default placeholder. When - /// the load operation completes successfully, SwiftUI updates the - /// view to show the loaded image. If the operation fails, SwiftUI - /// continues to display the placeholder. The following example loads - /// and displays an icon from an example server: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) - /// - /// If you want to customize the placeholder or apply image-specific - /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- - /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` - /// initializer instead. - /// - /// - Parameters: - /// - url: The URL of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { - let urlRequest = url == nil ? nil : URLRequest(url: url!) - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) - } - - /// Loads and displays an image from the specified URL. - /// - /// Until the image loads, SwiftUI displays a default placeholder. When - /// the load operation completes successfully, SwiftUI updates the - /// view to show the loaded image. If the operation fails, SwiftUI - /// continues to display the placeholder. The following example loads - /// and displays an icon from an example server: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) - /// - /// If you want to customize the placeholder or apply image-specific - /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- - /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` - /// initializer instead. - /// - /// - Parameters: - /// - urlRequest: The URL request of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in -#if os(macOS) - phase.image ?? Image(nsImage: .init()) -#else - switch phase { - case .success(let giffy): - return giffy - default: - return Giffy("") - } -#endif - } - } - - /// Loads and displays a modifiable image from the specified URL using - /// a custom placeholder until the image loads. - /// - /// Until the image loads, SwiftUI displays the placeholder view that - /// you specify. When the load operation completes successfully, SwiftUI - /// updates the view to show content that you specify, which you - /// create using the loaded image. For example, you can show a green - /// placeholder, followed by a tiled version of the loaded image: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in - /// image.resizable(resizingMode: .tile) - /// } placeholder: { - /// Color.green - /// } - /// - /// If the load operation fails, SwiftUI continues to display the - /// placeholder. To be able to display a different view on a load error, - /// use the ``init(url:scale:transaction:content:)`` initializer instead. - /// - /// - Parameters: - /// - url: The URL of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - content: A closure that takes the loaded image as an input, and - /// returns the view to show. You can return the image directly, or - /// modify it as needed before returning it. - /// - placeholder: A closure that returns the view to show until the - /// load operation completes successfully. - public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { - let urlRequest = url == nil ? nil : URLRequest(url: url!) - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, content: content, placeholder: placeholder) - } - - /// Loads and displays a modifiable image from the specified URL using - /// a custom placeholder until the image loads. - /// - /// Until the image loads, SwiftUI displays the placeholder view that - /// you specify. When the load operation completes successfully, SwiftUI - /// updates the view to show content that you specify, which you - /// create using the loaded image. For example, you can show a green - /// placeholder, followed by a tiled version of the loaded image: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in - /// image.resizable(resizingMode: .tile) - /// } placeholder: { - /// Color.green - /// } - /// - /// If the load operation fails, SwiftUI continues to display the - /// placeholder. To be able to display a different view on a load error, - /// use the ``init(url:scale:transaction:content:)`` initializer instead. - /// - /// - Parameters: - /// - urlRequest: The URL request of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - content: A closure that takes the loaded image as an input, and - /// returns the view to show. You can return the image directly, or - /// modify it as needed before returning it. - /// - placeholder: A closure that returns the view to show until the - /// load operation completes successfully. - public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in - switch phase { - case .success(let image): - content(image) - default: - placeholder() - } - } - } - - /// Loads and displays a modifiable image from the specified URL in phases. - /// - /// If you set the asynchronous image's URL to `nil`, or after you set the - /// URL to a value but before the load operation completes, the phase is - /// ``AsyncImagePhase/empty``. After the operation completes, the phase - /// becomes either ``AsyncImagePhase/failure(_:)`` or - /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's - /// ``AsyncImagePhase/error`` value indicates the reason for failure. - /// In the second case, the phase's ``AsyncImagePhase/image`` property - /// contains the loaded image. Use the phase to drive the output of the - /// `content` closure, which defines the view's appearance: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in - /// if let image = phase.image { - /// image // Displays the loaded image. - /// } else if phase.error != nil { - /// Color.red // Indicates an error. - /// } else { - /// Color.blue // Acts as a placeholder. - /// } - /// } - /// - /// To add transitions when you change the URL, apply an identifier to the - /// ``CachedAsyncImage``. - /// - /// - Parameters: - /// - url: The URL of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - transaction: The transaction to use when the phase changes. - /// - content: A closure that takes the load phase as an input, and - /// returns the view to display for the specified phase. - public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { - let urlRequest = url == nil ? nil : URLRequest(url: url!) - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, transaction: transaction, content: content) - } - - /// Loads and displays a modifiable image from the specified URL in phases. - /// - /// If you set the asynchronous image's URL to `nil`, or after you set the - /// URL to a value but before the load operation completes, the phase is - /// ``AsyncImagePhase/empty``. After the operation completes, the phase - /// becomes either ``AsyncImagePhase/failure(_:)`` or - /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's - /// ``AsyncImagePhase/error`` value indicates the reason for failure. - /// In the second case, the phase's ``AsyncImagePhase/image`` property - /// contains the loaded image. Use the phase to drive the output of the - /// `content` closure, which defines the view's appearance: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in - /// if let image = phase.image { - /// image // Displays the loaded image. - /// } else if phase.error != nil { - /// Color.red // Indicates an error. - /// } else { - /// Color.blue // Acts as a placeholder. - /// } - /// } - /// - /// To add transitions when you change the URL, apply an identifier to the - /// ``CachedAsyncImage``. - /// - /// - Parameters: - /// - urlRequest: The URL request of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - transaction: The transaction to use when the phase changes. - /// - content: A closure that takes the load phase as an input, and - /// returns the view to display for the specified phase. - public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { - let configuration = URLSessionConfiguration.default - configuration.urlCache = urlCache - self.urlRequest = urlRequest - self.urlSession = URLSession(configuration: configuration) - self.scale = scale - self.transaction = transaction - self.content = content - - self._phase = State(wrappedValue: .loading) - do { - if let urlRequest = urlRequest, let image = try cachedGif(from: urlRequest, cache: urlCache) { - self._phase = State(wrappedValue: .success(image)) - } - } catch { - self._phase = State(wrappedValue: .error) - } - } - - @Sendable - private func load() async { - do { - if let urlRequest = urlRequest { - let (image, metrics) = try await remoteGif(from: urlRequest, session: urlSession) - if metrics.transactionMetrics.last?.resourceFetchType == .localCache { - // WARNING: This does not behave well when the url is changed with another - phase = .success(image) - } else { - withAnimation(transaction.animation) { - phase = .success(image) - } - } - } else { - withAnimation(transaction.animation) { - phase = .loading - } - } - } catch { - withAnimation(transaction.animation) { - phase = .error - } - } - } -} - -// MARK: - Helpers - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -private extension CachedAsyncGiffy { - private func remoteGif(from request: URLRequest, session: URLSession) async throws -> (Giffy, URLSessionTaskMetrics) { - let (data, _, metrics) = try await session.data(for: request) - if metrics.redirectCount > 0, let lastResponse = metrics.transactionMetrics.last?.response { - let requests = metrics.transactionMetrics.map { $0.request } - requests.forEach(session.configuration.urlCache!.removeCachedResponse) - let lastCachedResponse = CachedURLResponse(response: lastResponse, data: data) - session.configuration.urlCache!.storeCachedResponse(lastCachedResponse, for: request) - } - return (try gif(from: data), metrics) - } - - private func cachedGif(from request: URLRequest, cache: URLCache) throws -> Giffy? { - guard let cachedResponse = cache.cachedResponse(for: request) else { return nil } - return try gif(from: cachedResponse.data) - } - - private func gif(from data: Data) throws -> Giffy { -#if os(macOS) - if let nsImage = NSImage(data: data) { - return Giffy(nsImage: nsImage) - } else { - throw AsyncGiffy.LoadingError() - } -#else - return Giffy(imageData: data) -#endif - } -} - -// MARK: - AsyncImageURLSession - -private class URLSessionTaskController: NSObject, URLSessionTaskDelegate { - - var metrics: URLSessionTaskMetrics? - - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - self.metrics = metrics - } -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -private extension URLSession { - - func data(for request: URLRequest) async throws -> (Data, URLResponse, URLSessionTaskMetrics) { - let controller = URLSessionTaskController() - let (data, response) = try await data(for: request, delegate: controller) - return (data, response, controller.metrics!) - } -} diff --git a/Pocket Poster/Packages/GIFImage/Sources/GIFImage+Init.swift b/Pocket Poster/Packages/GIFImage/Sources/GIFImage+Init.swift new file mode 100644 index 0000000..bc937c5 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/GIFImage+Init.swift @@ -0,0 +1,169 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 06/04/2022. +// + +import SwiftUI + +public extension GIFImage { + + /// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source + /// into frames and stream them based in the "Delay" key packaged on which frame item. + /// + /// - Parameters: + /// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil` + /// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed + /// - loop: Flag to indicate if the GIF should be played only once or continue to loop + /// - placeholder: Image to be used before the source is loaded + /// - errorImage: If the stream fails, this image is used + /// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate + /// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action + init?( + url: String, + animate: Bool, + loop: Bool, + placeholder: RawImage = RawImage(), + errorImage: RawImage? = nil, + frameRate: FrameRate = .dynamic, + loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + self.init( + url: url, + animate: .constant(animate), + loop: .constant(loop), + placeholder: placeholder, + errorImage: errorImage, + frameRate: frameRate, + loopAction: loopAction + ) + } + + /// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source + /// into frames and stream them based in the "Delay" key packaged on which frame item. + /// + /// - Parameters: + /// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil` + /// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed + /// - loop: Flag to indicate if the GIF should be played only once or continue to loop + /// - placeholder: Image to be used before the source is loaded + /// - errorImage: If the stream fails, this image is used + /// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate + /// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action + init?( + url: String, + animate: Binding = Binding.constant(true), + loop: Binding = Binding.constant(true), + placeholder: RawImage = RawImage(), + errorImage: RawImage? = nil, + frameRate: FrameRate = .dynamic, + loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + guard let resolvedURL = URL(string: url) else { + return nil + } + self.init( + source: GIFSource.remoteURL(resolvedURL), + animate: animate, + loop: loop, + placeholder: placeholder, + errorImage: errorImage, + frameRate: frameRate, + loopAction: loopAction + ) + } + + /// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source + /// into frames and stream them based in the "Delay" key packaged on which frame item. + /// + /// - Parameters: + /// - url: URL of the image. The response is cached using `URLCache` + /// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed + /// - loop: Flag to indicate if the GIF should be played only once or continue to loop + /// - placeholder: Image to be used before the source is loaded + /// - errorImage: If the stream fails, this image is used + /// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate + /// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action + init( + url: URL, + animate: Bool = true, + loop: Bool = true, + placeholder: RawImage = RawImage(), + errorImage: RawImage? = nil, + frameRate: FrameRate = .dynamic, + loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + self.init( + url: url, + animate: .constant(animate), + loop: .constant(loop), + placeholder: placeholder, + errorImage: errorImage, + frameRate: frameRate, + loopAction: loopAction + ) + } + + /// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source + /// into frames and stream them based in the "Delay" key packaged on which frame item. + /// + /// - Parameters: + /// - url: URL of the image. The response is cached using `URLCache` + /// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed + /// - loop: Flag to indicate if the GIF should be played only once or continue to loop + /// - placeholder: Image to be used before the source is loaded + /// - errorImage: If the stream fails, this image is used + /// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate + /// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action + init( + url: URL, + animate: Binding = Binding.constant(true), + loop: Binding = Binding.constant(true), + placeholder: RawImage = RawImage(), + errorImage: RawImage? = nil, + frameRate: FrameRate = .dynamic, + loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + self.init( + source: GIFSource.remoteURL(url), + animate: animate, + loop: loop, + placeholder: placeholder, + errorImage: errorImage, + frameRate: frameRate, + loopAction: loopAction + ) + } + + /// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source + /// into frames and stream them based in the "Delay" key packaged on which frame item. + /// + /// - Parameters: + /// - request: `URLRequest` of the image. The response is cached using `URLCache` + /// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed + /// - loop: Flag to indicate if the GIF should be played only once or continue to loop + /// - placeholder: Image to be used before the source is loaded + /// - errorImage: If the stream fails, this image is used + /// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate + /// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action + init( + request: URLRequest, + animate: Binding = Binding.constant(true), + loop: Binding = Binding.constant(true), + placeholder: RawImage = RawImage(), + errorImage: RawImage? = nil, + frameRate: FrameRate = .dynamic, + loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + self.init( + source: GIFSource.remoteRequest(request), + animate: animate, + loop: loop, + placeholder: placeholder, + errorImage: errorImage, + frameRate: frameRate, + loopAction: loopAction + ) + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/GIFImage.swift new file mode 100644 index 0000000..44ed5a7 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/GIFImage.swift @@ -0,0 +1,137 @@ +import SwiftUI + +/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source +/// into frames and stream them based in the "Delay" key packaged on which frame item. The view will use the `ImageLoader` from the environment +/// to convert the fetch the `Data` +public struct GIFImage: View { + public let source: GIFSource + public let placeholder: RawImage + public let errorImage: RawImage? + private let presentationController: PresentationController + + @Environment(\.imageLoader) var imageLoader + @State @MainActor private var frame: RawImage? + @Binding public var loop: Bool + @Binding public var animate: Bool + @State private var presentationTask: Task<(), Never>? + + /// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source + /// into frames and stream them based in the "Delay" key packaged on which frame item. + /// + /// - Parameters: + /// - source: Source of the image. If the source is remote, the response is cached using `URLCache` + /// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed + /// - loop: Flag to indicate if the GIF should be played only once or continue to loop + /// - placeholder: Image to be used before the source is loaded + /// - errorImage: If the stream fails, this image is used + /// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate + /// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action + public init( + source: GIFSource, + animate: Bool, + loop: Bool, + placeholder: RawImage = RawImage(), + errorImage: RawImage? = nil, + frameRate: FrameRate = .dynamic, + loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + self.init( + source: source, + animate: .constant(animate), + loop: .constant(loop), + placeholder: placeholder, + errorImage: errorImage, + frameRate: frameRate, + loopAction: loopAction + ) + } + + /// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source + /// into frames and stream them based in the "Delay" key packaged on which frame item. + /// + /// - Parameters: + /// - source: Source of the image. If the source is remote, the response is cached using `URLCache` + /// - loop: Flag to indicate if the GIF should be played only once or continue to loop + /// - placeholder: Image to be used before the source is loaded + /// - errorImage: If the stream fails, this image is used + /// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate + /// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action + public init( + source: GIFSource, + animate: Binding = Binding.constant(true), + loop: Binding = Binding.constant(true), + placeholder: RawImage = RawImage(), + errorImage: RawImage? = nil, + frameRate: FrameRate = .dynamic, + loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + self.source = source + self._animate = animate + self._loop = loop + self.placeholder = placeholder + self.errorImage = errorImage + + self.presentationController = PresentationController( + source: source, + frameRate: frameRate, + animate: animate, + loop: loop, + action: loopAction + ) + } + + public var body: some View { + Image.loadImage(with: frame ?? placeholder) + .resizable() + .scaledToFit() + .onChange(of: loop) { newValue in + handle(loop: newValue) + } + .onChange(of: animate) { newValue in + handle(animate: newValue) + } + .task(id: source, load) + } + + private func handle(animate: Bool) { + if animate { + load() + } else { + presentationTask?.cancel() + } + } + + private func handle(loop: Bool) { + if loop { load() } + } + + @Sendable private func load() { + presentationTask?.cancel() + presentationTask = Task { await presentationController.start( + imageLoader: imageLoader, + fallbackImage: errorImage ?? placeholder, + frameUpdate: setFrame(_:) + )} + } + + @MainActor + @Sendable private func setFrame(_ frame: RawImage) async { + self.frame = frame + } +} + +#if DEBUG +let placeholder = RawImage.create(symbol: "photo.circle.fill")! +let error = RawImage.create(symbol: "xmark.octagon") +let gifURL = "https://raw.githubusercontent.com/igorcferreira/GIFImage/main/Tests/test.gif" +#Preview("Raw URL") { + GIFImage(url: gifURL, placeholder: placeholder, errorImage: error) +} +#Preview("Limited 5 FPS") { + GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .limited(fps: 5)) +} +#Preview("Limited to 30 FPS") { + GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .static(fps: 30)) +} +#endif + diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceFrameSequence.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceFrameSequence.swift new file mode 100644 index 0000000..ac7b04c --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceFrameSequence.swift @@ -0,0 +1,46 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import Foundation +import ImageIO + +public actor CGImageSourceFrameSequence: AsyncSequence { + public typealias Element = ImageFrame + + public enum LoadError: Error { + case invalidData + case emptyData + } + + public let source: CGImageSource + + public init(data: Data) throws { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + throw LoadError.invalidData + } + + switch CGImageSourceGetStatus(source) { + case .statusComplete: break + case .statusReadingHeader: break + case .statusIncomplete: throw LoadError.emptyData + case .statusInvalidData: throw LoadError.invalidData + case .statusUnexpectedEOF: throw LoadError.invalidData + case .statusUnknownType: throw LoadError.invalidData + @unknown default: throw LoadError.invalidData + } + + self.init(source: source) + } + + public init(source: CGImageSource) { + self.source = source + } + + nonisolated public func makeAsyncIterator() -> CGImageSourceIterator { + CGImageSourceIterator(source: source) + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceIterator.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceIterator.swift new file mode 100644 index 0000000..4cb67a7 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceIterator.swift @@ -0,0 +1,36 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import Foundation +import ImageIO + +public actor CGImageSourceIterator: AsyncIteratorProtocol { + + public let frameCount: Int + public let source: CGImageSource + public private(set) var currentFrame: Int + + public init(source: CGImageSource) { + self.source = source + self.frameCount = CGImageSourceGetCount(source) + self.currentFrame = 0 + } + + public func next() async throws -> ImageFrame? { + guard currentFrame < frameCount else { + return nil + } + + let frame: ImageFrame? = if let image = CGImageSourceCreateImageAtIndex(source, currentFrame, nil) { + ImageFrame(image: image, interval: source.intervalAtIndex(currentFrame)) + } else { + nil + } + currentFrame += 1 + return frame + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/ImageLoader.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/ImageLoader.swift new file mode 100644 index 0000000..db7a575 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/domain/ImageLoader.swift @@ -0,0 +1,80 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import Foundation + +/// Object responsible to load a `GIFSource` into a `ImageFrame` stream. +public struct ImageLoader { + public let session: URLSession + public let cache: URLCache + public let fileManager: FileManager + + public init(session: URLSession = .shared, cache: URLCache = .shared, fileManager: FileManager = .default) { + self.session = session + self.cache = cache + self.fileManager = fileManager + } + + public func load(source: GIFSource) async throws -> CGImageSourceFrameSequence { + let data = try await source.loadData(session: session, cache: cache, fileManager: fileManager) + return try data.imageAsyncSequence() + } +} + +private extension GIFSource { + func loadData(session: URLSession, cache: URLCache, fileManager: FileManager) async throws -> Data { + switch self { + case let .static(data): data + case .remote(let url), .remoteURL(let url): try await url.loadData(session: session, cache: cache) + case let .remoteRequest(request): try await request.loadData(session: session, cache: cache) + case let .local(filePath): try await filePath.loadData(fileManager: fileManager) + } + } +} + +private extension String { + func loadData(fileManager: FileManager) async throws -> Data { + guard fileManager.fileExists(atPath: self) else { + throw URLError(.fileDoesNotExist) + } + guard let data = fileManager.contents(atPath: self) else { + throw URLError(.cannotOpenFile) + } + return data + } +} + +private extension URL { + func loadData(session: URLSession, cache: URLCache) async throws -> Data { + let request = URLRequest(url: self) + return try await request.loadData(session: session, cache: cache) + } +} + +private extension URLRequest { + func loadData(session: URLSession, cache: URLCache) async throws -> Data { + if let cache = cache.cachedResponse(for: self) { + return cache.data + } + + let (data, response) = try await session.data(for: self) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard httpResponse.isSuccess else { + throw URLError(.init(rawValue: httpResponse.statusCode)) + } + cache.storeCachedResponse(CachedURLResponse(response: response, data: data), for: self) + return data + } +} + +private extension HTTPURLResponse { + var isSuccess: Bool { + 200..<300 ~= statusCode + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/PresentationController.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/PresentationController.swift new file mode 100644 index 0000000..4de122d --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/domain/PresentationController.swift @@ -0,0 +1,65 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 9/9/22. +// + +import Foundation +import SwiftUI + +private let kDefaultGIFFrameInterval: TimeInterval = 1.0 / 24.0 + +struct PresentationController { + let source: GIFSource + let frameRate: FrameRate + let action: (GIFSource) async throws -> Void + @Binding var animate: Bool + @Binding var loop: Bool + + init( + source: GIFSource, + frameRate: FrameRate, + animate: Binding, + loop: Binding, + action: @Sendable @escaping (GIFSource) async throws -> Void = { _ in } + ) { + self.source = source + self.action = action + self.frameRate = frameRate + self._animate = animate + self._loop = loop + } + + func start(imageLoader: ImageLoader, fallbackImage: RawImage, frameUpdate: (RawImage) async -> Void) async { + do { + repeat { + for try await imageFrame in try await imageLoader.load(source: source) { + try await update(imageFrame, frameUpdate: frameUpdate) + if !animate { break } + } + try await action(source) + } while(self.loop && self.animate) + } catch { + if !(error is CancellationError) { + await frameUpdate(fallbackImage) + } + } + } + + private func update(_ imageFrame: ImageFrame, frameUpdate: (RawImage) async -> Void) async throws { + await frameUpdate(RawImage.create(with: imageFrame.image)) + let calculatedInterval = imageFrame.interval ?? kDefaultGIFFrameInterval + let interval: Double + switch frameRate { + case .static(let fps): + interval = (1.0 / Double(fps)) + case .limited(let fps): + let intervalLimit = (1.0 / Double(fps)) + interval = max(calculatedInterval, intervalLimit) + case .dynamic: + interval = imageFrame.interval ?? kDefaultGIFFrameInterval + } + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000.0)) + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/CFString+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/CFString+GIFImage.swift new file mode 100644 index 0000000..1c26073 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/extension/CFString+GIFImage.swift @@ -0,0 +1,15 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import Foundation +import ImageIO + +extension CFString { + var asKey: UnsafeMutableRawPointer { + return Unmanaged.passUnretained(self).toOpaque() + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/CGImageSource+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/CGImageSource+GIFImage.swift new file mode 100644 index 0000000..41034b9 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/extension/CGImageSource+GIFImage.swift @@ -0,0 +1,39 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import Foundation +import ImageIO + +extension CGImageSource { + func intervalAtIndex(_ index: Int) -> TimeInterval? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) else { + return nil + } + + guard let gifProperties = CFDictionaryGetValue(properties, kCGImagePropertyGIFDictionary.asKey) else { + return nil + } + + let dictionary = unsafeBitCast(gifProperties, to: CFDictionary.self) + + let pointer: UnsafeRawPointer + if let delay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFDelayTime.asKey) { + pointer = delay + } else if let unclampedDelay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFUnclampedDelayTime.asKey) { + pointer = unclampedDelay + } else { + return nil + } + + let interval = unsafeBitCast(pointer, to: AnyObject.self).doubleValue ?? 0.0 + if interval > 0.0 { + return interval + } else { + return nil + } + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/Data+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/Data+GIFImage.swift new file mode 100644 index 0000000..982adaf --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/extension/Data+GIFImage.swift @@ -0,0 +1,15 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import Foundation +import ImageIO + +extension Data { + func imageAsyncSequence() throws -> CGImageSourceFrameSequence { + return try CGImageSourceFrameSequence(data: self) + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/Environment+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/Environment+GIFImage.swift new file mode 100644 index 0000000..506d781 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/extension/Environment+GIFImage.swift @@ -0,0 +1,28 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import SwiftUI + +struct GIFImageEnvironment: EnvironmentKey { + static var defaultValue: ImageLoader = { + return ImageLoader() + }() +} + +extension EnvironmentValues { + var imageLoader: ImageLoader { + get { self[GIFImageEnvironment.self] } + set { self[GIFImageEnvironment.self] = newValue } + } +} + +extension View { + @ViewBuilder + func with(imageLoader: ImageLoader) -> some View { + environment(\.imageLoader, imageLoader) + } +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/FrameRate.swift b/Pocket Poster/Packages/GIFImage/Sources/model/FrameRate.swift new file mode 100644 index 0000000..96e1fb5 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/model/FrameRate.swift @@ -0,0 +1,14 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 06/04/2022. +// + +import Foundation + +public enum FrameRate { + case dynamic + case limited(fps: Int) + case `static`(fps: Int) +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/GIFSource.swift b/Pocket Poster/Packages/GIFImage/Sources/model/GIFSource.swift new file mode 100644 index 0000000..fe66dfb --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/model/GIFSource.swift @@ -0,0 +1,17 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 29/10/2020. +// + +import Foundation + +public enum GIFSource: Equatable { + @available(*, deprecated, renamed: "remoteURL(_:)") + case remote(url: URL) + case remoteURL(_ url: URL) + case remoteRequest(_ request: URLRequest) + case local(filePath: String) + case `static`(data: Data) +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/ImageFrame.swift b/Pocket Poster/Packages/GIFImage/Sources/model/ImageFrame.swift new file mode 100644 index 0000000..05cafa5 --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/model/ImageFrame.swift @@ -0,0 +1,14 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 29/10/2020. +// + +import ImageIO +import Foundation + +public struct ImageFrame { + public let image: CGImage + public let interval: TimeInterval? +} diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/RawImage.swift b/Pocket Poster/Packages/GIFImage/Sources/model/RawImage.swift new file mode 100644 index 0000000..36928cd --- /dev/null +++ b/Pocket Poster/Packages/GIFImage/Sources/model/RawImage.swift @@ -0,0 +1,44 @@ +// +// File.swift +// +// +// Created by Igor Ferreira on 05/04/2022. +// + +import ImageIO +import SwiftUI +#if os(macOS) +import AppKit +public typealias RawImage = NSImage +#else +import UIKit +public typealias RawImage = UIImage +#endif + +public extension RawImage { + static func create(with cgImage: CGImage) -> RawImage { + #if os(macOS) + return RawImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + #else + return RawImage(cgImage: cgImage) + #endif + } + + static func create(symbol: String) -> RawImage? { + #if os(macOS) + return RawImage(systemSymbolName: symbol, accessibilityDescription: nil) + #else + return RawImage(systemName: symbol) + #endif + } +} + +public extension Image { + static func loadImage(with image: RawImage) -> Image { + #if os(macOS) + return Image(nsImage: image) + #else + return Image(uiImage: image) + #endif + } +} diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index 10ddd3d..9603dcf 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -7,7 +7,6 @@ import SwiftUI import CachedAsyncImage -import Giffy let MIN_SIZE: CGFloat = 165 @@ -57,33 +56,11 @@ struct ExploreView: View { }) { VStack(spacing: 0) { if wallpaper.previewIsGif() { - CachedAsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { giffy in - giffy - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .cornerRadius(10, corners: .topLeft) - .cornerRadius(10, corners: .topRight) - } placeholder: { - Color.gray - .frame(height: MIN_SIZE) - } - // Non-cached code: -// AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in -// switch phase { -// case .loading: -// Color.gray -// .frame(height: MIN_SIZE) -// case .error: -// Color.red -// .frame(height: MIN_SIZE) -// case .success(let giffy): -// giffy -// .aspectRatio(contentMode: .fill) -// .frame(maxWidth: .infinity) -// .cornerRadius(10, corners: .topLeft) -// .cornerRadius(10, corners: .topRight) -// } -// } + GIFImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), animate: true, loop: true) + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) } else { CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in image