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 + } + } + } + } +}