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.
-///
-/// 
-///
-/// 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:
-///
-/// 
-///
-/// > 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