[tvOS] Media Item Menu - Refresh / Delete Items (#1348)

* Mirror tvOS to iOS

* Fix router dismiss. Remove redundent viewModel.refresh from itemView

* reset dev team info

* View Modifier and ViewModel cleanup

* Remove testing comments / events

* Cleanup `.errorMessage($error)`

* Cleanup all viewModel.states for item editing, add errorViews if the data fails to load, and add errorMessage on failed events.

MARK sections: Var/Func always unless only Body and Var/Lets only if there are several of varying types / functions.
This commit is contained in:
Joe Kribs 2024-12-10 13:37:22 -07:00 committed by GitHub
parent bbfa944b52
commit 548d35b19e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 680 additions and 245 deletions

View File

@ -0,0 +1,35 @@
//
// 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 SwiftUI
struct ErrorMessageModifier: ViewModifier {
@Binding
var error: Error?
let dismissActions: (() -> Void)?
// MARK: - Body
func body(content: Content) -> some View {
content
.alert(
L10n.error.text,
isPresented: .constant(error != nil),
presenting: error
) { _ in
Button(L10n.dismiss, role: .cancel) {
error = nil
dismissActions?()
}
} message: { error in
Text(error.localizedDescription)
}
}
}

View File

@ -148,6 +148,15 @@ extension View {
modifier(BottomEdgeGradientModifier(bottomColor: bottomColor)) modifier(BottomEdgeGradientModifier(bottomColor: bottomColor))
} }
/// Error Message Alert
func errorMessage(
_ error: Binding<Error?>,
dismissActions: (() -> Void)? = nil
) -> some View {
modifier(ErrorMessageModifier(error: error, dismissActions: dismissActions))
}
/// Apply a corner radius as a ratio of a view's side
func posterShadow() -> some View { func posterShadow() -> some View {
shadow(radius: 4, y: 2) shadow(radius: 4, y: 2)
} }

View File

@ -12,61 +12,57 @@ import JellyfinAPI
class DeleteItemViewModel: ViewModel, Stateful, Eventful { class DeleteItemViewModel: ViewModel, Stateful, Eventful {
// MARK: Events // MARK: - Events
enum Event: Equatable { enum Event: Equatable {
case error(JellyfinAPIError)
case deleted case deleted
case error(JellyfinAPIError)
} }
// MARK: Action // MARK: - Action
enum Action: Equatable { enum Action: Equatable {
case error(JellyfinAPIError)
case delete case delete
} }
// MARK: State // MARK: - State
enum State: Hashable { enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial case initial
case refreshing case error(JellyfinAPIError)
} }
@Published
var item: BaseItemDto?
@Published @Published
final var state: State = .initial final var state: State = .initial
private var deleteTask: AnyCancellable? // MARK: - Published Item
@Published
var item: BaseItemDto?
// MARK: Event Variables // MARK: Event Variables
private var deleteTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init() private var eventSubject: PassthroughSubject<Event, Never> = .init()
var events: AnyPublisher<Event, Never> { var events: AnyPublisher<Event, Never> {
eventSubject eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher() .eraseToAnyPublisher()
// Causes issues with the Deleted Event unless this is removed
// .receive(on: RunLoop.main)
} }
// MARK: Init // MARK: - Initializer
init(item: BaseItemDto) { init(item: BaseItemDto) {
self.item = item self.item = item
super.init() super.init()
} }
// MARK: Respond // MARK: - Respond
func respond(to action: Action) -> State { func respond(to action: Action) -> State {
switch action { switch action {
case let .error(error):
return .error(error)
case .delete: case .delete:
deleteTask?.cancel() deleteTask?.cancel()
@ -75,12 +71,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
do { do {
try await self.deleteItem() try await self.deleteItem()
await MainActor.run { await MainActor.run {
self.state = .content self.state = .initial
self.eventSubject.send(.deleted) self.eventSubject.send(.deleted)
} }
} catch { } catch {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
self.state = .error(JellyfinAPIError(error.localizedDescription)) self.state = .error(JellyfinAPIError(error.localizedDescription))
self.eventSubject.send(.error(JellyfinAPIError(error.localizedDescription))) self.eventSubject.send(.error(JellyfinAPIError(error.localizedDescription)))
@ -89,11 +84,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
} }
.asAnyCancellable() .asAnyCancellable()
return .refreshing return .initial
} }
} }
// MARK: Metadata Refresh Logic // MARK: - Item Deletion Logic
private func deleteItem() async throws { private func deleteItem() async throws {
guard let item, let itemID = item.id else { guard let item, let itemID = item.id else {

View File

@ -12,17 +12,15 @@ import JellyfinAPI
class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
// MARK: Events // MARK: - Events
enum Event: Equatable { enum Event: Equatable {
case error(JellyfinAPIError) case error(JellyfinAPIError)
case refreshTriggered
} }
// MARK: Action // MARK: - Action
enum Action: Equatable { enum Action: Equatable {
case error(JellyfinAPIError)
case refreshMetadata( case refreshMetadata(
metadataRefreshMode: MetadataRefreshMode, metadataRefreshMode: MetadataRefreshMode,
imageRefreshMode: MetadataRefreshMode, imageRefreshMode: MetadataRefreshMode,
@ -31,25 +29,25 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
) )
} }
// MARK: State // MARK: States
enum State: Hashable { enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial case initial
case refreshing case refreshing
} }
// A spoof progress, since there isn't a
// single item metadata refresh task
@Published
private(set) var progress: Double = 0.0
@Published
private var item: BaseItemDto
@Published @Published
final var state: State = .initial final var state: State = .initial
// MARK: - Published Items
@Published
private(set) var progress: Double = 0.0
@Published
private var item: BaseItemDto
// MARK: - Event Objects
private var itemTask: AnyCancellable? private var itemTask: AnyCancellable?
private var eventSubject = PassthroughSubject<Event, Never>() private var eventSubject = PassthroughSubject<Event, Never>()
@ -59,21 +57,17 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
// MARK: Init // MARK: - Init
init(item: BaseItemDto) { init(item: BaseItemDto) {
self.item = item self.item = item
super.init() super.init()
} }
// MARK: Respond // MARK: - Respond
func respond(to action: Action) -> State { func respond(to action: Action) -> State {
switch action { switch action {
case let .error(error):
eventSubject.send(.error(error))
return .error(error)
case let .refreshMetadata(metadataRefreshMode, imageRefreshMode, replaceMetadata, replaceImages): case let .refreshMetadata(metadataRefreshMode, imageRefreshMode, replaceMetadata, replaceImages):
itemTask?.cancel() itemTask?.cancel()
@ -81,8 +75,7 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
guard let self else { return } guard let self else { return }
do { do {
await MainActor.run { await MainActor.run {
self.state = .content self.state = .refreshing
self.eventSubject.send(.refreshTriggered)
} }
try await self.refreshMetadata( try await self.refreshMetadata(
@ -93,14 +86,7 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
) )
await MainActor.run { await MainActor.run {
self.state = .refreshing self.state = .initial
self.eventSubject.send(.refreshTriggered)
}
try await self.refreshItem()
await MainActor.run {
self.state = .content
} }
} catch { } catch {
@ -108,18 +94,17 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
let apiError = JellyfinAPIError(error.localizedDescription) let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run { await MainActor.run {
self.state = .error(apiError)
self.eventSubject.send(.error(apiError)) self.eventSubject.send(.error(apiError))
} }
} }
} }
.asAnyCancellable() .asAnyCancellable()
return .refreshing return state
} }
} }
// MARK: Metadata Refresh Logic // MARK: - Metadata Refresh Logic
private func refreshMetadata( private func refreshMetadata(
metadataRefreshMode: MetadataRefreshMode, metadataRefreshMode: MetadataRefreshMode,
@ -140,18 +125,37 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
parameters: parameters parameters: parameters
) )
_ = try await userSession.client.send(request) _ = try await userSession.client.send(request)
try await self.refreshItem()
} }
// MARK: Refresh Item After Request Queued // MARK: - Refresh Item After Request Queued
private func refreshItem() async throws { private func refreshItem() async throws {
guard let itemId = item.id else { return } guard let itemId = item.id else { return }
try await pollRefreshProgress()
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
self.progress = 0.0
Notifications[.itemMetadataDidChange].post(self.item)
}
}
// MARK: - Poll Progress
// TODO: Find a way to actually check refresh progress. Not currently possible on 10.10.
private func pollRefreshProgress() async throws {
let totalDuration: Double = 5.0 let totalDuration: Double = 5.0
let interval: Double = 0.05 let interval: Double = 0.05
let steps = Int(totalDuration / interval) let steps = Int(totalDuration / interval)
// Update progress every 0.05 seconds. Ticks up "1%" at a time. /// Update progress every 0.05 seconds. Ticks up "1%" at a time.
for i in 1 ... steps { for i in 1 ... steps {
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
@ -160,16 +164,5 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
self.progress = currentProgress self.progress = currentProgress
} }
} }
// After waiting for 5 seconds, fetch the updated item
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
self.progress = 0.0
Notifications[.itemMetadataDidChange].post(item)
}
} }
} }

View File

@ -13,14 +13,24 @@ extension ItemView {
struct ActionButton: View { struct ActionButton: View {
// MARK: - Environment Objects
@Environment(\.isSelected) @Environment(\.isSelected)
private var isSelected private var isSelected
// MARK: - Focus State
@FocusState @FocusState
private var isFocused: Bool private var isFocused: Bool
// MARK: - Item Variables
let title: String let title: String
let icon: String let icon: String
let selectedIcon: String let selectedIcon: String
// MARK: - Item Actions
let onSelect: () -> Void let onSelect: () -> Void
// MARK: - Body // MARK: - Body

View File

@ -12,10 +12,68 @@ extension ItemView {
struct ActionButtonHStack: View { struct ActionButtonHStack: View {
// MARK: - Observed, State, & Environment Objects
@EnvironmentObject
private var router: ItemCoordinator.Router
@ObservedObject @ObservedObject
var viewModel: ItemViewModel var viewModel: ItemViewModel
// TODO: Shrink to minWWith 100 (button) / 50 (menu) and 16 spacing to get 4 buttons inline @StateObject
var deleteViewModel: DeleteItemViewModel
// MARK: - Defaults
@StoredValue(.User.enableItemDeletion)
private var enableItemDeletion: Bool
@StoredValue(.User.enableItemEditing)
private var enableItemEditing: Bool
@StoredValue(.User.enableCollectionManagement)
private var enableCollectionManagement: Bool
// MARK: - Dialog States
@State
private var showConfirmationDialog = false
@State
private var isPresentingEventAlert = false
// MARK: - Error State
@State
private var error: Error?
// MARK: - Can Delete Item
private var canDelete: Bool {
if viewModel.item.type == .boxSet {
return enableCollectionManagement && viewModel.item.canDelete ?? false
} else {
return enableItemDeletion && viewModel.item.canDelete ?? false
}
}
// MARK: - Refresh Item
private var canRefresh: Bool {
if viewModel.item.type == .boxSet {
return enableCollectionManagement
} else {
return enableItemEditing
}
}
// MARK: - Initializer
init(viewModel: ItemViewModel) {
self.viewModel = viewModel
self._deleteViewModel = StateObject(wrappedValue: .init(item: viewModel.item))
}
// MARK: - Body
/// Shrink to minWidth 100 (button) / 50 (menu) and 16 spacing to get 3 buttons + menu
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 24) { HStack(alignment: .center, spacing: 24) {
@ -47,11 +105,42 @@ extension ItemView {
// MARK: - Additional Menu Options // MARK: - Additional Menu Options
// TODO: Enable if there are more items needed if canRefresh || canDelete {
/* ActionMenu {} ActionMenu {
.frame(width: 70)*/ if canRefresh {
RefreshMetadataButton(item: viewModel.item)
}
if canDelete {
Divider()
Button(L10n.delete, systemImage: "trash", role: .destructive) {
showConfirmationDialog = true
}
}
}
.frame(width: 70)
}
} }
.frame(height: 100) .frame(height: 100)
.confirmationDialog(
L10n.deleteItemConfirmationMessage,
isPresented: $showConfirmationDialog,
titleVisibility: .visible
) {
Button(L10n.confirm, role: .destructive) {
deleteViewModel.send(.delete)
}
Button(L10n.cancel, role: .cancel) {}
}
.onReceive(deleteViewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
case .deleted:
router.dismissCoordinator()
}
}
.errorMessage($error)
} }
} }
} }

View File

@ -13,9 +13,13 @@ extension ItemView {
struct ActionMenu<Content: View>: View { struct ActionMenu<Content: View>: View {
// MARK: - Focus State
@FocusState @FocusState
private var isFocused: Bool private var isFocused: Bool
// MARK: - Menu Items
@ViewBuilder @ViewBuilder
let menuItems: Content let menuItems: Content

View File

@ -0,0 +1,105 @@
//
// 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 ItemView {
struct RefreshMetadataButton: View {
// MARK: - State Object
@StateObject
private var viewModel: RefreshMetadataViewModel
// MARK: - Error State
@State
private var error: Error?
// MARK: - Initializer
init(item: BaseItemDto) {
_viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item))
}
// MARK: - Body
var body: some View {
Menu {
Group {
Button(L10n.findMissing, systemImage: "magnifyingglass") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .fullRefresh,
replaceMetadata: false,
replaceImages: false
)
)
}
Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .none,
replaceMetadata: true,
replaceImages: false
)
)
}
Button(L10n.replaceImages, systemImage: "photo") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .none,
imageRefreshMode: .fullRefresh,
replaceMetadata: false,
replaceImages: true
)
)
}
Button(L10n.replaceAll, systemImage: "staroflife") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .fullRefresh,
replaceMetadata: true,
replaceImages: true
)
)
}
}
} label: {
HStack {
Text(L10n.refreshMetadata)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "arrow.clockwise")
.foregroundStyle(.secondary)
.backport
.fontWeight(.semibold)
}
}
.foregroundStyle(.primary, .secondary)
.disabled(viewModel.state == .refreshing || error != nil)
.onReceive(viewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
}
}
.errorMessage($error)
}
}
}

View File

@ -0,0 +1,50 @@
//
// 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 Defaults
import Factory
import SwiftUI
extension CustomizeViewsSettings {
struct ItemSection: View {
@Injected(\.currentUserSession)
private var userSession
@StoredValue(.User.enableItemEditing)
private var enableItemEditing
@StoredValue(.User.enableItemDeletion)
private var enableItemDeletion
@StoredValue(.User.enableCollectionManagement)
private var enableCollectionManagement
var body: some View {
if userSession?.user.permissions.items.canEditMetadata ?? false ||
userSession?.user.permissions.items.canDelete ?? false ||
userSession?.user.permissions.items.canManageCollections ?? false
{
Section(L10n.items) {
/// Enable Refreshing Items from All Visible LIbraries
if userSession?.user.permissions.items.canEditMetadata ?? false {
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing)
}
/// Enable Deleting Items from Approved Libraries
if userSession?.user.permissions.items.canDelete ?? false {
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
}
/// Enable Refreshing & Deleting Collections
if userSession?.user.permissions.items.canManageCollections ?? false {
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement)
}
}
}
}
}
}

View File

@ -105,6 +105,8 @@ struct CustomizeViewsSettings: View {
} }
} }
ItemSection()
HomeSection() HomeSection()
} }
.withDescriptionTopPadding() .withDescriptionTopPadding()

View File

@ -153,6 +153,8 @@
4E90F7672CC72B1F00417C31 /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */; }; 4E90F7672CC72B1F00417C31 /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */; };
4E90F7682CC72B1F00417C31 /* TriggersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */; }; 4E90F7682CC72B1F00417C31 /* TriggersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */; };
4E90F76A2CC72B1F00417C31 /* DetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7592CC72B1F00417C31 /* DetailsSection.swift */; }; 4E90F76A2CC72B1F00417C31 /* DetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7592CC72B1F00417C31 /* DetailsSection.swift */; };
4E97D1832D064748004B89AD /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E97D1822D064748004B89AD /* ItemSection.swift */; };
4E97D1852D064B43004B89AD /* RefreshMetadataButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */; };
4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */; }; 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */; };
4E9A24E82C82B6190023DA83 /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */; }; 4E9A24E82C82B6190023DA83 /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */; };
4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; };
@ -195,6 +197,8 @@
4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; }; 4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; };
4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; };
4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; 4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; };
4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; };
4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; };
4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.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 */; };
@ -1265,6 +1269,8 @@
4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggersSection.swift; sourceTree = "<group>"; }; 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggersSection.swift; sourceTree = "<group>"; };
4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRow.swift; sourceTree = "<group>"; }; 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRow.swift; sourceTree = "<group>"; };
4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerTaskView.swift; sourceTree = "<group>"; }; 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerTaskView.swift; sourceTree = "<group>"; };
4E97D1822D064748004B89AD /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = "<group>"; };
4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataButton.swift; sourceTree = "<group>"; };
4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = "<group>"; }; 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = "<group>"; };
4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; }; 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; };
4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; };
@ -1301,6 +1307,7 @@
4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = "<group>"; }; 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = "<group>"; };
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; }; 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
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>"; };
@ -2255,6 +2262,7 @@
4E5334A12CD1A28400D59FA8 /* ActionButton.swift */, 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */,
E1C926032887565C002A7A66 /* ActionButtonHStack.swift */, E1C926032887565C002A7A66 /* ActionButtonHStack.swift */,
4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */, 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */,
4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */,
); );
path = ActionButtons; path = ActionButtons;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2392,6 +2400,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4E699BBF2CB34775007CBD5D /* HomeSection.swift */, 4E699BBF2CB34775007CBD5D /* HomeSection.swift */,
4E97D1822D064748004B89AD /* ItemSection.swift */,
); );
path = Sections; path = Sections;
sourceTree = "<group>"; sourceTree = "<group>";
@ -3857,6 +3866,7 @@
E18E0202288749200022598C /* AttributeStyleModifier.swift */, E18E0202288749200022598C /* AttributeStyleModifier.swift */,
E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */,
E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */,
4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */,
E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */, E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */,
E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */, E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */,
E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */, E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */,
@ -4974,6 +4984,7 @@
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */, 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */,
4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */, 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */,
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
4E97D1832D064748004B89AD /* ItemSection.swift in Sources */,
E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */, E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */,
E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */, E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */,
@ -5248,6 +5259,7 @@
E18E02232887492B0022598C /* ImageView.swift in Sources */, E18E02232887492B0022598C /* ImageView.swift in Sources */,
E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */, E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */,
E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */, E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */,
4E97D1852D064B43004B89AD /* RefreshMetadataButton.swift in Sources */,
E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */,
E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */, E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */,
E1CB758B2C80F9EC00217C76 /* CodecProfile.swift in Sources */, E1CB758B2C80F9EC00217C76 /* CodecProfile.swift in Sources */,
@ -5298,6 +5310,7 @@
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */,
E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */, E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */,
4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */, 4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */,
4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */,
E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */, E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */,
E1575E70293E77B5001665B1 /* TextPair.swift in Sources */, E1575E70293E77B5001665B1 /* TextPair.swift in Sources */,
4E2AC4C62C6C492700DD600D /* MediaContainer.swift in Sources */, 4E2AC4C62C6C492700DD600D /* MediaContainer.swift in Sources */,
@ -5615,6 +5628,7 @@
E168BD10289A4162001A6922 /* HomeView.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */,
4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */, 4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */,
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */, 4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */,
4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */,
E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */, E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */,
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */, 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */,
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */, 4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */,
@ -6270,7 +6284,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 78; CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = TY84JMYEFE; DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -6286,7 +6300,7 @@
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.0;
OTHER_CFLAGS = ""; OTHER_CFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
@ -6310,7 +6324,7 @@
CURRENT_PROJECT_VERSION = 78; CURRENT_PROJECT_VERSION = 78;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = TY84JMYEFE; DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -6326,7 +6340,7 @@
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.0;
OTHER_CFLAGS = ""; OTHER_CFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;

View File

@ -13,15 +13,21 @@ import SwiftUI
struct AddItemElementView<Element: Hashable>: View { struct AddItemElementView<Element: Hashable>: View {
// MARK: - Defaults
@Default(.accentColor) @Default(.accentColor)
private var accentColor private var accentColor
// MARK: - Environment & Observed Objects
@EnvironmentObject @EnvironmentObject
private var router: BasicNavigationViewCoordinator.Router private var router: BasicNavigationViewCoordinator.Router
@ObservedObject @ObservedObject
var viewModel: ItemEditorViewModel<Element> var viewModel: ItemEditorViewModel<Element>
// MARK: - Elements Variables
let type: ItemArrayElements let type: ItemArrayElements
@State @State
@ -33,11 +39,13 @@ struct AddItemElementView<Element: Hashable>: View {
@State @State
private var personRole: String = "" private var personRole: String = ""
// MARK: - Trie Data Loaded
@State @State
private var loaded: Bool = false private var loaded: Bool = false
@State // MARK: - Error State
private var isPresentingError: Bool = false
@State @State
private var error: Error? private var error: Error?
@ -47,6 +55,8 @@ struct AddItemElementView<Element: Hashable>: View {
name.isNotEmpty name.isNotEmpty
} }
// MARK: - Name Already Exists
private var itemAlreadyExists: Bool { private var itemAlreadyExists: Bool {
viewModel.trie.contains(key: name.localizedLowercase) viewModel.trie.contains(key: name.localizedLowercase)
} }
@ -56,12 +66,10 @@ struct AddItemElementView<Element: Hashable>: View {
var body: some View { var body: some View {
ZStack { ZStack {
switch viewModel.state { switch viewModel.state {
case .initial, .content, .updating:
contentView
case let .error(error): case let .error(error):
ErrorView(error: error) ErrorView(error: error)
case .updating:
DelayedProgressView()
case .initial, .content:
contentView
} }
} }
.navigationTitle(type.displayTitle) .navigationTitle(type.displayTitle)
@ -104,16 +112,9 @@ struct AddItemElementView<Element: Hashable>: View {
case let .error(eventError): case let .error(eventError):
UIDevice.feedback(.error) UIDevice.feedback(.error)
error = eventError error = eventError
isPresentingError = true
} }
} }
.alert( .errorMessage($error)
L10n.error,
isPresented: $isPresentingError,
presenting: error
) { error in
Text(error.localizedDescription)
}
} }
// MARK: - Content View // MARK: - Content View
@ -122,15 +123,15 @@ struct AddItemElementView<Element: Hashable>: View {
List { List {
NameInput( NameInput(
name: $name, name: $name,
type: type,
personKind: $personKind, personKind: $personKind,
personRole: $personRole, personRole: $personRole,
type: type,
itemAlreadyExists: itemAlreadyExists itemAlreadyExists: itemAlreadyExists
) )
SearchResultsSection( SearchResultsSection(
id: $id,
name: $name, name: $name,
id: $id,
type: type, type: type,
population: viewModel.matches, population: viewModel.matches,
isSearching: viewModel.backgroundStates.contains(.searching) isSearching: viewModel.backgroundStates.contains(.searching)

View File

@ -13,15 +13,16 @@ extension AddItemElementView {
struct NameInput: View { struct NameInput: View {
// MARK: - Element Variables
@Binding @Binding
var name: String var name: String
var type: ItemArrayElements
@Binding @Binding
var personKind: PersonKind var personKind: PersonKind
@Binding @Binding
var personRole: String var personRole: String
let type: ItemArrayElements
let itemAlreadyExists: Bool let itemAlreadyExists: Bool
// MARK: - Body // MARK: - Body
@ -34,7 +35,7 @@ extension AddItemElementView {
} }
} }
// MARK: - Name Input Field // MARK: - Name View
private var nameView: some View { private var nameView: some View {
Section { Section {
@ -67,7 +68,7 @@ extension AddItemElementView {
} }
} }
// MARK: - Person Input Fields // MARK: - Person View
var personView: some View { var personView: some View {
Section { Section {

View File

@ -13,13 +13,19 @@ extension AddItemElementView {
struct SearchResultsSection: View { struct SearchResultsSection: View {
@Binding // MARK: - Element Variables
var id: String?
@Binding @Binding
var name: String var name: String
@Binding
var id: String?
// MARK: - Element Search Variables
let type: ItemArrayElements let type: ItemArrayElements
let population: [Element] let population: [Element]
// TODO: Why doesn't environment(\.isSearching) work?
let isSearching: Bool let isSearching: Bool
// MARK: - Body // MARK: - Body
@ -50,7 +56,7 @@ extension AddItemElementView {
} }
} }
// MARK: - Empty Matches Results // MARK: - No Results View
private var noResultsView: some View { private var noResultsView: some View {
Text(L10n.none) Text(L10n.none)
@ -58,7 +64,7 @@ extension AddItemElementView {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
// MARK: - Formatted Matches Results // MARK: - Results View
private var resultsView: some View { private var resultsView: some View {
ForEach(population, id: \.self) { result in ForEach(population, id: \.self) { result in
@ -75,7 +81,7 @@ extension AddItemElementView {
} }
} }
// MARK: - Element Matches Button Label by Type // MARK: - Label View
@ViewBuilder @ViewBuilder
private func labelView(_ match: Element) -> some View { private func labelView(_ match: Element) -> some View {

View File

@ -13,6 +13,8 @@ extension ItemEditorView {
struct RefreshMetadataButton: View { struct RefreshMetadataButton: View {
// MARK: - Environment & State Objects
// Bug in SwiftUI where Menu item icons will be black in dark mode // Bug in SwiftUI where Menu item icons will be black in dark mode
// when a HierarchicalShapeStyle is applied to the Buttons // when a HierarchicalShapeStyle is applied to the Buttons
@Environment(\.colorScheme) @Environment(\.colorScheme)
@ -21,10 +23,10 @@ extension ItemEditorView {
@StateObject @StateObject
private var viewModel: RefreshMetadataViewModel private var viewModel: RefreshMetadataViewModel
// MARK: - Error State
@State @State
private var isPresentingEventAlert = false private var error: Error?
@State
private var error: JellyfinAPIError?
// MARK: - Initializer // MARK: - Initializer
@ -103,25 +105,14 @@ extension ItemEditorView {
} }
} }
.foregroundStyle(.primary, .secondary) .foregroundStyle(.primary, .secondary)
.disabled(viewModel.state == .refreshing || isPresentingEventAlert) .disabled(viewModel.state == .refreshing || error != nil)
.onReceive(viewModel.events) { event in .onReceive(viewModel.events) { event in
switch event { switch event {
case let .error(eventError): case let .error(eventError):
error = eventError error = eventError
isPresentingEventAlert = true
case .refreshTriggered:
UIDevice.impact(.light)
} }
} }
.alert( .errorMessage($error)
L10n.error,
isPresented: $isPresentingEventAlert,
presenting: error
) { _ in
} message: { error in
Text(error.localizedDescription)
}
} }
} }
} }

View File

@ -14,13 +14,20 @@ extension EditItemElementView {
struct EditItemElementRow: View { struct EditItemElementRow: View {
// MARK: - Enviroment Variables
@Environment(\.isEditing) @Environment(\.isEditing)
var isEditing var isEditing
@Environment(\.isSelected) @Environment(\.isSelected)
var isSelected var isSelected
// MARK: - Metadata Variables
let item: Element let item: Element
let type: ItemArrayElements let type: ItemArrayElements
// MARK: - Row Actions
let onSelect: () -> Void let onSelect: () -> Void
let onDelete: () -> Void let onDelete: () -> Void

View File

@ -13,25 +13,38 @@ import SwiftUI
struct EditItemElementView<Element: Hashable>: View { struct EditItemElementView<Element: Hashable>: View {
// MARK: - Defaults
@Default(.accentColor) @Default(.accentColor)
private var accentColor private var accentColor
// MARK: - Observed & Environment Objects
@EnvironmentObject @EnvironmentObject
private var router: ItemEditorCoordinator.Router private var router: ItemEditorCoordinator.Router
@ObservedObject @ObservedObject
var viewModel: ItemEditorViewModel<Element> var viewModel: ItemEditorViewModel<Element>
// MARK: - Elements
@State @State
private var elements: [Element] private var elements: [Element]
// MARK: - Type & Route
private let type: ItemArrayElements private let type: ItemArrayElements
private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel<Element>) -> Void private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel<Element>) -> Void
// MARK: - Dialog States
@State @State
private var isPresentingDeleteConfirmation = false private var isPresentingDeleteConfirmation = false
@State @State
private var isPresentingDeleteSelectionConfirmation = false private var isPresentingDeleteSelectionConfirmation = false
// MARK: - Editing States
@State @State
private var selectedElements: Set<Element> = [] private var selectedElements: Set<Element> = []
@State @State
@ -39,6 +52,11 @@ struct EditItemElementView<Element: Hashable>: View {
@State @State
private var isReordering: Bool = false private var isReordering: Bool = false
// MARK: - Error State
@State
private var error: Error?
// MARK: - Initializer // MARK: - Initializer
init( init(
@ -55,95 +73,111 @@ struct EditItemElementView<Element: Hashable>: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
contentView ZStack {
.navigationBarTitle(type.displayTitle) switch viewModel.state {
.navigationBarTitleDisplayMode(.inline) case .initial, .content, .updating:
.navigationBarBackButtonHidden(isEditing || isReordering) contentView
.toolbar { case let .error(error):
ToolbarItem(placement: .topBarLeading) { errorView(with: error)
if isEditing { }
navigationBarSelectView }
} .navigationBarTitle(type.displayTitle)
} .navigationBarTitleDisplayMode(.inline)
ToolbarItem(placement: .topBarTrailing) { .navigationBarBackButtonHidden(isEditing || isReordering)
if isEditing || isReordering { .toolbar {
Button(L10n.cancel) { ToolbarItem(placement: .topBarLeading) {
if isEditing { if isEditing {
isEditing.toggle() navigationBarSelectView
}
if isReordering {
elements = type.getElement(for: viewModel.item)
isReordering.toggle()
}
UIDevice.impact(.light)
selectedElements.removeAll()
}
.buttonStyle(.toolbarPill)
.foregroundStyle(accentColor)
}
}
ToolbarItem(placement: .bottomBar) {
if isEditing {
Button(L10n.delete) {
isPresentingDeleteSelectionConfirmation = true
}
.buttonStyle(.toolbarPill(.red))
.disabled(selectedElements.isEmpty)
.frame(maxWidth: .infinity, alignment: .trailing)
}
if isReordering {
Button(L10n.save) {
viewModel.send(.reorder(elements))
isReordering = false
}
.buttonStyle(.toolbarPill)
.disabled(type.getElement(for: viewModel.item) == elements)
.frame(maxWidth: .infinity, alignment: .trailing)
}
} }
} }
.navigationBarMenuButton( ToolbarItem(placement: .topBarTrailing) {
isLoading: viewModel.backgroundStates.contains(.refreshing), if isEditing || isReordering {
isHidden: isEditing || isReordering Button(L10n.cancel) {
) { if isEditing {
Button(L10n.add, systemImage: "plus") { isEditing.toggle()
route(router, viewModel) }
if isReordering {
elements = type.getElement(for: viewModel.item)
isReordering.toggle()
}
UIDevice.impact(.light)
selectedElements.removeAll()
}
.buttonStyle(.toolbarPill)
.foregroundStyle(accentColor)
}
}
ToolbarItem(placement: .bottomBar) {
if isEditing {
Button(L10n.delete) {
isPresentingDeleteSelectionConfirmation = true
}
.buttonStyle(.toolbarPill(.red))
.disabled(selectedElements.isEmpty)
.frame(maxWidth: .infinity, alignment: .trailing)
}
if isReordering {
Button(L10n.save) {
viewModel.send(.reorder(elements))
isReordering = false
}
.buttonStyle(.toolbarPill)
.disabled(type.getElement(for: viewModel.item) == elements)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.navigationBarMenuButton(
isLoading: viewModel.backgroundStates.contains(.refreshing),
isHidden: isEditing || isReordering
) {
Button(L10n.add, systemImage: "plus") {
route(router, viewModel)
}
if elements.isNotEmpty == true {
Button(L10n.edit, systemImage: "checkmark.circle") {
isEditing = true
} }
if elements.isNotEmpty == true { Button(L10n.reorder, systemImage: "arrow.up.arrow.down") {
Button(L10n.edit, systemImage: "checkmark.circle") { isReordering = true
isEditing = true
}
Button(L10n.reorder, systemImage: "arrow.up.arrow.down") {
isReordering = true
}
} }
} }
.confirmationDialog( }
L10n.delete, .onReceive(viewModel.events) { events in
isPresented: $isPresentingDeleteSelectionConfirmation, switch events {
titleVisibility: .visible case let .error(eventError):
) { error = eventError
deleteSelectedConfirmationActions default:
} message: { break
Text(L10n.deleteSelectedConfirmation)
}
.confirmationDialog(
L10n.delete,
isPresented: $isPresentingDeleteConfirmation,
titleVisibility: .visible
) {
deleteConfirmationActions
} message: {
Text(L10n.deleteItemConfirmation)
}
.onNotification(.itemMetadataDidChange) { _ in
self.elements = type.getElement(for: self.viewModel.item)
} }
}
.errorMessage($error)
.confirmationDialog(
L10n.delete,
isPresented: $isPresentingDeleteSelectionConfirmation,
titleVisibility: .visible
) {
deleteSelectedConfirmationActions
} message: {
Text(L10n.deleteSelectedConfirmation)
}
.confirmationDialog(
L10n.delete,
isPresented: $isPresentingDeleteConfirmation,
titleVisibility: .visible
) {
deleteConfirmationActions
} message: {
Text(L10n.deleteItemConfirmation)
}
.onNotification(.itemMetadataDidChange) { _ in
self.elements = type.getElement(for: self.viewModel.item)
}
} }
// MARK: - Navigation Bar Select/Remove All Content // MARK: - Select/Remove All Button
@ViewBuilder @ViewBuilder
private var navigationBarSelectView: some View { private var navigationBarSelectView: some View {
@ -156,6 +190,16 @@ struct EditItemElementView<Element: Hashable>: View {
.foregroundStyle(accentColor) .foregroundStyle(accentColor)
} }
// MARK: - ErrorView
@ViewBuilder
private func errorView(with error: some Error) -> some View {
ErrorView(error: error)
.onRetry {
viewModel.send(.load)
}
}
// MARK: - Content View // MARK: - Content View
private var contentView: some View { private var contentView: some View {

View File

@ -17,10 +17,12 @@ extension EditMetadataView {
@Binding @Binding
var item: BaseItemDto var item: BaseItemDto
// MARK: - Body
var body: some View { var body: some View {
Section(L10n.season) { Section(L10n.season) {
// MARK: Season Number // MARK: - Season Number
ChevronAlertButton( ChevronAlertButton(
L10n.season, L10n.season,
@ -35,7 +37,7 @@ extension EditMetadataView {
.keyboardType(.numberPad) .keyboardType(.numberPad)
} }
// MARK: Episode Number // MARK: - Episode Number
ChevronAlertButton( ChevronAlertButton(
L10n.episode, L10n.episode,

View File

@ -14,11 +14,15 @@ extension EditMetadataView {
struct OverviewSection: View { struct OverviewSection: View {
// MARK: - Metadata Variables
@Binding @Binding
var item: BaseItemDto var item: BaseItemDto
let itemType: BaseItemKind let itemType: BaseItemKind
// MARK: - Show Tagline
private var showTaglines: Bool { private var showTaglines: Bool {
[ [
BaseItemKind.movie, BaseItemKind.movie,
@ -29,6 +33,8 @@ extension EditMetadataView {
].contains(itemType) ].contains(itemType)
} }
// MARK: - Body
var body: some View { var body: some View {
if showTaglines { if showTaglines {
// There doesn't seem to be a usage anywhere of more than 1 tagline? // There doesn't seem to be a usage anywhere of more than 1 tagline?

View File

@ -10,17 +10,22 @@ import Combine
import JellyfinAPI import JellyfinAPI
import SwiftUI import SwiftUI
// TODO: Reimagine this whole thing to be much leaner.
extension EditMetadataView { extension EditMetadataView {
struct ParentalRatingSection: View { struct ParentalRatingSection: View {
@Binding // MARK: - Observed Object
var item: BaseItemDto
@ObservedObject @ObservedObject
private var viewModel = ParentalRatingsViewModel() private var viewModel = ParentalRatingsViewModel()
// MARK: - Item
@Binding
var item: BaseItemDto
// MARK: - Ratings States
@State @State
private var officialRatings: [ParentalRating] = [] private var officialRatings: [ParentalRating] = []
@State @State
@ -31,7 +36,7 @@ extension EditMetadataView {
var body: some View { var body: some View {
Section(L10n.parentalRating) { Section(L10n.parentalRating) {
// MARK: Official Rating Picker // MARK: - Official Rating Picker
Picker( Picker(
L10n.officialRating, L10n.officialRating,
@ -53,7 +58,7 @@ extension EditMetadataView {
updateOfficialRatings() updateOfficialRatings()
} }
// MARK: Custom Rating Picker // MARK: - Custom Rating Picker
Picker( Picker(
L10n.customRating, L10n.customRating,

View File

@ -17,10 +17,12 @@ extension EditMetadataView {
@Binding @Binding
var item: BaseItemDto var item: BaseItemDto
// MARK: - Body
var body: some View { var body: some View {
Section(L10n.reviews) { Section(L10n.reviews) {
// MARK: Critics Rating // MARK: - Critics Rating
ChevronAlertButton( ChevronAlertButton(
L10n.critics, L10n.critics,
@ -40,7 +42,7 @@ extension EditMetadataView {
} }
} }
// MARK: Community Rating // MARK: - Community Rating
ChevronAlertButton( ChevronAlertButton(
L10n.community, L10n.community,

View File

@ -12,12 +12,16 @@ import SwiftUI
struct EditMetadataView: View { struct EditMetadataView: View {
// MARK: - Observed & Environment Objects
@EnvironmentObject @EnvironmentObject
private var router: BasicNavigationViewCoordinator.Router private var router: BasicNavigationViewCoordinator.Router
@ObservedObject @ObservedObject
private var viewModel: ItemEditorViewModel<BaseItemDto> private var viewModel: ItemEditorViewModel<BaseItemDto>
// MARK: - Metadata Variables
@Binding @Binding
var item: BaseItemDto var item: BaseItemDto
@ -26,6 +30,11 @@ struct EditMetadataView: View {
private let itemType: BaseItemKind private let itemType: BaseItemKind
// MARK: - Error State
@State
private var error: Error?
// MARK: - Initializer // MARK: - Initializer
init(viewModel: ItemEditorViewModel<BaseItemDto>) { init(viewModel: ItemEditorViewModel<BaseItemDto>) {
@ -39,21 +48,47 @@ struct EditMetadataView: View {
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
contentView ZStack {
.navigationBarTitle(L10n.metadata) switch viewModel.state {
.navigationBarTitleDisplayMode(.inline) case .initial, .content, .updating:
.topBarTrailing { contentView
Button(L10n.save) { case let .error(error):
item = tempItem errorView(with: error)
viewModel.send(.update(tempItem))
router.dismissCoordinator()
}
.buttonStyle(.toolbarPill)
.disabled(viewModel.item == tempItem)
} }
.navigationBarCloseButton { }
.navigationBarTitle(L10n.metadata)
.navigationBarTitleDisplayMode(.inline)
.topBarTrailing {
Button(L10n.save) {
item = tempItem
viewModel.send(.update(tempItem))
router.dismissCoordinator() router.dismissCoordinator()
} }
.buttonStyle(.toolbarPill)
.disabled(viewModel.item == tempItem)
}
.navigationBarCloseButton {
router.dismissCoordinator()
}
.onReceive(viewModel.events) { events in
switch events {
case let .error(eventError):
error = eventError
default:
break
}
}
.errorMessage($error)
}
// MARK: - ErrorView
@ViewBuilder
private func errorView(with error: some Error) -> some View {
ErrorView(error: error)
.onRetry {
viewModel.send(.load)
}
} }
// MARK: - Content View // MARK: - Content View

View File

@ -24,12 +24,19 @@ struct ItemEditorView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
contentView ZStack {
.navigationBarTitle(L10n.metadata) switch viewModel.state {
.navigationBarTitleDisplayMode(.inline) case .initial, .content, .refreshing:
.navigationBarCloseButton { contentView
router.dismissCoordinator() case let .error(error):
errorView(with: error)
} }
}
.navigationBarTitle(L10n.metadata)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
router.dismissCoordinator()
}
} }
// MARK: - Content View // MARK: - Content View
@ -47,6 +54,18 @@ struct ItemEditorView: View {
} }
} }
// MARK: - ErrorView
@ViewBuilder
private func errorView(with error: some Error) -> some View {
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
}
// MARK: - Refresh Menu Button
@ViewBuilder @ViewBuilder
private var refreshButtonView: some View { private var refreshButtonView: some View {
Section { Section {
@ -74,6 +93,8 @@ struct ItemEditorView: View {
} }
} }
// MARK: - Editable Routing Buttons
@ViewBuilder @ViewBuilder
private var editView: some View { private var editView: some View {
Section(L10n.edit) { Section(L10n.edit) {

View File

@ -25,31 +25,39 @@ extension CustomizeViewsSettings {
private var enableCollectionManagement private var enableCollectionManagement
var body: some View { var body: some View {
Section(L10n.items) { if userSession?.user.permissions.items.canEditMetadata ?? false
/// Enable Editing Items from All Visible LIbraries || userSession?.user.permissions.items.canDelete ?? false
if userSession?.user.permissions.items.canEditMetadata ?? false { // || userSession?.user.permissions.items.canDownload ?? false
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) || userSession?.user.permissions.items.canManageCollections ?? false
} // || userSession?.user.permissions.items.canManageLyrics ?? false
/// Enable Downloading All Items // || userSession?.user.permissions.items.canManageSubtitles
/* if userSession?.user.permissions.items.canDownload ?? false { {
Section(L10n.items) {
/// Enable Editing Items from All Visible LIbraries
if userSession?.user.permissions.items.canEditMetadata ?? false {
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing)
}
/// Enable Deleting Items from Approved Libraries
if userSession?.user.permissions.items.canDelete ?? false {
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
}
/// Enable Downloading All Items
/* if userSession?.user.permissions.items.canDownload ?? false {
Toggle(L10n.allowItemDownloading, isOn: $enableItemDownloads) Toggle(L10n.allowItemDownloading, isOn: $enableItemDownloads)
} */ } */
/// Enable Deleting or Editing Collections /// Enable Deleting or Editing Collections
if userSession?.user.permissions.items.canManageCollections ?? false { if userSession?.user.permissions.items.canManageCollections ?? false {
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement) Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement)
} }
/// Manage Item Lyrics /// Manage Item Lyrics
/* if userSession?.user.permissions.items.canManageLyrics ?? false { /* if userSession?.user.permissions.items.canManageLyrics ?? false {
Toggle(L10n.allowLyricsManagement isOn: $enableLyricsManagement) Toggle(L10n.allowLyricsManagement isOn: $enableLyricsManagement)
} */ } */
/// Manage Item Subtitles /// Manage Item Subtitles
/* if userSession?.user.items.canManageSubtitles ?? false { /* if userSession?.user.items.canManageSubtitles ?? false {
Toggle(L10n.allowSubtitleManagement, isOn: $enableSubtitleManagement) Toggle(L10n.allowSubtitleManagement, isOn: $enableSubtitleManagement)
} */ } */
} }
/// Enable Deleting Items from Approved Libraries
if userSession?.user.permissions.items.canDelete ?? false {
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
} }
} }
} }