[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
|
// 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))
|
||||||
|
|
|
@ -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 {
|
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]
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 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 {
|
||||||
|
|
|
@ -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 */,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
@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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue