diff --git a/Shared/Coordinators/ItemEditorCoordinator.swift b/Shared/Coordinators/ItemEditorCoordinator.swift index 37e698ea..930f6bfe 100644 --- a/Shared/Coordinators/ItemEditorCoordinator.swift +++ b/Shared/Coordinators/ItemEditorCoordinator.swift @@ -21,6 +21,8 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable { // MARK: - Route to Metadata + @Route(.push) + var identifyItem = makeIdentifyItem @Route(.modal) var editMetadata = makeEditMetadata @@ -60,6 +62,11 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable { // MARK: - Item Metadata + @ViewBuilder + func makeIdentifyItem(item: BaseItemDto) -> some View { + IdentifyItemView(item: item) + } + func makeEditMetadata(item: BaseItemDto) -> NavigationViewCoordinator { NavigationViewCoordinator { EditMetadataView(viewModel: ItemEditorViewModel(item: item)) diff --git a/Shared/Extensions/JellyfinAPI/RemoteSearchResult.swift b/Shared/Extensions/JellyfinAPI/RemoteSearchResult.swift new file mode 100644 index 00000000..9ea81b50 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/RemoteSearchResult.swift @@ -0,0 +1,56 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI + +extension RemoteSearchResult: Displayable { + + var displayTitle: String { + name ?? L10n.unknown + } +} + +// TODO: fix in SDK, should already be equatable +extension RemoteSearchResult: @retroactive Hashable, @retroactive Identifiable { + + public var id: Int { + hashValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(albumArtist) + hasher.combine(artists) + hasher.combine(imageURL) + hasher.combine(indexNumber) + hasher.combine(indexNumberEnd) + hasher.combine(name) + hasher.combine(overview) + hasher.combine(parentIndexNumber) + hasher.combine(premiereDate) + hasher.combine(productionYear) + hasher.combine(providerIDs) + hasher.combine(searchProviderName) + } + + public static func == (lhs: RemoteSearchResult, rhs: RemoteSearchResult) -> Bool { + lhs.albumArtist == rhs.albumArtist && + lhs.artists == rhs.artists && + lhs.imageURL == rhs.imageURL && + lhs.indexNumber == rhs.indexNumber && + lhs.indexNumberEnd == rhs.indexNumberEnd && + lhs.name == rhs.name && + lhs.overview == rhs.overview && + lhs.parentIndexNumber == rhs.parentIndexNumber && + lhs.premiereDate == rhs.premiereDate && + lhs.productionYear == rhs.productionYear && + lhs.providerIDs == rhs.providerIDs && + lhs.searchProviderName == rhs.searchProviderName + } +} diff --git a/Shared/Extensions/Optional.swift b/Shared/Extensions/Optional.swift index fff50184..82234f9a 100644 --- a/Shared/Extensions/Optional.swift +++ b/Shared/Extensions/Optional.swift @@ -10,6 +10,10 @@ import Foundation extension Optional where Wrapped: Collection { + var isNilOrEmpty: Bool { + self?.isEmpty ?? true + } + mutating func appendedOrInit(_ element: Wrapped.Element) -> [Wrapped.Element] { if let self { return self + [element] diff --git a/Shared/Extensions/String.swift b/Shared/Extensions/String.swift index a19d8079..cffd79c2 100644 --- a/Shared/Extensions/String.swift +++ b/Shared/Extensions/String.swift @@ -35,6 +35,14 @@ extension String { self + String(element) } + func appending(_ element: @autoclosure () -> String, if condition: Bool) -> String { + if condition { + return self + element() + } else { + return self + } + } + func prepending(_ element: String) -> String { element + self } diff --git a/Shared/Extensions/URL.swift b/Shared/Extensions/URL.swift index 8802c353..e11a938e 100644 --- a/Shared/Extensions/URL.swift +++ b/Shared/Extensions/URL.swift @@ -15,6 +15,14 @@ extension URL: Identifiable { } } +extension URL { + + init?(string: String?) { + guard let string = string else { return nil } + self.init(string: string) + } +} + extension URL { static var documents: URL { diff --git a/Shared/Services/Notifications.swift b/Shared/Services/Notifications.swift index 003287fe..35794ff9 100644 --- a/Shared/Services/Notifications.swift +++ b/Shared/Services/Notifications.swift @@ -124,12 +124,15 @@ extension Notifications.Key { // MARK: - Media Items + // TODO: come up with a cleaner, more defined way for item update notifications + /// - Payload: The new item with updated metadata. static var itemMetadataDidChange: Key { Key("itemMetadataDidChange") } - static var itemShouldRefresh: Key<(itemID: String, parentID: String?)> { + /// - Payload: The ID of the item that should refresh + static var itemShouldRefreshMetadata: Key { Key("itemShouldRefresh") } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 862150a0..9d072679 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -106,6 +106,8 @@ internal enum L10n { internal static let appIcon = L10n.tr("Localizable", "appIcon", fallback: "App Icon") /// Application Name internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "Application Name") + /// Applying media information + internal static let applyingMediaInformation = L10n.tr("Localizable", "applyingMediaInformation", fallback: "Applying media information") /// Arranger internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger") /// Artist @@ -602,6 +604,10 @@ internal enum L10n { internal static let home = L10n.tr("Localizable", "home", fallback: "Home") /// Hours internal static let hours = L10n.tr("Localizable", "hours", fallback: "Hours") + /// ID + internal static let id = L10n.tr("Localizable", "id", fallback: "ID") + /// Identify + internal static let identify = L10n.tr("Localizable", "identify", fallback: "Identify") /// Idle internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle") /// Illustrator @@ -906,6 +912,8 @@ internal enum L10n { internal static let production = L10n.tr("Localizable", "production", fallback: "Production") /// Production Locations internal static let productionLocations = L10n.tr("Localizable", "productionLocations", fallback: "Production Locations") + /// Production Year + internal static let productionYear = L10n.tr("Localizable", "productionYear", fallback: "Production Year") /// Profile Image internal static let profileImage = L10n.tr("Localizable", "profileImage", fallback: "Profile Image") /// Profiles @@ -914,6 +922,8 @@ internal enum L10n { internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs") /// Progress internal static let progress = L10n.tr("Localizable", "progress", fallback: "Progress") + /// Provider + internal static let provider = L10n.tr("Localizable", "provider", fallback: "Provider") /// Public Users internal static let publicUsers = L10n.tr("Localizable", "publicUsers", fallback: "Public Users") /// Quick Connect @@ -1298,6 +1308,8 @@ internal enum L10n { internal static let unsavedChangesMessage = L10n.tr("Localizable", "unsavedChangesMessage", fallback: "You have unsaved changes. Are you sure you want to discard them?") /// URL internal static let url = L10n.tr("Localizable", "url", fallback: "URL") + /// Use as item + internal static let useAsItem = L10n.tr("Localizable", "useAsItem", fallback: "Use as item") /// Use as Transcoding Profile internal static let useAsTranscodingProfile = L10n.tr("Localizable", "useAsTranscodingProfile", fallback: "Use as Transcoding Profile") /// Use Primary Image diff --git a/Shared/ViewModels/ItemAdministration/IdentifyItemViewModel.swift b/Shared/ViewModels/ItemAdministration/IdentifyItemViewModel.swift new file mode 100644 index 00000000..55e3419b --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/IdentifyItemViewModel.swift @@ -0,0 +1,224 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import Get +import JellyfinAPI +import OrderedCollections + +class IdentifyItemViewModel: ViewModel, Stateful, Eventful { + + // MARK: - Events + + enum Event: Equatable { + case updated + case cancelled + case error(JellyfinAPIError) + } + + // MARK: - Actions + + enum Action: Equatable { + case cancel + case search(name: String? = nil, originalTitle: String? = nil, year: Int? = nil) + case update(RemoteSearchResult) + } + + // MARK: - State + + enum State: Hashable { + case content + case searching + case updating + } + + @Published + var item: BaseItemDto + @Published + var searchResults: [RemoteSearchResult] = [] + @Published + var state: State = .content + + private var updateTask: AnyCancellable? + private var searchTask: AnyCancellable? + + private let eventSubject = PassthroughSubject() + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + // MARK: - Initializer + + init(item: BaseItemDto) { + self.item = item + super.init() + } + + // MARK: - Respond to Actions + + func respond(to action: Action) -> State { + switch action { + + case .cancel: + updateTask?.cancel() + searchTask?.cancel() + + return .content + + case let .search(name, originalTitle, year): + searchTask?.cancel() + + searchTask = Task { + do { + let newResults = try await self.searchItem( + name: name, + originalTitle: originalTitle, + year: year + ) + + await MainActor.run { + self.searchResults = newResults + self.state = .content + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .content + self.eventSubject.send(.error(apiError)) + } + } + }.asAnyCancellable() + return .searching + + case let .update(searchResult): + updateTask?.cancel() + + updateTask = Task { + do { + try await updateItem(searchResult) + + await MainActor.run { + self.state = .content + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .content + self.eventSubject.send(.error(apiError)) + } + } + }.asAnyCancellable() + + return .updating + } + } + + // MARK: - Return Matching Elements (To Be Overridden) + + private func searchItem( + name: String?, + originalTitle: String?, + year: Int? + ) async throws -> [RemoteSearchResult] { + + guard let itemID = item.id, let itemType = item.type else { + return [] + } + + switch itemType { + case .boxSet: + let parameters = BoxSetInfoRemoteSearchQuery( + itemID: itemID, + searchInfo: BoxSetInfo( + name: name, + originalTitle: originalTitle, + year: year + ) + ) + let request = Paths.getBoxSetRemoteSearchResults(parameters) + let response = try await userSession.client.send(request) + + return response.value + + case .movie: + let parameters = MovieInfoRemoteSearchQuery( + itemID: itemID, + searchInfo: MovieInfo( + name: name, + originalTitle: originalTitle, + year: year + ) + ) + let request = Paths.getMovieRemoteSearchResults(parameters) + let response = try await userSession.client.send(request) + + return response.value + + case .person: + let parameters = PersonLookupInfoRemoteSearchQuery( + itemID: itemID, + searchInfo: PersonLookupInfo( + name: name, + originalTitle: originalTitle, + year: year + ) + ) + let request = Paths.getPersonRemoteSearchResults(parameters) + let response = try await userSession.client.send(request) + + return response.value + + case .series: + let parameters = SeriesInfoRemoteSearchQuery( + itemID: itemID, + searchInfo: SeriesInfo( + name: name, + originalTitle: originalTitle, + year: year + ) + ) + let request = Paths.getSeriesRemoteSearchResults(parameters) + let response = try await userSession.client.send(request) + + return response.value + + default: + return [] + } + } + + // MARK: - Save Updated Item to Server + + private func updateItem(_ match: RemoteSearchResult) async throws { + guard let itemID = item.id else { return } + + let request = Paths.applySearchCriteria(itemID: itemID, match) + _ = try await userSession.client.send(request) + + try await refreshItem() + } + + // MARK: - Refresh Item + + private func refreshItem() async throws { + guard let itemID = item.id else { return } + + let request = Paths.getItem(userID: userSession.user.id, itemID: itemID) + let response = try await userSession.client.send(request) + + await MainActor.run { + self.item = response.value + Notifications[.itemShouldRefreshMetadata].post(itemID) + } + } +} diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index cebab770..80dc7855 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -14,6 +14,8 @@ import JellyfinAPI import OrderedCollections import UIKit +// TODO: come up with a cleaner, more defined way for item update notifications + class ItemViewModel: ViewModel, Stateful { // MARK: Action @@ -89,10 +91,10 @@ class ItemViewModel: ViewModel, Stateful { self.item = item super.init() - Notifications[.itemShouldRefresh] + Notifications[.itemShouldRefreshMetadata] .publisher - .sink { itemID, parentID in - guard itemID == self.item.id || parentID == self.item.id else { return } + .sink { itemID in + guard itemID == self.item.id else { return } Task { await self.send(.backgroundRefresh) @@ -141,9 +143,16 @@ class ItemViewModel: ViewModel, Stateful { await MainActor.run { self.backgroundStates.remove(.refresh) - self.item = results.fullItem + + // see TODO, as the item will be set in + // itemMetadataDidChange notification but + // is a bit redundant +// self.item = results.fullItem + self.similarItems = results.similarItems self.specialFeatures = results.specialFeatures + + Notifications[.itemMetadataDidChange].post(results.fullItem) } } catch { guard !Task.isCancelled else { return } @@ -332,7 +341,7 @@ class ItemViewModel: ViewModel, Stateful { } let _ = try await userSession.client.send(request) - Notifications[.itemShouldRefresh].post((itemID, nil)) + Notifications[.itemShouldRefreshMetadata].post(itemID) } private func setIsFavorite(_ isFavorite: Bool) async throws { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 38ad6ae0..1a053995 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -216,6 +216,13 @@ 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; + 4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */; }; + 4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; }; + 4EE766F82D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; }; + 4EE766FA2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; }; + 4EE766FB2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; }; + 4EE767082D13403F009658F0 /* RemoteSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */; }; + 4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */; }; 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; }; 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; }; 4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; }; @@ -1339,6 +1346,11 @@ 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = ""; }; + 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemView.swift; sourceTree = ""; }; + 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemViewModel.swift; sourceTree = ""; }; + 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResult.swift; sourceTree = ""; }; + 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultRow.swift; sourceTree = ""; }; + 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultView.swift; sourceTree = ""; }; 4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = ""; }; 4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = ""; }; 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = ""; }; @@ -2245,6 +2257,15 @@ path = ServerLogsView; sourceTree = ""; }; + 4E3766192D2144BA00C5D7A5 /* ItemElements */ = { + isa = PBXGroup; + children = ( + 4E5071E22CFCEFC3003FA2AD /* AddItemElementView */, + 4E31EFA22CFFFB410053DFE7 /* EditItemElementView */, + ); + path = ItemElements; + sourceTree = ""; + }; 4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = { isa = PBXGroup; children = ( @@ -2489,11 +2510,11 @@ 4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = { isa = PBXGroup; children = ( - 4E5071E22CFCEFC3003FA2AD /* AddItemElementView */, 4E8F74A62CE03D4C00CC8969 /* Components */, - 4E31EFA22CFFFB410053DFE7 /* EditItemElementView */, 4E6619FF2CEFE39000025C99 /* EditMetadataView */, + 4EE766F32D131F6E009658F0 /* IdentifyItemView */, 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */, + 4E3766192D2144BA00C5D7A5 /* ItemElements */, ); path = ItemEditorView; sourceTree = ""; @@ -2511,6 +2532,7 @@ children = ( 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */, 4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */, + 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */, 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */, ); path = ItemAdministration; @@ -2768,6 +2790,24 @@ path = EditAccessScheduleView; sourceTree = ""; }; + 4EE766F32D131F6E009658F0 /* IdentifyItemView */ = { + isa = PBXGroup; + children = ( + 4EE767062D13401C009658F0 /* Components */, + 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */, + ); + path = IdentifyItemView; + sourceTree = ""; + }; + 4EE767062D13401C009658F0 /* Components */ = { + isa = PBXGroup; + children = ( + 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */, + 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */, + ); + path = Components; + sourceTree = ""; + }; 4EED87472CBF824B002354D2 /* Components */ = { isa = PBXGroup; children = ( @@ -4389,6 +4429,7 @@ 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, + 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */, 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */, @@ -5170,6 +5211,7 @@ 4E98F7D22D123AD4001E7518 /* NavigationBarMenuButton.swift in Sources */, 4E98F7D32D123AD4001E7518 /* View-tvOS.swift in Sources */, C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */, + 4EE766FB2D132954009658F0 /* RemoteSearchResult.swift in Sources */, C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */, E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, @@ -5261,6 +5303,7 @@ E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, + 4EE766F82D132054009658F0 /* IdentifyItemViewModel.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, @@ -5676,6 +5719,7 @@ 4E661A102CEFE46300025C99 /* TitleSection.swift in Sources */, 4E661A112CEFE46300025C99 /* LockMetadataSection.swift in Sources */, 4E661A122CEFE46300025C99 /* MediaFormatSection.swift in Sources */, + 4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */, 4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */, 4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */, 4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */, @@ -5686,6 +5730,7 @@ E1A3E4CD2BB7D8C8005C59F8 /* Label-iOS.swift in Sources */, E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */, + 4EE767082D13403F009658F0 /* RemoteSearchResultRow.swift in Sources */, E1ED7FD92CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */, E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */, 4EB538C52CE3E25700EB72D5 /* ExternalAccessSection.swift in Sources */, @@ -5709,6 +5754,7 @@ E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, 4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */, + 4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, @@ -6087,6 +6133,7 @@ E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */, 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */, E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, + 4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */, 4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, @@ -6107,6 +6154,7 @@ E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, + 4EE766FA2D132954009658F0 /* RemoteSearchResult.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, diff --git a/Swiftfin/Components/ListRowButton.swift b/Swiftfin/Components/ListRowButton.swift index da110b83..ebd10813 100644 --- a/Swiftfin/Components/ListRowButton.swift +++ b/Swiftfin/Components/ListRowButton.swift @@ -22,21 +22,23 @@ struct ListRowButton: View { } var body: some View { - Button(title) { - action() - } - .font(.body.weight(.bold)) - .buttonStyle(ListRowButtonStyle()) - .listRowInsets(.init(.zero)) + Button(title, action: action) + .font(.body.weight(.bold)) + .buttonStyle(ListRowButtonStyle()) + .listRowInsets(.init(.zero)) } } +// TODO: implement `role` private struct ListRowButtonStyle: ButtonStyle { + @Environment(\.isEnabled) + private var isEnabled + func makeBody(configuration: Configuration) -> some View { ZStack { Rectangle() - .foregroundStyle(.secondary) + .foregroundStyle(isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray)) configuration.label .foregroundStyle(.primary) diff --git a/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift new file mode 100644 index 00000000..11edd466 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift @@ -0,0 +1,56 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import JellyfinAPI +import SwiftUI + +extension IdentifyItemView { + + struct RemoteSearchResultRow: View { + + // MARK: - Remote Search Result Variable + + let result: RemoteSearchResult + + // MARK: - Remote Search Result Action + + let onSelect: () -> Void + + // MARK: - Result Title + + private var resultTitle: String { + result.displayTitle + .appending(" (\(result.premiereDate!.formatted(.dateTime.year())))", if: result.premiereDate != nil) + } + + // MARK: - Body + + var body: some View { + ListRow { + IdentifyItemView.resultImage(URL(string: result.imageURL)) + .frame(width: 60) + } content: { + VStack(alignment: .leading) { + Text(resultTitle) + .font(.headline) + .foregroundStyle(.primary) + + if let overview = result.overview { + Text(overview) + .lineLimit(3) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .onSelect(perform: onSelect) + .isSeparatorVisible(false) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift new file mode 100644 index 00000000..a08c49a1 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift @@ -0,0 +1,107 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension IdentifyItemView { + + struct RemoteSearchResultView: View { + + // MARK: - Item Info Variables + + let result: RemoteSearchResult + + // MARK: - Item Info Actions + + let onSave: () -> Void + let onClose: () -> Void + + // MARK: - Body + + @ViewBuilder + private var header: some View { + Section { + HStack(alignment: .bottom, spacing: 12) { + IdentifyItemView.resultImage(URL(string: result.imageURL)) + .frame(width: 100) + .accessibilityIgnoresInvertColors() + + Text(result.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + .padding(.bottom) + } + } + .listRowBackground(Color.clear) + .listRowCornerRadius(0) + .listRowInsets(.zero) + } + + @ViewBuilder + private var resultDetails: some View { + Section(L10n.details) { + + if let premiereDate = result.premiereDate { + TextPairView( + L10n.premiereDate, + value: Text(premiereDate.formatted(.dateTime.year().month().day())) + ) + } + + if let productionYear = result.productionYear { + TextPairView( + L10n.productionYear, + value: Text(productionYear, format: .number.grouping(.never)) + ) + } + + if let provider = result.searchProviderName { + TextPairView( + leading: L10n.provider, + trailing: provider + ) + } + + if let providerID = result.providerIDs?.values.first { + TextPairView( + leading: L10n.id, + trailing: providerID + ) + } + } + + if let overview = result.overview { + Section(L10n.overview) { + Text(overview) + } + } + } + + var body: some View { + NavigationView { + List { + header + + resultDetails + } + .navigationTitle(L10n.identify) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + onClose() + } + .topBarTrailing { + Button(L10n.save, action: onSave) + .buttonStyle(.toolbarPill) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift b/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift new file mode 100644 index 00000000..3f61017d --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift @@ -0,0 +1,191 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct IdentifyItemView: View { + + private struct SearchFields: Equatable { + var name: String? + var originalTitle: String? + var year: Int? + + var isEmpty: Bool { + name.isNilOrEmpty && + originalTitle.isNilOrEmpty && + year == nil + } + } + + @Default(.accentColor) + private var accentColor + + @FocusState + private var isTitleFocused: Bool + + // MARK: - Observed & Environment Objects + + @EnvironmentObject + private var router: ItemEditorCoordinator.Router + + @StateObject + private var viewModel: IdentifyItemViewModel + + // MARK: - Identity Variables + + @State + private var selectedResult: RemoteSearchResult? + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Lookup States + + @State + private var search = SearchFields() + + // MARK: - Initializer + + init(item: BaseItemDto) { + self._viewModel = StateObject(wrappedValue: IdentifyItemViewModel(item: item)) + } + + // MARK: - Body + + var body: some View { + Group { + switch viewModel.state { + case .content, .searching: + contentView + case .updating: + ProgressView() + } + } + .navigationTitle(L10n.identify) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(viewModel.state == .updating) + .sheet(item: $selectedResult) { result in + RemoteSearchResultView(result: result) { + selectedResult = nil + viewModel.send(.update(result)) + } onClose: { + selectedResult = nil + } + } + .onReceive(viewModel.events) { events in + switch events { + case let .error(eventError): + error = eventError + case .cancelled: + selectedResult = nil + case .updated: + router.pop() + } + } + .errorMessage($error) + .onFirstAppear { + isTitleFocused = true + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + Form { + searchView + + resultsView + } + } + + // MARK: - Search View + + @ViewBuilder + private var searchView: some View { + Section(L10n.search) { + TextField( + L10n.title, + text: $search.name.coalesce("") + ) + .focused($isTitleFocused) + + TextField( + L10n.originalTitle, + text: $search.originalTitle.coalesce("") + ) + + TextField( + L10n.year, + text: $search.year + .map( + getter: { $0 == nil ? "" : "\($0!)" }, + setter: { Int($0) } + ) + ) + .keyboardType(.numberPad) + } + + if viewModel.state == .searching { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.search) { + viewModel.send(.search( + name: search.name, + originalTitle: search.originalTitle, + year: search.year + )) + } + .disabled(search.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + } + } + + // MARK: - Results View + + @ViewBuilder + private var resultsView: some View { + if viewModel.searchResults.isNotEmpty { + Section(L10n.items) { + ForEach(viewModel.searchResults) { result in + RemoteSearchResultRow(result: result) { + selectedResult = result + } + } + } + } + } + + // MARK: - Result Image + + @ViewBuilder + static func resultImage(_ url: URL?) -> some View { + ZStack { + Color.clear + + ImageView(url) + .failure { + Image(systemName: "questionmark") + .foregroundStyle(.primary) + } + } + .posterStyle(.portrait) + .posterShadow() + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift index f926dd57..01cd7ee7 100644 --- a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift @@ -98,6 +98,12 @@ struct ItemEditorView: View { @ViewBuilder private var editView: some View { Section(L10n.edit) { + if [.boxSet, .movie, .person, .series].contains(viewModel.item.type) { + ChevronButton(L10n.identify) + .onSelect { + router.route(to: \.identifyItem, viewModel.item) + } + } ChevronButton(L10n.metadata) .onSelect { router.route(to: \.editMetadata, viewModel.item) diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift b/Swiftfin/Views/ItemEditorView/ItemElements/AddItemElementView/AddItemElementView.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift rename to Swiftfin/Views/ItemEditorView/ItemElements/AddItemElementView/AddItemElementView.swift diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift b/Swiftfin/Views/ItemEditorView/ItemElements/AddItemElementView/Components/NameInput.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift rename to Swiftfin/Views/ItemEditorView/ItemElements/AddItemElementView/Components/NameInput.swift diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift b/Swiftfin/Views/ItemEditorView/ItemElements/AddItemElementView/Components/SearchResultsSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift rename to Swiftfin/Views/ItemEditorView/ItemElements/AddItemElementView/Components/SearchResultsSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift b/Swiftfin/Views/ItemEditorView/ItemElements/EditItemElementView/Components/EditItemElementRow.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift rename to Swiftfin/Views/ItemEditorView/ItemElements/EditItemElementView/Components/EditItemElementRow.swift diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift b/Swiftfin/Views/ItemEditorView/ItemElements/EditItemElementView/EditItemElementView.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift rename to Swiftfin/Views/ItemEditorView/ItemElements/EditItemElementView/EditItemElementView.swift diff --git a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift index f3bfcb06..78ba39dc 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift @@ -8,8 +8,8 @@ import CollectionHStack import Defaults +import IdentifiedCollections import JellyfinAPI -import OrderedCollections import SwiftUI // TODO: rename `AboutItemView` @@ -22,8 +22,7 @@ extension ItemView { struct AboutView: View { - private enum AboutViewItem: Hashable, Identifiable { - + private enum AboutViewItem: Identifiable { case image case overview case mediaSource(MediaSourceInfo) @@ -43,21 +42,14 @@ extension ItemView { } } - @Default(.accentColor) - private var accentColor - @ObservedObject var viewModel: ItemViewModel @State private var contentSize: CGSize = .zero - @State - private var items: OrderedSet - init(viewModel: ItemViewModel) { - self.viewModel = viewModel - - var items: OrderedSet = [ + private var items: [AboutViewItem] { + var items: [AboutViewItem] = [ .image, .overview, ] @@ -70,7 +62,11 @@ extension ItemView { items.append(.ratings) } - self._items = State(initialValue: items) + return items + } + + init(viewModel: ItemViewModel) { + self.viewModel = viewModel } // TODO: break out into a general solution for general use? @@ -161,6 +157,7 @@ extension ItemView { .scrollBehavior(.continuousLeadingEdge) } .trackingSize($contentSize) + .id(viewModel.item.hashValue) } } } diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 78aaf8b4..56416934 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -122,7 +122,7 @@ struct ItemView: View { } var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: contentView diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 4ca18cb6..e6848816 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -7,9 +7,6 @@ /// Accent Color "accentColor" = "Accent Color"; -/// Some views may need an app restart to update. -"accentColorDescription" = "Some views may need an app restart to update."; - /// Access "access" = "Access"; @@ -847,6 +844,12 @@ /// Hours "hours" = "Hours"; +/// ID +"id" = "ID"; + +/// Identify +"identify" = "Identify"; + /// Idle "idle" = "Idle"; @@ -1291,6 +1294,9 @@ /// Production Locations "productionLocations" = "Production Locations"; +/// Production Year +"productionYear" = "Production Year"; + /// Profile Image "profileImage" = "Profile Image"; @@ -1303,6 +1309,9 @@ /// Progress "progress" = "Progress"; +/// Provider +"provider" = "Provider"; + /// Public Users "publicUsers" = "Public Users";