[iOS] Media Item Menu - Identify Media Item (#1369)
* WIP * All item types. * V2: Functionally better. UI still weird * Rework! * Organization, new LoadingIcon, remove unnecessary components, and standardize: CancellableLoadingButton * Organization & Static Method Re-Use. * wip * fix tvOS * wip * localize * Update RemoteSearchResultRow.swift * Update Localizable.strings * Update RemoteSearchResultRow.swift --------- Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
parent
23beb088da
commit
486995b0cf
|
@ -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<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
EditMetadataView(viewModel: ItemEditorViewModel(item: item))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<BaseItemDto> {
|
||||
Key("itemMetadataDidChange")
|
||||
}
|
||||
|
||||
static var itemShouldRefresh: Key<(itemID: String, parentID: String?)> {
|
||||
/// - Payload: The ID of the item that should refresh
|
||||
static var itemShouldRefreshMetadata: Key<String> {
|
||||
Key("itemShouldRefresh")
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Event, Never>()
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = "<group>"; };
|
||||
4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = "<group>"; };
|
||||
4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemView.swift; sourceTree = "<group>"; };
|
||||
4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyItemViewModel.swift; sourceTree = "<group>"; };
|
||||
4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResult.swift; sourceTree = "<group>"; };
|
||||
4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultRow.swift; sourceTree = "<group>"; };
|
||||
4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultView.swift; sourceTree = "<group>"; };
|
||||
4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = "<group>"; };
|
||||
4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = "<group>"; };
|
||||
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -2245,6 +2257,15 @@
|
|||
path = ServerLogsView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E3766192D2144BA00C5D7A5 /* ItemElements */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E5071E22CFCEFC3003FA2AD /* AddItemElementView */,
|
||||
4E31EFA22CFFFB410053DFE7 /* EditItemElementView */,
|
||||
);
|
||||
path = ItemElements;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
|
@ -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 = "<group>";
|
||||
};
|
||||
4EE766F32D131F6E009658F0 /* IdentifyItemView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EE767062D13401C009658F0 /* Components */,
|
||||
4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */,
|
||||
);
|
||||
path = IdentifyItemView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EE767062D13401C009658F0 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */,
|
||||
4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -22,21 +22,23 @@ struct ListRowButton: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
Button(title) {
|
||||
action()
|
||||
}
|
||||
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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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<AboutViewItem>
|
||||
|
||||
init(viewModel: ItemViewModel) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
var items: OrderedSet<AboutViewItem> = [
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,7 +122,7 @@ struct ItemView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
WrappedView {
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .content:
|
||||
contentView
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
Loading…
Reference in New Issue