// // 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) 2025 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 @Router private var router @StateObject private var viewModel: IdentifyItemViewModel // 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) .onReceive(viewModel.events) { events in switch events { case let .error(eventError): error = eventError case .cancelled: break case .updated: router.dismiss() } } .errorMessage($error) .onFirstAppear { isTitleFocused = true } } // MARK: - Content View @ViewBuilder private var contentView: some View { Form { ListTitleSection( viewModel.item.name ?? L10n.unknown, description: viewModel.item.path ) 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) { router.route( to: .identifyItemResults( viewModel: viewModel, result: 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() } }