diff --git a/Modules/Sources/NineAnimatorNativeListServices/ListServices/TraceMoe/TraceMoe.swift b/Modules/Sources/NineAnimatorNativeListServices/ListServices/TraceMoe/TraceMoe.swift
new file mode 100644
index 00000000..e6bb7646
--- /dev/null
+++ b/Modules/Sources/NineAnimatorNativeListServices/ListServices/TraceMoe/TraceMoe.swift
@@ -0,0 +1,160 @@
+//
+// This file is part of the NineAnimator project.
+//
+// Copyright © 2018-2020 Marcus Zhou. All rights reserved.
+//
+// NineAnimator is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// NineAnimator is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with NineAnimator. If not, see .
+//
+
+import Alamofire
+import NineAnimatorCommon
+import UIKit
+
+public class TraceMoe {
+ var apiURL: URL { URL(string: "https://api.trace.moe")! }
+
+ let requestManager = NAEndpointRelativeRequestManager()
+
+ public init() {}
+
+ public func search(with image: UIImage) -> NineAnimatorPromise<[TraceMoeSearchResult]> {
+ NineAnimatorPromise.firstly {
+ guard let jpegData = image.jpegData(compressionQuality: 0.8) else {
+ throw NineAnimatorError.unknownError("Could not convert image to jpeg.")
+ }
+ return jpegData
+ } .thenPromise {
+ jpegData -> NineAnimatorPromise in
+ let jsonDecoder = JSONDecoder()
+ jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
+
+ let request = self.requestManager.request { session in
+ return self.requestManager.session.upload(
+ multipartFormData: { form in
+ form.append(jpegData, withName: "image", fileName: "image", mimeType: "image/jpeg")
+ },
+ to: self.apiURL.appendingPathComponent("/search").absoluteString + "?anilistInfo"
+ )
+ }
+
+ return request
+ .onReceivingResponse { try self.validateResponse($0) }
+ .responseDecodable(type: TraceMoeSearchResponse.self, decoder: jsonDecoder)
+ } .then {
+ responseObject in
+ let filteredResponses = responseObject.result.filter { $0.similarity >= 0.6 }
+ guard !filteredResponses.isEmpty else { throw NineAnimatorError.searchError("No Results Found") }
+ return filteredResponses
+ }
+ }
+
+ public func search(with imageURL: URL) -> NineAnimatorPromise<[TraceMoeSearchResult]> {
+ let jsonDecoder = JSONDecoder()
+ jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
+ return self.requestManager.request(
+ url: self.apiURL.appendingPathComponent("/search"),
+ method: .post,
+ query: [
+ "url": imageURL.absoluteString,
+ "anilistInfo": ""
+ ]
+ )
+ .onReceivingResponse { try self.validateResponse($0) }
+ .responseDecodable(type: TraceMoeSearchResponse.self, decoder: jsonDecoder)
+ .then {
+ responseObject in
+ let filteredResponses = responseObject.result.filter { $0.similarity >= 0.6 }
+ guard !filteredResponses.isEmpty else { throw NineAnimatorError.searchError("No Results Found") }
+ return filteredResponses
+ }
+ }
+
+ private func validateResponse(_ response: NARequestManager.Response) throws {
+ guard let HTTPresponse = response.response else { return }
+ switch HTTPresponse.statusCode {
+ case 400:
+ throw NineAnimatorError.searchError("Image Was Not Provided")
+ case 413:
+ throw NineAnimatorError.searchError("Your image was above 10MB")
+ case 429:
+ throw NineAnimatorError.searchError("You are hitting the rate limiter. Calm down!")
+ case 500..<600:
+ if let data = response.data, let errorString = String(data: data, encoding: .utf8) {
+ throw NineAnimatorError.searchError("Trace.moe experienced a backend error: \(errorString)")
+ }
+ throw NineAnimatorError.searchError("Trace.moe experienced a backend error")
+ default: break
+ }
+ }
+}
+
+// MARK: - Data Types
+public extension TraceMoe {
+ struct TraceMoeSearchResponse: Codable {
+ public let result: [TraceMoeSearchResult]
+ }
+
+ struct TraceMoeSearchResult: Codable {
+ public let from, to: Double
+ public let anilist: TraceMoeAnilistInfo
+ public let filename: String
+ public let video: URL
+ public let image: URL
+ public let episode: TraceMoeEpisode?
+ public let similarity: Double
+ }
+
+ struct TraceMoeAnilistInfo: Codable {
+ public let id, idMal: Int
+ public let title: TraceMoeAnilistTitle
+ public let synonyms: [String]
+ public let isAdult: Bool
+ }
+
+ struct TraceMoeAnilistTitle: Codable {
+ public let romaji: String?
+ public let english: String?
+ public let native: String?
+ public let userPreferred: String?
+ }
+
+ /// Abstracts Trace.moe's custom episode type to a string value type
+ struct TraceMoeEpisode: Codable {
+ public let value: String
+
+ public init(_ value: String) {
+ self.value = value
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if let str = try? container.decode(String.self) {
+ value = str
+ } else if let int = try? container.decode(Int.self) {
+ value = String(int)
+ } else if let double = try? container.decode(Double.self) {
+ value = String(double)
+ } else if let array = try? container.decode([Int].self) {
+ value = array.description
+ } else {
+ throw DecodingError.typeMismatch(String.self, .init(codingPath: decoder.codingPath, debugDescription: "Could not convert trace.moe episode into String"))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(value)
+ }
+ }
+}
diff --git a/NineAnimator.xcodeproj/project.pbxproj b/NineAnimator.xcodeproj/project.pbxproj
index 688df670..5fe2cee2 100644
--- a/NineAnimator.xcodeproj/project.pbxproj
+++ b/NineAnimator.xcodeproj/project.pbxproj
@@ -229,7 +229,11 @@
52CADD6721BD86F40077BEB1 /* UITableView+DeselectRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52CADD6621BD86F40077BEB1 /* UITableView+DeselectRows.swift */; };
BF6820E326817C93004DCDBD /* NineAnimatorCommon in Frameworks */ = {isa = PBXBuildFile; productRef = BF6820E226817C93004DCDBD /* NineAnimatorCommon */; };
BF6820E426817C93004DCDBD /* NineAnimatorCommon in Embed Frameworks */ = {isa = PBXBuildFile; productRef = BF6820E226817C93004DCDBD /* NineAnimatorCommon */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
+ BF8CC475268562AB00A0A485 /* ImageSearchSelectorScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8CC474268562AB00A0A485 /* ImageSearchSelectorScene.swift */; };
+ BF8CC4772685646100A0A485 /* ImageSearchSelectorView+PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8CC4762685646100A0A485 /* ImageSearchSelectorView+PhotoPicker.swift */; };
BF9203DC25E07E6E00CB2D91 /* AudioBackgroundController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9203DB25E07E6E00CB2D91 /* AudioBackgroundController.swift */; };
+ BF99DE63268698240019BFC6 /* ImageSearchResultsView+AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF99DE62268698240019BFC6 /* ImageSearchResultsView+AVPlayer.swift */; };
+ BF99DE65268698920019BFC6 /* ImageSearchResultsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF99DE64268698920019BFC6 /* ImageSearchResultsScene.swift */; };
BFF95F0A257C2DA8001F8484 /* SettingsSceneController+DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF95F09257C2DA8001F8484 /* SettingsSceneController+DocumentPicker.swift */; };
/* End PBXBuildFile section */
@@ -563,7 +567,11 @@
2CF8912121DB39A1006D4799 /* icon_rendered.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = icon_rendered.png; sourceTree = ""; };
52CADD6621BD86F40077BEB1 /* UITableView+DeselectRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+DeselectRows.swift"; sourceTree = ""; };
52F6A4F221DABDB00005D78D /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text; path = .swiftlint.yml; sourceTree = SOURCE_ROOT; };
+ BF8CC474268562AB00A0A485 /* ImageSearchSelectorScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSearchSelectorScene.swift; sourceTree = ""; };
+ BF8CC4762685646100A0A485 /* ImageSearchSelectorView+PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageSearchSelectorView+PhotoPicker.swift"; sourceTree = ""; };
BF9203DB25E07E6E00CB2D91 /* AudioBackgroundController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBackgroundController.swift; sourceTree = ""; };
+ BF99DE62268698240019BFC6 /* ImageSearchResultsView+AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageSearchResultsView+AVPlayer.swift"; sourceTree = ""; };
+ BF99DE64268698920019BFC6 /* ImageSearchResultsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSearchResultsScene.swift; sourceTree = ""; };
BFF95F09257C2DA8001F8484 /* SettingsSceneController+DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsSceneController+DocumentPicker.swift"; sourceTree = ""; };
/* End PBXFileReference section */
@@ -895,6 +903,7 @@
2C4ABC7721B59C95009B4D47 /* Views */ = {
isa = PBXGroup;
children = (
+ BF8CC4732685621F00A0A485 /* Image Search Scene */,
2C0DA2E3238B2784002C6447 /* Settings Scene */,
2C665570235CE70300A6126A /* Library Scene */,
2C71DEC1226CF748004F3AC3 /* Server Selection Scene */,
@@ -1297,6 +1306,17 @@
path = "User Notification";
sourceTree = "";
};
+ BF8CC4732685621F00A0A485 /* Image Search Scene */ = {
+ isa = PBXGroup;
+ children = (
+ BF8CC474268562AB00A0A485 /* ImageSearchSelectorScene.swift */,
+ BF8CC4762685646100A0A485 /* ImageSearchSelectorView+PhotoPicker.swift */,
+ BF99DE62268698240019BFC6 /* ImageSearchResultsView+AVPlayer.swift */,
+ BF99DE64268698920019BFC6 /* ImageSearchResultsScene.swift */,
+ );
+ path = "Image Search Scene";
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -1658,12 +1678,14 @@
2C71DEC5226E0610004F3AC3 /* ServerSelectionView.swift in Sources */,
2CA1DD5C21E2B404009241CB /* HomeController.swift in Sources */,
2C90C6A6236F98C600C18620 /* LibraryTrackingReferenceCell.swift in Sources */,
+ BF8CC4772685646100A0A485 /* ImageSearchSelectorView+PhotoPicker.swift in Sources */,
2C11187125B624F500BD49D2 /* CoreExportsLogging.swift in Sources */,
2C90C6A4236F988100C18620 /* LibraryUntrackedReferenceCell.swift in Sources */,
2CB004252200AEBC00351B7E /* OfflineContent.swift in Sources */,
2C96DAE424562A2F007A29B0 /* InformationSceneAiringEpisodeCell.swift in Sources */,
2CAA70D321CFF42A00F0D082 /* NativePlayerController.swift in Sources */,
2C368A0D2592F0580069DE91 /* CoreExportsNineAnimator+Fetch.swift in Sources */,
+ BF8CC475268562AB00A0A485 /* ImageSearchSelectorScene.swift in Sources */,
2C9383CD21FBF442008C0D01 /* UIView+MakeThemable.swift in Sources */,
2C1C717322186EC900760A69 /* TrackingServiceTableViewController.swift in Sources */,
2C93BD43221E1FE8000411CB /* InformationSceneStatisticsTableViewCell.swift in Sources */,
@@ -1692,6 +1714,7 @@
2C85367E222EEC7400A5CFB9 /* RecentsSceneCollectionCollectionViewCell.swift in Sources */,
2CB9919421FEAC5D0055B7A7 /* DetailedEpisodeTableViewCell.swift in Sources */,
2C86C1EB225D6F9F00950ABF /* SetupWelcomeViewController.swift in Sources */,
+ BF99DE65268698920019BFC6 /* ImageSearchResultsScene.swift in Sources */,
2C66779821BC2213000E5ACC /* SearchViewController.swift in Sources */,
2CB4AF96239AD1B700EE4EDA /* CachableAVAssetLoaderDelegate.swift in Sources */,
2C9C44FE21CD2B33004C8F0C /* SettingsSceneController.swift in Sources */,
@@ -1706,6 +1729,7 @@
52CADD6721BD86F40077BEB1 /* UITableView+DeselectRows.swift in Sources */,
2C12EDDE21BDE6B20064D2BD /* HalfFillTransitionDelegate.swift in Sources */,
2CE18B0C2374839100771A16 /* LibrarySubscriptionCell.swift in Sources */,
+ BF99DE63268698240019BFC6 /* ImageSearchResultsView+AVPlayer.swift in Sources */,
2C9383D421FCCC43008C0D01 /* UILabel+StyleAttributes.swift in Sources */,
2C35C968227F223700D469E6 /* CalendarHeaderView.swift in Sources */,
2C12EDDB21BDE6B20064D2BD /* HalfFillViewControllerProtocol.swift in Sources */,
diff --git a/NineAnimator/Controllers/Anime Discovery Scenes/AnimeScheduleCollectionViewController.swift b/NineAnimator/Controllers/Anime Discovery Scenes/AnimeScheduleCollectionViewController.swift
index 07d0ad86..92bec1e8 100644
--- a/NineAnimator/Controllers/Anime Discovery Scenes/AnimeScheduleCollectionViewController.swift
+++ b/NineAnimator/Controllers/Anime Discovery Scenes/AnimeScheduleCollectionViewController.swift
@@ -139,7 +139,7 @@ class AnimeScheduleCollectionViewController: MinFilledCollectionViewController,
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if let view = collectionView.cellForItem(at: indexPath) as? CalendarAnimeCell,
let item = view.representingScheduledAnime {
- RootViewController.shared?.open(immedietly: item.link, in: self)
+ RootViewController.shared?.open(immedietly: item.link, method: .inController(self))
}
}
}
diff --git a/NineAnimator/Controllers/Anime Information Scene/AnimeInformationTableViewController.swift b/NineAnimator/Controllers/Anime Information Scene/AnimeInformationTableViewController.swift
index 93d10c0a..9ef93abd 100644
--- a/NineAnimator/Controllers/Anime Information Scene/AnimeInformationTableViewController.swift
+++ b/NineAnimator/Controllers/Anime Information Scene/AnimeInformationTableViewController.swift
@@ -319,7 +319,8 @@ class AnimeInformationTableViewController: UITableViewController, DontBotherView
let cell = tableView.dequeueReusableCell(withIdentifier: "anime.related", for: indexPath) as! InformationSceneRelatedTableViewCell
cell.initialize(_relatedReferences!) {
[weak self] reference in
- RootViewController.shared?.open(immedietly: .listingReference(reference), in: self)
+ guard let self = self else { return }
+ RootViewController.shared?.open(immedietly: .listingReference(reference), method: .inController(self))
}
return cell
case .airingSchedule:
@@ -658,7 +659,7 @@ extension AnimeInformationTableViewController {
/// Open the match directly
private func onPerfectMatch(_ animeLink: AnimeLink) {
if presentingAnimeInformation == nil { navigationController?.popViewController(animated: true) }
- RootViewController.shared?.open(immedietly: .anime(animeLink), in: self)
+ RootViewController.shared?.open(immedietly: .anime(animeLink), method: .inController(self))
}
/// Present options to the user for multiple match
diff --git a/NineAnimator/Controllers/Content List Scene/ContentListViewController.swift b/NineAnimator/Controllers/Content List Scene/ContentListViewController.swift
index 62f37d7f..3d385893 100644
--- a/NineAnimator/Controllers/Content List Scene/ContentListViewController.swift
+++ b/NineAnimator/Controllers/Content List Scene/ContentListViewController.swift
@@ -153,7 +153,7 @@ extension ContentListViewController {
// Open the link if it exists
if let cell = cell as? ListingEntryTableViewCell,
let link = cell.link {
- RootViewController.shared?.open(immedietly: link, in: self)
+ RootViewController.shared?.open(immedietly: link, method: .inController(self))
}
}
}
diff --git a/NineAnimator/Controllers/Library Scene/LibrarySceneController+Tips.swift b/NineAnimator/Controllers/Library Scene/LibrarySceneController+Tips.swift
index 07a92482..0ebce7dd 100644
--- a/NineAnimator/Controllers/Library Scene/LibrarySceneController+Tips.swift
+++ b/NineAnimator/Controllers/Library Scene/LibrarySceneController+Tips.swift
@@ -42,7 +42,7 @@ extension LibrarySceneController {
let anime = updatedAnimeLinks.first {
RootViewController.shared?.open(
immedietly: .anime(anime),
- in: parent
+ method: .inController(parent)
)
} else if let category = parent.category(withIdentifier: "library.category.subscribed") {
// Else present the subscription category
diff --git a/NineAnimator/Controllers/Library Scene/LibrarySceneController.swift b/NineAnimator/Controllers/Library Scene/LibrarySceneController.swift
index f4ec8a09..7e978984 100644
--- a/NineAnimator/Controllers/Library Scene/LibrarySceneController.swift
+++ b/NineAnimator/Controllers/Library Scene/LibrarySceneController.swift
@@ -435,7 +435,7 @@ extension LibrarySceneController {
let anime = cachedRecentlyWatchedList[indexPath.item]
RootViewController.shared?.open(
immedietly: .anime(anime),
- in: self
+ method: .inController(self)
)
case .collection:
let collection = self.collection(atPath: indexPath)
diff --git a/NineAnimator/Controllers/Library Scene/LibrarySubscriptionCategoryController.swift b/NineAnimator/Controllers/Library Scene/LibrarySubscriptionCategoryController.swift
index 62c43506..805c932b 100644
--- a/NineAnimator/Controllers/Library Scene/LibrarySubscriptionCategoryController.swift
+++ b/NineAnimator/Controllers/Library Scene/LibrarySubscriptionCategoryController.swift
@@ -99,7 +99,7 @@ extension LibrarySubscriptionCategoryController {
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let link = cachedWatchedAnimeItems[indexPath.item]
- RootViewController.shared?.open(immedietly: link, in: self)
+ RootViewController.shared?.open(immedietly: link, method: .inController(self))
}
override func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
diff --git a/NineAnimator/Controllers/RootViewController.swift b/NineAnimator/Controllers/RootViewController.swift
index 56b78de7..bad55aaa 100644
--- a/NineAnimator/Controllers/RootViewController.swift
+++ b/NineAnimator/Controllers/RootViewController.swift
@@ -59,7 +59,7 @@ class RootViewController: UITabBarController, Themable {
// Open the pending if there is any
if let pendingOpeningLink = RootViewController._pendingOpeningLink {
RootViewController._pendingOpeningLink = nil
- open(immedietly: pendingOpeningLink)
+ open(immedietly: pendingOpeningLink, method: .inWatchTab)
}
// Restore config if there is any
@@ -144,7 +144,7 @@ extension RootViewController {
/// Open an `AnyLink` struct
static func open(whenReady link: AnyLink) {
if let sharedRootViewController = shared {
- sharedRootViewController.open(immedietly: link)
+ sharedRootViewController.open(immedietly: link, method: .inWatchTab)
} else { _pendingOpeningLink = link }
}
@@ -167,7 +167,46 @@ extension RootViewController {
extension RootViewController {
fileprivate static var _pendingOpeningLink: AnyLink?
- func open(immedietly link: AnyLink, in viewController: UIViewController? = nil) {
+ enum LinkOpeningMethod {
+ /// Opens the link in the controller's nearest navigation controller or presents it modally if no navigation controller is found
+ case inController(UIViewController)
+ /// Opens the link in the navigation controller that is currently selected in the UITabBarController
+ case inCurrentlyDisplayedTab
+ /// Opens the link in the watch next tab
+ /// - Note: Any UIViewControllers in the watch next tab will be popped off the navigation stack before opening the link
+ case inWatchTab
+ }
+
+ func open(immedietly link: AnyLink, method: LinkOpeningMethod) {
+ guard let controllerForLink = retrieveViewController(forLink: link) else {
+ Log.error("[RootViewController] Failed to retrieve view controller to handle anylink: %@", link)
+ return
+ }
+
+ switch method {
+ case let .inController(controller):
+ if let navigationController = controller.navigationController {
+ navigationController.pushViewController(controllerForLink, animated: true)
+ } else { controller.present(controllerForLink, animated: true) }
+ case .inWatchTab:
+ guard let navController = viewControllers?[NineAnimatorRootScene.toWatch.rawValue] as? ApplicationNavigationController else {
+ Log.error("[RootViewController] The watch next tab controller is not ApplicationNavigationController.")
+ return
+ }
+ self.navigate(toScene: .toWatch)
+ navController.popToRootViewController(animated: true)
+ navController.pushViewController(controllerForLink, animated: true)
+ case .inCurrentlyDisplayedTab:
+ guard let navController = viewControllers?[selectedIndex] as? ApplicationNavigationController else {
+ Log.error("[RootViewController] The currently selected tab controller is not ApplicationNavigationController.")
+ return
+ }
+ navController.pushViewController(controllerForLink, animated: true)
+ }
+ }
+
+ /// Initializes view controller responsible for the corresponding AnyLink
+ private func retrieveViewController(forLink link: AnyLink) -> UIViewController? {
let targetViewController: UIViewController
// Determine if the link is supported
@@ -178,7 +217,7 @@ extension RootViewController {
// Instantiate the view controller from storyboard
guard let animeViewController = storyboard.instantiateInitialViewController() as? AnimeViewController else {
Log.error("The view controller instantiated from AnimePlayer.storyboard is not AnimeViewController.")
- return
+ return nil
}
// Initialize the AnimeViewController with the link
@@ -190,35 +229,14 @@ extension RootViewController {
// Instantiate the view controller from storyboard
guard let animeInformationController = storyboard.instantiateInitialViewController() as? AnimeInformationTableViewController else {
Log.error("The view controller instantiated from AnimeInformation.storyboard is not AnimeInformationTableViewController.")
- return
+ return nil
}
// Initialize the AnimeInformationTableViewController with the link
animeInformationController.setPresenting(link)
targetViewController = animeInformationController
}
-
- // If a view controller is provided
- if let viewController = viewController {
- // If the provided view controller has a navigation controller,
- // open the link in the navigation controller. Else present it
- // directly from the provided view controller.
- if let navigationController = viewController.navigationController {
- navigationController.pushViewController(targetViewController, animated: true)
- } else { viewController.present(targetViewController, animated: true) }
- } else { // If no view controller is provided, present the link in the featured tab
- // Jump to Featured tab
- selectedIndex = 0
-
- guard let navigationController = viewControllers?.first as? ApplicationNavigationController else {
- Log.error("The first view controller is not ApplicationNavigationController.")
- return
- }
-
- // Pop to root view controller
- navigationController.popToRootViewController(animated: true)
- navigationController.pushViewController(targetViewController, animated: true)
- }
+ return targetViewController
}
}
diff --git a/NineAnimator/Controllers/Search Scene/SearchViewController.swift b/NineAnimator/Controllers/Search Scene/SearchViewController.swift
index 6f6bd154..f847558e 100644
--- a/NineAnimator/Controllers/Search Scene/SearchViewController.swift
+++ b/NineAnimator/Controllers/Search Scene/SearchViewController.swift
@@ -18,8 +18,7 @@
//
import NineAnimatorCommon
-import NineAnimatorNativeParsers
-import NineAnimatorNativeSources
+import SwiftUI
import UIKit
class SearchViewController: UITableViewController, UISearchResultsUpdating, UISearchControllerDelegate, UISearchBarDelegate {
@@ -68,6 +67,17 @@ class SearchViewController: UITableViewController, UISearchResultsUpdating, UISe
// Hide table cell separators at empty state
tableView.tableFooterView = UIView()
+ // Add Reverse Image Search Button
+ if #available(iOS 14.0, *) {
+ navigationItem.rightBarButtonItems?
+ .insert(UIBarButtonItem(
+ barButtonSystemItem: .camera,
+ target: self,
+ action: #selector(onReverseImageSearchButtonTapped)),
+ at: 0
+ )
+ }
+
// Themes
tableView.makeThemable()
searchController.searchBar.makeThemable()
@@ -201,12 +211,12 @@ extension SearchViewController {
if case let .anime(animeLink) = link {
// Open the anime link directly if the current source is the link's source
if animeLink.source.name == source.name {
- RootViewController.shared?.open(immedietly: link, in: self)
+ RootViewController.shared?.open(immedietly: link, method: .inController(self))
} else {
performSearch(keywords: item.keywords)
}
} else {
- RootViewController.shared?.open(immedietly: link, in: self)
+ RootViewController.shared?.open(immedietly: link, method: .inController(self))
}
} else { // For keywords, just search directly
performSearch(keywords: item.keywords)
@@ -281,6 +291,15 @@ extension SearchViewController {
alertView.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alertView, animated: true)
}
+
+ @available(iOS 14.0, *)
+ @objc func onReverseImageSearchButtonTapped() {
+ let hostingView = UIHostingController(rootView: ImageSearchSelectorScene())
+ // Manually setting the nav title here to fix a SwiftUI issue
+ // where the nav title pops in without an animation
+ hostingView.navigationItem.title = "Image Search"
+ navigationController?.pushViewController(hostingView, animated: true)
+ }
}
// MARK: - Defs & Helpers
diff --git a/NineAnimator/Views/Anime Information Scene/InformationSceneHeadingView.swift b/NineAnimator/Views/Anime Information Scene/InformationSceneHeadingView.swift
index c34d9fbe..6b05a9d1 100644
--- a/NineAnimator/Views/Anime Information Scene/InformationSceneHeadingView.swift
+++ b/NineAnimator/Views/Anime Information Scene/InformationSceneHeadingView.swift
@@ -70,7 +70,7 @@ class InformationSceneHeadingView: UIView, Themable {
optionsButton.isEnabled = false
animeArtworkImageView.alpha = 0.0
- animeArtworkImageView.kf.setImage(with: reference.artwork, progressBlock: nil) {
+ animeArtworkImageView.kf.setImage(with: reference.artwork) {
[weak animeArtworkImageView] _ in
UIView.animate(withDuration: 0.1) {
animeArtworkImageView?.alpha = 1.0
@@ -87,6 +87,7 @@ class InformationSceneHeadingView: UIView, Themable {
}
}
}
+ animeArtworkImageView.kf.setImage(with: animeInformation.artwork, options: [.transition(.fade(0.2))])
UIView.animate(withDuration: 0.2) {
[weak self] in
diff --git a/NineAnimator/Views/Image Search Scene/ImageSearchResultsScene.swift b/NineAnimator/Views/Image Search Scene/ImageSearchResultsScene.swift
new file mode 100644
index 00000000..5bb3841a
--- /dev/null
+++ b/NineAnimator/Views/Image Search Scene/ImageSearchResultsScene.swift
@@ -0,0 +1,219 @@
+//
+// This file is part of the NineAnimator project.
+//
+// Copyright © 2018-2020 Marcus Zhou. All rights reserved.
+//
+// NineAnimator is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// NineAnimator is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with NineAnimator. If not, see .
+//
+
+import AVKit
+import Kingfisher
+import NineAnimatorCommon
+import NineAnimatorNativeListServices
+import SwiftUI
+
+@available(iOS 14.0, *)
+struct ImageSearchResultsScene: View {
+ @Binding var searchResults: [TraceMoe.TraceMoeSearchResult]
+
+ var body: some View {
+ ScrollView {
+ AdaptiveStack(horizontalAlignment: .leading, verticalAlignment: .top, verticalSpacing: 15) {
+ TopResultView(result: searchResults[0])
+ if searchResults.count > 1 {
+ VStack(alignment: .leading) {
+ Text("Similar Results")
+ .font(.title2)
+ .fontWeight(.bold)
+ ForEach(searchResults[1...], id: \.video) {
+ searchResult in
+ SimilarAnimeView(result: searchResult)
+ }
+ }
+ }
+ }
+ .padding([.bottom, .horizontal])
+ }
+ .navigationTitle("Search Results")
+ // Fixes SwiftUI Bug With Navigation bar and ScrollView Interactions
+ // https://stackoverflow.com/a/64281045
+ .padding(.top, 1)
+ }
+}
+
+@available(iOS 14.0, *)
+private extension ImageSearchResultsScene {
+ struct TopResultView: View {
+ let result: TraceMoe.TraceMoeSearchResult
+ @State private var imageRequestTask: NineAnimatorAsyncTask?
+ @State private var imageURL: URL?
+ @State var shouldPlayPreview = true
+ @ScaledMetric private var imageHeight: CGFloat = 150
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ LoopingVideoPlayer(videoURL: result.video, isPlaying: $shouldPlayPreview)
+ .cornerRadius(8)
+ .aspectRatio(16/9, contentMode: .fit)
+ .frame(maxWidth: .infinity)
+ Button { openAnilistView() } label: {
+ HStack(alignment: .top) {
+ if let coverURL = imageURL {
+ KFImage(coverURL)
+ .fade(duration: 0.5)
+ .resizable()
+ .aspectRatio(2/3, contentMode: .fit)
+ .frame(height: imageHeight)
+ .cornerRadius(8)
+ } else {
+ ProgressView()
+ .aspectRatio(2/3, contentMode: .fit)
+ .frame(height: imageHeight)
+ .foregroundColor(.clear)
+ }
+ VStack(alignment: .leading) {
+ AnimeTitlesView(result: result)
+ AnimeDetailedInfoView(result: result)
+ }
+ }
+ }
+ // Prevent button from changing text colour to accent colour
+ .foregroundColor(.primary)
+ }
+ .onLoad {
+ retrieveAnilistInfo()
+ }
+ .onDisappear { shouldPlayPreview = false }
+ .onAppear { shouldPlayPreview = true }
+ }
+
+ func retrieveAnilistInfo() {
+ let anilist = NineAnimator.default.service(type: Anilist.self)
+ let listingReference = ListingAnimeReference(
+ anilist,
+ name: result.anilist.title.romaji ?? "Unknown Title",
+ identifier: String(result.anilist.id)
+ )
+ imageRequestTask = anilist.listingAnime(from: listingReference)
+ .error { _ in imageURL = NineAnimator.placeholderArtworkUrl }
+ .finally {
+ animeInfo in
+ imageURL = animeInfo.artwork
+ }
+ }
+
+ func openAnilistView() {
+ let anilist = NineAnimator.default.service(type: Anilist.self)
+ let listingReference = ListingAnimeReference(
+ anilist,
+ name: result.anilist.title.romaji ?? "Unknown Title",
+ identifier: String(result.anilist.id),
+ artwork: imageURL
+ )
+
+ RootViewController.shared?.open(
+ immedietly: .listingReference(listingReference),
+ method: .inCurrentlyDisplayedTab
+ )
+ }
+ }
+
+ struct SimilarAnimeView: View {
+ let result: TraceMoe.TraceMoeSearchResult
+ @State var shouldPlayPreview = true
+
+ var body: some View {
+ Button { openAnilistView() } label: {
+ HStack {
+ VStack(alignment: .leading) {
+ Text(
+ result.anilist.title.english ??
+ result.anilist.title.native ??
+ "No Title")
+ .font(.title3)
+ .lineLimit(2)
+ AnimeDetailedInfoView(result: result)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ LoopingVideoPlayer(videoURL: result.video, isPlaying: $shouldPlayPreview)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ }
+ // Prevent button from changing text colour to accent colour
+ .foregroundColor(.primary)
+ .onDisappear { shouldPlayPreview = false }
+ .onAppear { shouldPlayPreview = true }
+ }
+
+ func openAnilistView() {
+ let anilist = NineAnimator.default.service(type: Anilist.self)
+ let listingReference = ListingAnimeReference(
+ anilist,
+ name: result.anilist.title.romaji ?? "Unknown Title",
+ identifier: String(result.anilist.id)
+ )
+
+ let link = AnyLink.listingReference(listingReference)
+ RootViewController.shared?.open(
+ immedietly: link,
+ method: .inCurrentlyDisplayedTab
+ )
+ }
+ }
+
+ struct AnimeTitlesView: View {
+ let result: TraceMoe.TraceMoeSearchResult
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text(result.anilist.title.english ?? "No English Title")
+ .font(.title)
+ .lineLimit(2)
+ .padding(0)
+ if let nativeTitle = result.anilist.title.native {
+ Text(nativeTitle)
+ .font(.subheadline)
+ .fontWeight(.light)
+ .lineLimit(1)
+ .foregroundColor(.gray)
+ }
+ if let romaji = result.anilist.title.romaji {
+ Text(romaji)
+ .font(.subheadline)
+ .fontWeight(.light)
+ .lineLimit(1)
+ .foregroundColor(.gray)
+ }
+ }
+ }
+ }
+
+ struct AnimeDetailedInfoView: View {
+ let result: TraceMoe.TraceMoeSearchResult
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ if let ep = result.episode?.value,
+ !ep.isEmpty {
+ Text("Episode: \(ep)")
+ .font(.subheadline)
+ .fontWeight(.light)
+ }
+ Text("Similarity: \(Int(result.similarity * 100))%")
+ .font(.subheadline)
+ .fontWeight(.medium)
+ }
+ }
+ }
+}
diff --git a/NineAnimator/Views/Image Search Scene/ImageSearchResultsView+AVPlayer.swift b/NineAnimator/Views/Image Search Scene/ImageSearchResultsView+AVPlayer.swift
new file mode 100644
index 00000000..1fbc401d
--- /dev/null
+++ b/NineAnimator/Views/Image Search Scene/ImageSearchResultsView+AVPlayer.swift
@@ -0,0 +1,116 @@
+//
+// This file is part of the NineAnimator project.
+//
+// Copyright © 2018-2020 Marcus Zhou. All rights reserved.
+//
+// NineAnimator is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// NineAnimator is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with NineAnimator. If not, see .
+//
+
+import AVKit
+import SwiftUI
+
+@available(iOS 14.0, *)
+extension ImageSearchResultsScene {
+ struct LoopingVideoPlayer: UIViewRepresentable {
+ let videoURL: URL
+ @Binding var isPlaying: Bool
+
+ func updateUIView(_ uiView: LoopingPlayerUIView, context: UIViewRepresentableContext) {
+ uiView.isPlaying = isPlaying
+ }
+
+ func makeUIView(context: Context) -> LoopingPlayerUIView {
+ LoopingPlayerUIView(frame: .zero, url: videoURL)
+ }
+ }
+
+ class LoopingPlayerUIView: UIView {
+ private let playerLayer = AVPlayerLayer()
+ private let loadingIndicator = UIActivityIndicatorView()
+ private var playerLooper: AVPlayerLooper?
+ private var player = AVQueuePlayer()
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ init(frame: CGRect, url: URL) {
+ super.init(frame: frame)
+
+ // Show progress indicator until video has loaded
+ addSubview(loadingIndicator)
+ loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
+ loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
+ loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
+ loadingIndicator.startAnimating()
+
+ // Load the resource
+ let asset = AVAsset(url: url)
+ let item = AVPlayerItem(asset: asset)
+
+ // Setup the player
+ player.isMuted = true
+ player.allowsExternalPlayback = false
+ playerLayer.player = player
+ playerLayer.videoGravity = .resizeAspect
+ layer.addSublayer(playerLayer)
+
+ // Manually pause video playback when app goes to background
+ // This fixes an issue where the playerLayer will become blank
+ let notificationCenter = NotificationCenter.default
+ notificationCenter.addObserver(
+ self,
+ selector: #selector(appMovedToBackground),
+ name: UIApplication.willResignActiveNotification,
+ object: nil
+ )
+ notificationCenter.addObserver(
+ self,
+ selector: #selector(appMovedToForeground),
+ name: UIApplication.didBecomeActiveNotification,
+ object: nil
+ )
+
+ // Create a new player looper with the queue player and template item
+ self.playerLooper = AVPlayerLooper(player: self.player, templateItem: item)
+
+ // Start the video
+ self.player.play()
+ }
+
+ var isPlaying: Bool {
+ get { player.rate == 1 }
+ set {
+ let playRate: Float = newValue ? 1 : 0
+ player.rate = playRate
+ }
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ playerLayer.frame = bounds
+ loadingIndicator.frame = bounds
+ }
+
+ @objc func appMovedToBackground() {
+ player.pause()
+ }
+
+ @objc func appMovedToForeground() {
+ player.seek(to: .zero)
+ player.play()
+ }
+ }
+}
diff --git a/NineAnimator/Views/Image Search Scene/ImageSearchSelectorScene.swift b/NineAnimator/Views/Image Search Scene/ImageSearchSelectorScene.swift
new file mode 100644
index 00000000..5f061ba9
--- /dev/null
+++ b/NineAnimator/Views/Image Search Scene/ImageSearchSelectorScene.swift
@@ -0,0 +1,282 @@
+//
+// This file is part of the NineAnimator project.
+//
+// Copyright © 2018-2020 Marcus Zhou. All rights reserved.
+//
+// NineAnimator is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// NineAnimator is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with NineAnimator. If not, see .
+//
+
+import Kingfisher
+import NineAnimatorCommon
+import NineAnimatorNativeListServices
+import SwiftUI
+
+@available(iOS 14.0, *)
+struct ImageSearchSelectorScene: View {
+ private let traceMoeEngine = TraceMoe()
+ @State private var selectedImage: SelectedImage?
+ @State private var imageUploadState: ImageUploadState = .completed
+
+ @State private var inputTextURL = ""
+
+ @State private var shouldDisplayError = false
+ @State private var shouldDisplayPhotoPicker = false
+
+ @State private var searchResults: [TraceMoe.TraceMoeSearchResult] = []
+ @State private var shouldDisplayResultsView: Bool = false
+
+ var body: some View {
+ VStack {
+ Form {
+ ImagePreview(
+ selectedImage: $selectedImage,
+ imageState: $imageUploadState,
+ shouldDisplayError: $shouldDisplayError)
+ .scaledToFit()
+ .listRowInsets(EdgeInsets())
+ .padding()
+ .frame(minWidth: 0, maxWidth: .infinity, minHeight: 300, maxHeight: 300, alignment: .center)
+ Section(header: Text("ENTER IMAGE LINK")) {
+ HStack {
+ TextField(
+ "Enter URL",
+ text: $inputTextURL,
+ onEditingChanged: { _ in },
+ onCommit: { loadInputURL() })
+ .textFieldStyle(PlainTextFieldStyle())
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ Button("Load URL") { loadInputURL() }
+ .buttonStyle(PlainButtonStyle())
+ .foregroundColor(.accentColor)
+ }
+ }
+ Section(header: Text("UPLOAD IMAGE")) {
+ Button("Select Image From Library") {
+ shouldDisplayPhotoPicker = true
+ }
+ }
+ Button(action: { uploadImage() }, label: {
+ if case .uploading = imageUploadState {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("UPLOAD")
+ .frame(maxWidth: .infinity)
+ }
+ })
+ .disabled(isUploadButtonDisabled)
+ }
+ NavigationLink(
+ destination: ImageSearchResultsScene(searchResults: $searchResults),
+ isActive: $shouldDisplayResultsView) { EmptyView() }
+ }
+ .sheet(
+ isPresented: $shouldDisplayPhotoPicker,
+ onDismiss: {
+ // Displacing error alert after
+ // photo picker is dismissed to
+ // avoid weird animation
+ if case .errored = imageUploadState {
+ shouldDisplayError = true
+ }
+ }, content: {
+ PhotoPicker(
+ isPresented: $shouldDisplayPhotoPicker,
+ selectedImageState: $imageUploadState,
+ selectedImage: $selectedImage
+ )
+ }
+ )
+ .alert(isPresented: $shouldDisplayError) {
+ generateErrorAlert()
+ }
+ .navigationTitle("Image Search")
+ }
+
+ private var isUploadButtonDisabled: Bool {
+ if selectedImage != .none,
+ case .completed = imageUploadState {
+ return false
+ } else { return true }
+ }
+
+ private var uploadButtonColor: Color {
+ isUploadButtonDisabled ? .gray : .accentColor
+ }
+}
+
+// View methods
+@available(iOS 14.0, *)
+private extension ImageSearchSelectorScene {
+ func loadInputURL() {
+ // Dismiss keyboard
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ guard !inputTextURL.isEmpty else { return }
+ // Add https:// prefix for the lazy humans
+ var selectedText = inputTextURL
+ if !selectedText.hasPrefix("https://") {
+ selectedText = "https://\(selectedText)"
+ }
+ guard let URL = URL(string: selectedText) else {
+ imageUploadState = .errored(error: NineAnimatorError.urlError)
+ selectedImage = .none
+ return
+ }
+ imageUploadState = .downloading
+ selectedImage = .remoteURL(URL)
+ }
+
+ func uploadImage() {
+ func handleError(_ error: Error) {
+ Log.error("[ImageSearchSelectorScene] Failed To Upload Image: %@", error)
+ imageUploadState = .errored(error: error)
+ shouldDisplayError = true
+ }
+
+ func handleResult(_ results: [TraceMoe.TraceMoeSearchResult]) {
+ imageUploadState = .completed
+ searchResults = results
+ shouldDisplayResultsView = true
+ }
+
+ switch selectedImage {
+ case .none:
+ Log.error("[ImageSearchSelectorScene] Tried Uploading Without A Selected Image")
+ case let .localImage(localImage):
+ imageUploadState = .uploading(task: traceMoeEngine.search(with: localImage)
+ .error { handleError($0) }
+ .finally { handleResult($0) })
+ case let .remoteURL(url):
+ imageUploadState = .uploading(task: traceMoeEngine.search(with: url)
+ .error { handleError($0) }
+ .finally { handleResult($0) })
+ }
+ }
+
+ func generateErrorAlert() -> Alert {
+ guard case let .errored(error) = imageUploadState else {
+ assertionFailure("Alert displayed without any error!")
+ return Alert(
+ title: Text("Unknown Error"),
+ message: Text("Unknown Error"),
+ dismissButton: .default(Text("OK")) {
+ selectedImage = .none
+ imageUploadState = .completed
+ }
+ )
+ }
+
+ var errorMessage: String
+ if let nineError = error as? NineAnimatorError {
+ errorMessage = nineError.description
+ } else if let kingFisherError = error as? KingfisherError {
+ errorMessage = kingFisherError.shortenedDescription
+ } else {
+ errorMessage = error.localizedDescription
+ }
+
+ return Alert(
+ title: Text("Error"),
+ message: Text(errorMessage),
+ dismissButton: .default(Text("OK")) {
+ selectedImage = .none
+ imageUploadState = .completed
+ }
+ )
+ }
+}
+
+// Internal State
+@available(iOS 14.0, *)
+extension ImageSearchSelectorScene {
+ enum ImageUploadState {
+ /// The selected image has been downloaded/uploaded
+ case completed
+
+ /// The selected image is downloading for previewing
+ case downloading
+
+ /// The selected image is being uploaded to retrieve search results
+ case uploading(task: NineAnimatorAsyncTask)
+
+ /// The selected image has failed to download/upload
+ case errored(error: Error)
+ }
+
+ enum SelectedImage: Hashable {
+ /// Represents an image stored on the user's device
+ case localImage(UIImage)
+
+ /// Represents a URL pointing to an image stored remotely
+ case remoteURL(URL)
+ }
+}
+
+@available(iOS 14.0, *)
+private extension ImageSearchSelectorScene {
+ struct ImagePreview: View {
+ @Binding fileprivate var selectedImage: SelectedImage?
+ @Binding fileprivate var imageState: ImageUploadState
+ @Binding fileprivate var shouldDisplayError: Bool
+
+ var body: some View {
+ switch selectedImage {
+ case .none:
+ // Display Default Placeholder Image
+ Image("NineAnimator Lists Tip")
+ .resizable()
+ case let .localImage(image):
+ Image(uiImage: image)
+ .resizable()
+ case let .remoteURL(url):
+ KFImage(url)
+ .fade(duration: 0.5)
+ .resizable()
+ .placeholder {
+ ProgressView()
+ .scaleEffect(2)
+ }
+ .onSuccess { _ in
+ // SwiftUI will force kingfisher to reload many times 🤦♂️
+ // This guard statement will ensure internal state does not get
+ // updated unless image was actually being requested to download.
+ guard case .downloading = imageState else { return }
+ imageState = .completed
+ }
+ .onFailure { error in
+ guard case .downloading = imageState else { return }
+ Log.error(error)
+ selectedImage = .none
+ imageState = .errored(error: error)
+ shouldDisplayError = true
+ }
+ // Loading image immediately to fix bug
+ // https://github.com/onevcat/Kingfisher/issues/1660
+ .loadImmediately()
+ }
+ }
+ }
+}
+
+@available(iOS 14.0, *)
+struct ImageSelector_Previews: PreviewProvider {
+ static var previews: some View {
+ NavigationView {
+ ImageSearchSelectorScene().preferredColorScheme(.light)
+ }
+ }
+}
diff --git a/NineAnimator/Views/Image Search Scene/ImageSearchSelectorView+PhotoPicker.swift b/NineAnimator/Views/Image Search Scene/ImageSearchSelectorView+PhotoPicker.swift
new file mode 100644
index 00000000..0131fc9f
--- /dev/null
+++ b/NineAnimator/Views/Image Search Scene/ImageSearchSelectorView+PhotoPicker.swift
@@ -0,0 +1,87 @@
+//
+// This file is part of the NineAnimator project.
+//
+// Copyright © 2018-2020 Marcus Zhou. All rights reserved.
+//
+// NineAnimator is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// NineAnimator is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with NineAnimator. If not, see .
+//
+
+import NineAnimatorCommon
+import PhotosUI
+import SwiftUI
+
+@available(iOS 14.0, *)
+extension ImageSearchSelectorScene {
+ struct PhotoPicker: UIViewControllerRepresentable {
+ @Binding var isPresented: Bool
+ @Binding var selectedImageState: ImageSearchSelectorScene.ImageUploadState
+ @Binding var selectedImage: ImageSearchSelectorScene.SelectedImage?
+
+ func makeUIViewController(context: Context) -> PHPickerViewController {
+ var configuration = PHPickerConfiguration()
+ configuration.filter = .images
+ configuration.preferredAssetRepresentationMode = .compatible
+ let controller = PHPickerViewController(configuration: configuration)
+ controller.delegate = context.coordinator
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: PHPickerViewControllerDelegate {
+ private let parent: PhotoPicker
+
+ init(_ parent: PhotoPicker) {
+ self.parent = parent
+ }
+
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ self.parent.isPresented = false
+
+ guard let item = results.first?.itemProvider else { return }
+
+ guard item.canLoadObject(ofClass: UIImage.self) else {
+ Log.error("[PhotoPickerController] Cannot load imported image: %@", item)
+ return self.parent.selectedImageState = .errored(
+ error: NineAnimatorError.unknownError("Unable to import this image: \(item)")
+ )
+ }
+
+ self.parent.selectedImageState = .downloading
+ item.loadObject(ofClass: UIImage.self) {
+ image, error in
+
+ if let error = error {
+ Log.error(error)
+ return self.parent.selectedImageState = .errored(error: error)
+ }
+
+ guard let image = image as? UIImage else {
+ Log.error("[PhotoPickerController] Could not convert item to image")
+ return self.parent.selectedImageState = .errored(
+ error: NineAnimatorError.unknownError("Could not convert item to image")
+ )
+ }
+
+ self.parent.selectedImage = .localImage(image)
+ self.parent.selectedImageState = .completed
+ }
+ }
+ }
+ }
+}