[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:
Joe Kribs 2024-12-31 14:16:34 -07:00 committed by GitHub
parent 23beb088da
commit 486995b0cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 779 additions and 32 deletions

View File

@ -21,6 +21,8 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
// MARK: - Route to Metadata // MARK: - Route to Metadata
@Route(.push)
var identifyItem = makeIdentifyItem
@Route(.modal) @Route(.modal)
var editMetadata = makeEditMetadata var editMetadata = makeEditMetadata
@ -60,6 +62,11 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
// MARK: - Item Metadata // MARK: - Item Metadata
@ViewBuilder
func makeIdentifyItem(item: BaseItemDto) -> some View {
IdentifyItemView(item: item)
}
func makeEditMetadata(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { func makeEditMetadata(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator { NavigationViewCoordinator {
EditMetadataView(viewModel: ItemEditorViewModel(item: item)) EditMetadataView(viewModel: ItemEditorViewModel(item: item))

View File

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

View File

@ -10,6 +10,10 @@ import Foundation
extension Optional where Wrapped: Collection { extension Optional where Wrapped: Collection {
var isNilOrEmpty: Bool {
self?.isEmpty ?? true
}
mutating func appendedOrInit(_ element: Wrapped.Element) -> [Wrapped.Element] { mutating func appendedOrInit(_ element: Wrapped.Element) -> [Wrapped.Element] {
if let self { if let self {
return self + [element] return self + [element]

View File

@ -35,6 +35,14 @@ extension String {
self + String(element) 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 { func prepending(_ element: String) -> String {
element + self element + self
} }

View File

@ -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 { extension URL {
static var documents: URL { static var documents: URL {

View File

@ -124,12 +124,15 @@ extension Notifications.Key {
// MARK: - Media Items // MARK: - Media Items
// TODO: come up with a cleaner, more defined way for item update notifications
/// - Payload: The new item with updated metadata. /// - Payload: The new item with updated metadata.
static var itemMetadataDidChange: Key<BaseItemDto> { static var itemMetadataDidChange: Key<BaseItemDto> {
Key("itemMetadataDidChange") 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") Key("itemShouldRefresh")
} }

View File

@ -106,6 +106,8 @@ internal enum L10n {
internal static let appIcon = L10n.tr("Localizable", "appIcon", fallback: "App Icon") internal static let appIcon = L10n.tr("Localizable", "appIcon", fallback: "App Icon")
/// Application Name /// Application Name
internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "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 /// Arranger
internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger") internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger")
/// Artist /// Artist
@ -602,6 +604,10 @@ internal enum L10n {
internal static let home = L10n.tr("Localizable", "home", fallback: "Home") internal static let home = L10n.tr("Localizable", "home", fallback: "Home")
/// Hours /// Hours
internal static let hours = L10n.tr("Localizable", "hours", fallback: "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 /// Idle
internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle") internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle")
/// Illustrator /// Illustrator
@ -906,6 +912,8 @@ internal enum L10n {
internal static let production = L10n.tr("Localizable", "production", fallback: "Production") internal static let production = L10n.tr("Localizable", "production", fallback: "Production")
/// Production Locations /// Production Locations
internal static let productionLocations = L10n.tr("Localizable", "productionLocations", fallback: "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 /// Profile Image
internal static let profileImage = L10n.tr("Localizable", "profileImage", fallback: "Profile Image") internal static let profileImage = L10n.tr("Localizable", "profileImage", fallback: "Profile Image")
/// Profiles /// Profiles
@ -914,6 +922,8 @@ internal enum L10n {
internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs") internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs")
/// Progress /// Progress
internal static let progress = L10n.tr("Localizable", "progress", fallback: "Progress") internal static let progress = L10n.tr("Localizable", "progress", fallback: "Progress")
/// Provider
internal static let provider = L10n.tr("Localizable", "provider", fallback: "Provider")
/// Public Users /// Public Users
internal static let publicUsers = L10n.tr("Localizable", "publicUsers", fallback: "Public Users") internal static let publicUsers = L10n.tr("Localizable", "publicUsers", fallback: "Public Users")
/// Quick Connect /// 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?") internal static let unsavedChangesMessage = L10n.tr("Localizable", "unsavedChangesMessage", fallback: "You have unsaved changes. Are you sure you want to discard them?")
/// URL /// URL
internal static let url = L10n.tr("Localizable", "url", fallback: "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 /// Use as Transcoding Profile
internal static let useAsTranscodingProfile = L10n.tr("Localizable", "useAsTranscodingProfile", fallback: "Use as Transcoding Profile") internal static let useAsTranscodingProfile = L10n.tr("Localizable", "useAsTranscodingProfile", fallback: "Use as Transcoding Profile")
/// Use Primary Image /// Use Primary Image

View File

@ -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)
}
}
}

View File

@ -14,6 +14,8 @@ import JellyfinAPI
import OrderedCollections import OrderedCollections
import UIKit import UIKit
// TODO: come up with a cleaner, more defined way for item update notifications
class ItemViewModel: ViewModel, Stateful { class ItemViewModel: ViewModel, Stateful {
// MARK: Action // MARK: Action
@ -89,10 +91,10 @@ class ItemViewModel: ViewModel, Stateful {
self.item = item self.item = item
super.init() super.init()
Notifications[.itemShouldRefresh] Notifications[.itemShouldRefreshMetadata]
.publisher .publisher
.sink { itemID, parentID in .sink { itemID in
guard itemID == self.item.id || parentID == self.item.id else { return } guard itemID == self.item.id else { return }
Task { Task {
await self.send(.backgroundRefresh) await self.send(.backgroundRefresh)
@ -141,9 +143,16 @@ class ItemViewModel: ViewModel, Stateful {
await MainActor.run { await MainActor.run {
self.backgroundStates.remove(.refresh) 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.similarItems = results.similarItems
self.specialFeatures = results.specialFeatures self.specialFeatures = results.specialFeatures
Notifications[.itemMetadataDidChange].post(results.fullItem)
} }
} catch { } catch {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
@ -332,7 +341,7 @@ class ItemViewModel: ViewModel, Stateful {
} }
let _ = try await userSession.client.send(request) let _ = try await userSession.client.send(request)
Notifications[.itemShouldRefresh].post((itemID, nil)) Notifications[.itemShouldRefreshMetadata].post(itemID)
} }
private func setIsFavorite(_ isFavorite: Bool) async throws { private func setIsFavorite(_ isFavorite: Bool) async throws {

View File

@ -216,6 +216,13 @@
4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; };
4EE07CBC2D08B19700B0B636 /* 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 */; }; 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 */; }; 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; };
4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; }; 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; };
4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = "<group>"; };
@ -2245,6 +2257,15 @@
path = ServerLogsView; path = ServerLogsView;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4E3766192D2144BA00C5D7A5 /* ItemElements */ = {
isa = PBXGroup;
children = (
4E5071E22CFCEFC3003FA2AD /* AddItemElementView */,
4E31EFA22CFFFB410053DFE7 /* EditItemElementView */,
);
path = ItemElements;
sourceTree = "<group>";
};
4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = { 4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2489,11 +2510,11 @@
4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = { 4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4E5071E22CFCEFC3003FA2AD /* AddItemElementView */,
4E8F74A62CE03D4C00CC8969 /* Components */, 4E8F74A62CE03D4C00CC8969 /* Components */,
4E31EFA22CFFFB410053DFE7 /* EditItemElementView */,
4E6619FF2CEFE39000025C99 /* EditMetadataView */, 4E6619FF2CEFE39000025C99 /* EditMetadataView */,
4EE766F32D131F6E009658F0 /* IdentifyItemView */,
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */, 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */,
4E3766192D2144BA00C5D7A5 /* ItemElements */,
); );
path = ItemEditorView; path = ItemEditorView;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2511,6 +2532,7 @@
children = ( children = (
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */, 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */,
4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */, 4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */,
4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */,
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */, 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */,
); );
path = ItemAdministration; path = ItemAdministration;
@ -2768,6 +2790,24 @@
path = EditAccessScheduleView; path = EditAccessScheduleView;
sourceTree = "<group>"; 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 */ = { 4EED87472CBF824B002354D2 /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -4389,6 +4429,7 @@
4EFE0C7C2D0156A500D4834D /* PersonKind.swift */, 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */,
E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */,
4E2182E42CAF67EF0094806B /* PlayMethod.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */,
4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */,
4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */,
4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */,
4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */, 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */,
@ -5170,6 +5211,7 @@
4E98F7D22D123AD4001E7518 /* NavigationBarMenuButton.swift in Sources */, 4E98F7D22D123AD4001E7518 /* NavigationBarMenuButton.swift in Sources */,
4E98F7D32D123AD4001E7518 /* View-tvOS.swift in Sources */, 4E98F7D32D123AD4001E7518 /* View-tvOS.swift in Sources */,
C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */, C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */,
4EE766FB2D132954009658F0 /* RemoteSearchResult.swift in Sources */,
C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */, C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */,
E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */,
E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */,
@ -5261,6 +5303,7 @@
E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */,
E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */,
E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */,
4EE766F82D132054009658F0 /* IdentifyItemViewModel.swift in Sources */,
E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */,
E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */,
BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */,
@ -5676,6 +5719,7 @@
4E661A102CEFE46300025C99 /* TitleSection.swift in Sources */, 4E661A102CEFE46300025C99 /* TitleSection.swift in Sources */,
4E661A112CEFE46300025C99 /* LockMetadataSection.swift in Sources */, 4E661A112CEFE46300025C99 /* LockMetadataSection.swift in Sources */,
4E661A122CEFE46300025C99 /* MediaFormatSection.swift in Sources */, 4E661A122CEFE46300025C99 /* MediaFormatSection.swift in Sources */,
4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */,
4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */, 4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */,
4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */, 4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */,
4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */, 4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */,
@ -5686,6 +5730,7 @@
E1A3E4CD2BB7D8C8005C59F8 /* Label-iOS.swift in Sources */, E1A3E4CD2BB7D8C8005C59F8 /* Label-iOS.swift in Sources */,
E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */, E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */,
4EE767082D13403F009658F0 /* RemoteSearchResultRow.swift in Sources */,
E1ED7FD92CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */, E1ED7FD92CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */,
E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */, E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */,
4EB538C52CE3E25700EB72D5 /* ExternalAccessSection.swift in Sources */, 4EB538C52CE3E25700EB72D5 /* ExternalAccessSection.swift in Sources */,
@ -5709,6 +5754,7 @@
E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */,
E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */,
4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */, 4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */,
4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */,
E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */,
E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */, E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */,
E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */,
@ -6087,6 +6133,7 @@
E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */, E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */,
4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */, 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */,
E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */,
4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */, 4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */,
E18E01F1288747230022598C /* PlayButton.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */,
E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */,
@ -6107,6 +6154,7 @@
E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */,
E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */,
E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */,
4EE766FA2D132954009658F0 /* RemoteSearchResult.swift in Sources */,
E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */,
C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */,
E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */,

View File

@ -22,21 +22,23 @@ struct ListRowButton: View {
} }
var body: some View { var body: some View {
Button(title) { Button(title, action: action)
action() .font(.body.weight(.bold))
} .buttonStyle(ListRowButtonStyle())
.font(.body.weight(.bold)) .listRowInsets(.init(.zero))
.buttonStyle(ListRowButtonStyle())
.listRowInsets(.init(.zero))
} }
} }
// TODO: implement `role`
private struct ListRowButtonStyle: ButtonStyle { private struct ListRowButtonStyle: ButtonStyle {
@Environment(\.isEnabled)
private var isEnabled
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
ZStack { ZStack {
Rectangle() Rectangle()
.foregroundStyle(.secondary) .foregroundStyle(isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray))
configuration.label configuration.label
.foregroundStyle(.primary) .foregroundStyle(.primary)

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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()
}
}

View File

@ -98,6 +98,12 @@ struct ItemEditorView: View {
@ViewBuilder @ViewBuilder
private var editView: some View { private var editView: some View {
Section(L10n.edit) { 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) ChevronButton(L10n.metadata)
.onSelect { .onSelect {
router.route(to: \.editMetadata, viewModel.item) router.route(to: \.editMetadata, viewModel.item)

View File

@ -8,8 +8,8 @@
import CollectionHStack import CollectionHStack
import Defaults import Defaults
import IdentifiedCollections
import JellyfinAPI import JellyfinAPI
import OrderedCollections
import SwiftUI import SwiftUI
// TODO: rename `AboutItemView` // TODO: rename `AboutItemView`
@ -22,8 +22,7 @@ extension ItemView {
struct AboutView: View { struct AboutView: View {
private enum AboutViewItem: Hashable, Identifiable { private enum AboutViewItem: Identifiable {
case image case image
case overview case overview
case mediaSource(MediaSourceInfo) case mediaSource(MediaSourceInfo)
@ -43,21 +42,14 @@ extension ItemView {
} }
} }
@Default(.accentColor)
private var accentColor
@ObservedObject @ObservedObject
var viewModel: ItemViewModel var viewModel: ItemViewModel
@State @State
private var contentSize: CGSize = .zero private var contentSize: CGSize = .zero
@State
private var items: OrderedSet<AboutViewItem>
init(viewModel: ItemViewModel) { private var items: [AboutViewItem] {
self.viewModel = viewModel var items: [AboutViewItem] = [
var items: OrderedSet<AboutViewItem> = [
.image, .image,
.overview, .overview,
] ]
@ -70,7 +62,11 @@ extension ItemView {
items.append(.ratings) 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? // TODO: break out into a general solution for general use?
@ -161,6 +157,7 @@ extension ItemView {
.scrollBehavior(.continuousLeadingEdge) .scrollBehavior(.continuousLeadingEdge)
} }
.trackingSize($contentSize) .trackingSize($contentSize)
.id(viewModel.item.hashValue)
} }
} }
} }

View File

@ -122,7 +122,7 @@ struct ItemView: View {
} }
var body: some View { var body: some View {
WrappedView { ZStack {
switch viewModel.state { switch viewModel.state {
case .content: case .content:
contentView contentView

View File

@ -7,9 +7,6 @@
/// Accent Color /// Accent Color
"accentColor" = "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" = "Access"; "access" = "Access";
@ -847,6 +844,12 @@
/// Hours /// Hours
"hours" = "Hours"; "hours" = "Hours";
/// ID
"id" = "ID";
/// Identify
"identify" = "Identify";
/// Idle /// Idle
"idle" = "Idle"; "idle" = "Idle";
@ -1291,6 +1294,9 @@
/// Production Locations /// Production Locations
"productionLocations" = "Production Locations"; "productionLocations" = "Production Locations";
/// Production Year
"productionYear" = "Production Year";
/// Profile Image /// Profile Image
"profileImage" = "Profile Image"; "profileImage" = "Profile Image";
@ -1303,6 +1309,9 @@
/// Progress /// Progress
"progress" = "Progress"; "progress" = "Progress";
/// Provider
"provider" = "Provider";
/// Public Users /// Public Users
"publicUsers" = "Public Users"; "publicUsers" = "Public Users";