[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:
parent
bbfa944b52
commit
548d35b19e
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -148,6 +148,15 @@ extension View {
|
|||
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 {
|
||||
shadow(radius: 4, y: 2)
|
||||
}
|
||||
|
|
|
@ -12,61 +12,57 @@ import JellyfinAPI
|
|||
|
||||
class DeleteItemViewModel: ViewModel, Stateful, Eventful {
|
||||
|
||||
// MARK: Events
|
||||
// MARK: - Events
|
||||
|
||||
enum Event: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case deleted
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
// MARK: Action
|
||||
// MARK: - Action
|
||||
|
||||
enum Action: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case delete
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
// MARK: - State
|
||||
|
||||
enum State: Hashable {
|
||||
case content
|
||||
case error(JellyfinAPIError)
|
||||
case initial
|
||||
case refreshing
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
@Published
|
||||
var item: BaseItemDto?
|
||||
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
|
||||
private var deleteTask: AnyCancellable?
|
||||
// MARK: - Published Item
|
||||
|
||||
@Published
|
||||
var item: BaseItemDto?
|
||||
|
||||
// MARK: Event Variables
|
||||
|
||||
private var deleteTask: AnyCancellable?
|
||||
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.eraseToAnyPublisher()
|
||||
// Causes issues with the Deleted Event unless this is removed
|
||||
// .receive(on: RunLoop.main)
|
||||
}
|
||||
|
||||
// MARK: Init
|
||||
// MARK: - Initializer
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: Respond
|
||||
// MARK: - Respond
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case let .error(error):
|
||||
return .error(error)
|
||||
|
||||
case .delete:
|
||||
deleteTask?.cancel()
|
||||
|
||||
|
@ -75,12 +71,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
|
|||
do {
|
||||
try await self.deleteItem()
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.deleted)
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .error(JellyfinAPIError(error.localizedDescription))
|
||||
self.eventSubject.send(.error(JellyfinAPIError(error.localizedDescription)))
|
||||
|
@ -89,11 +84,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
|
|||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .refreshing
|
||||
return .initial
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Metadata Refresh Logic
|
||||
// MARK: - Item Deletion Logic
|
||||
|
||||
private func deleteItem() async throws {
|
||||
guard let item, let itemID = item.id else {
|
||||
|
|
|
@ -12,17 +12,15 @@ import JellyfinAPI
|
|||
|
||||
class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
||||
|
||||
// MARK: Events
|
||||
// MARK: - Events
|
||||
|
||||
enum Event: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case refreshTriggered
|
||||
}
|
||||
|
||||
// MARK: Action
|
||||
// MARK: - Action
|
||||
|
||||
enum Action: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case refreshMetadata(
|
||||
metadataRefreshMode: MetadataRefreshMode,
|
||||
imageRefreshMode: MetadataRefreshMode,
|
||||
|
@ -31,25 +29,25 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
|||
)
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
// MARK: States
|
||||
|
||||
enum State: Hashable {
|
||||
case content
|
||||
case error(JellyfinAPIError)
|
||||
case initial
|
||||
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
|
||||
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 eventSubject = PassthroughSubject<Event, Never>()
|
||||
|
||||
|
@ -59,21 +57,17 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: Init
|
||||
// MARK: - Init
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: Respond
|
||||
// MARK: - Respond
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case let .error(error):
|
||||
eventSubject.send(.error(error))
|
||||
return .error(error)
|
||||
|
||||
case let .refreshMetadata(metadataRefreshMode, imageRefreshMode, replaceMetadata, replaceImages):
|
||||
itemTask?.cancel()
|
||||
|
||||
|
@ -81,8 +75,7 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
|||
guard let self else { return }
|
||||
do {
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.eventSubject.send(.refreshTriggered)
|
||||
self.state = .refreshing
|
||||
}
|
||||
|
||||
try await self.refreshMetadata(
|
||||
|
@ -93,14 +86,7 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
|||
)
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .refreshing
|
||||
self.eventSubject.send(.refreshTriggered)
|
||||
}
|
||||
|
||||
try await self.refreshItem()
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.state = .initial
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
@ -108,18 +94,17 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
|||
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .error(apiError)
|
||||
self.eventSubject.send(.error(apiError))
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .refreshing
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Metadata Refresh Logic
|
||||
// MARK: - Metadata Refresh Logic
|
||||
|
||||
private func refreshMetadata(
|
||||
metadataRefreshMode: MetadataRefreshMode,
|
||||
|
@ -140,18 +125,37 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
|||
parameters: parameters
|
||||
)
|
||||
_ = 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 {
|
||||
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 interval: Double = 0.05
|
||||
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 {
|
||||
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||
|
||||
|
@ -160,16 +164,5 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,14 +13,24 @@ extension ItemView {
|
|||
|
||||
struct ActionButton: View {
|
||||
|
||||
// MARK: - Environment Objects
|
||||
|
||||
@Environment(\.isSelected)
|
||||
private var isSelected
|
||||
|
||||
// MARK: - Focus State
|
||||
|
||||
@FocusState
|
||||
private var isFocused: Bool
|
||||
|
||||
// MARK: - Item Variables
|
||||
|
||||
let title: String
|
||||
let icon: String
|
||||
let selectedIcon: String
|
||||
|
||||
// MARK: - Item Actions
|
||||
|
||||
let onSelect: () -> Void
|
||||
|
||||
// MARK: - Body
|
||||
|
|
|
@ -12,10 +12,68 @@ extension ItemView {
|
|||
|
||||
struct ActionButtonHStack: View {
|
||||
|
||||
// MARK: - Observed, State, & Environment Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: ItemCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
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 {
|
||||
HStack(alignment: .center, spacing: 24) {
|
||||
|
||||
|
@ -47,11 +105,42 @@ extension ItemView {
|
|||
|
||||
// MARK: - Additional Menu Options
|
||||
|
||||
// TODO: Enable if there are more items needed
|
||||
/* ActionMenu {}
|
||||
.frame(width: 70)*/
|
||||
if canRefresh || canDelete {
|
||||
ActionMenu {
|
||||
if canRefresh {
|
||||
RefreshMetadataButton(item: viewModel.item)
|
||||
}
|
||||
|
||||
if canDelete {
|
||||
Divider()
|
||||
Button(L10n.delete, systemImage: "trash", role: .destructive) {
|
||||
showConfirmationDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 70)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,9 +13,13 @@ extension ItemView {
|
|||
|
||||
struct ActionMenu<Content: View>: View {
|
||||
|
||||
// MARK: - Focus State
|
||||
|
||||
@FocusState
|
||||
private var isFocused: Bool
|
||||
|
||||
// MARK: - Menu Items
|
||||
|
||||
@ViewBuilder
|
||||
let menuItems: Content
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -105,6 +105,8 @@ struct CustomizeViewsSettings: View {
|
|||
}
|
||||
}
|
||||
|
||||
ItemSection()
|
||||
|
||||
HomeSection()
|
||||
}
|
||||
.withDescriptionTopPadding()
|
||||
|
|
|
@ -153,6 +153,8 @@
|
|||
4E90F7672CC72B1F00417C31 /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */; };
|
||||
4E90F7682CC72B1F00417C31 /* TriggersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75D2CC72B1F00417C31 /* TriggersSection.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 */; };
|
||||
4E9A24E82C82B6190023DA83 /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E72C82B6190023DA83 /* CustomProfileButton.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 */; };
|
||||
4ECDAA9E2C920A8E0030F2F5 /* 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 */; };
|
||||
4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1301,6 +1307,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -2255,6 +2262,7 @@
|
|||
4E5334A12CD1A28400D59FA8 /* ActionButton.swift */,
|
||||
E1C926032887565C002A7A66 /* ActionButtonHStack.swift */,
|
||||
4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */,
|
||||
4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */,
|
||||
);
|
||||
path = ActionButtons;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2392,6 +2400,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
4E699BBF2CB34775007CBD5D /* HomeSection.swift */,
|
||||
4E97D1822D064748004B89AD /* ItemSection.swift */,
|
||||
);
|
||||
path = Sections;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3857,6 +3866,7 @@
|
|||
E18E0202288749200022598C /* AttributeStyleModifier.swift */,
|
||||
E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */,
|
||||
E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */,
|
||||
4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */,
|
||||
E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */,
|
||||
E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */,
|
||||
E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */,
|
||||
|
@ -4974,6 +4984,7 @@
|
|||
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */,
|
||||
4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */,
|
||||
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
|
||||
4E97D1832D064748004B89AD /* ItemSection.swift in Sources */,
|
||||
E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */,
|
||||
E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
|
||||
E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */,
|
||||
|
@ -5248,6 +5259,7 @@
|
|||
E18E02232887492B0022598C /* ImageView.swift in Sources */,
|
||||
E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */,
|
||||
E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */,
|
||||
4E97D1852D064B43004B89AD /* RefreshMetadataButton.swift in Sources */,
|
||||
E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */,
|
||||
E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */,
|
||||
E1CB758B2C80F9EC00217C76 /* CodecProfile.swift in Sources */,
|
||||
|
@ -5298,6 +5310,7 @@
|
|||
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */,
|
||||
E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */,
|
||||
4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */,
|
||||
4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */,
|
||||
E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */,
|
||||
E1575E70293E77B5001665B1 /* TextPair.swift in Sources */,
|
||||
4E2AC4C62C6C492700DD600D /* MediaContainer.swift in Sources */,
|
||||
|
@ -5615,6 +5628,7 @@
|
|||
E168BD10289A4162001A6922 /* HomeView.swift in Sources */,
|
||||
4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */,
|
||||
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */,
|
||||
4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */,
|
||||
E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */,
|
||||
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */,
|
||||
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */,
|
||||
|
@ -6270,7 +6284,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 78;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = TY84JMYEFE;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
|
@ -6286,7 +6300,7 @@
|
|||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
OTHER_CFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
@ -6310,7 +6324,7 @@
|
|||
CURRENT_PROJECT_VERSION = 78;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = TY84JMYEFE;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
|
@ -6326,7 +6340,7 @@
|
|||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
OTHER_CFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
|
|
@ -13,15 +13,21 @@ import SwiftUI
|
|||
|
||||
struct AddItemElementView<Element: Hashable>: View {
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
// MARK: - Environment & Observed Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: BasicNavigationViewCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
var viewModel: ItemEditorViewModel<Element>
|
||||
|
||||
// MARK: - Elements Variables
|
||||
|
||||
let type: ItemArrayElements
|
||||
|
||||
@State
|
||||
|
@ -33,11 +39,13 @@ struct AddItemElementView<Element: Hashable>: View {
|
|||
@State
|
||||
private var personRole: String = ""
|
||||
|
||||
// MARK: - Trie Data Loaded
|
||||
|
||||
@State
|
||||
private var loaded: Bool = false
|
||||
|
||||
@State
|
||||
private var isPresentingError: Bool = false
|
||||
// MARK: - Error State
|
||||
|
||||
@State
|
||||
private var error: Error?
|
||||
|
||||
|
@ -47,6 +55,8 @@ struct AddItemElementView<Element: Hashable>: View {
|
|||
name.isNotEmpty
|
||||
}
|
||||
|
||||
// MARK: - Name Already Exists
|
||||
|
||||
private var itemAlreadyExists: Bool {
|
||||
viewModel.trie.contains(key: name.localizedLowercase)
|
||||
}
|
||||
|
@ -56,12 +66,10 @@ struct AddItemElementView<Element: Hashable>: View {
|
|||
var body: some View {
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .initial, .content, .updating:
|
||||
contentView
|
||||
case let .error(error):
|
||||
ErrorView(error: error)
|
||||
case .updating:
|
||||
DelayedProgressView()
|
||||
case .initial, .content:
|
||||
contentView
|
||||
}
|
||||
}
|
||||
.navigationTitle(type.displayTitle)
|
||||
|
@ -104,16 +112,9 @@ struct AddItemElementView<Element: Hashable>: View {
|
|||
case let .error(eventError):
|
||||
UIDevice.feedback(.error)
|
||||
error = eventError
|
||||
isPresentingError = true
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
L10n.error,
|
||||
isPresented: $isPresentingError,
|
||||
presenting: error
|
||||
) { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
.errorMessage($error)
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
@ -122,15 +123,15 @@ struct AddItemElementView<Element: Hashable>: View {
|
|||
List {
|
||||
NameInput(
|
||||
name: $name,
|
||||
type: type,
|
||||
personKind: $personKind,
|
||||
personRole: $personRole,
|
||||
type: type,
|
||||
itemAlreadyExists: itemAlreadyExists
|
||||
)
|
||||
|
||||
SearchResultsSection(
|
||||
id: $id,
|
||||
name: $name,
|
||||
id: $id,
|
||||
type: type,
|
||||
population: viewModel.matches,
|
||||
isSearching: viewModel.backgroundStates.contains(.searching)
|
||||
|
|
|
@ -13,15 +13,16 @@ extension AddItemElementView {
|
|||
|
||||
struct NameInput: View {
|
||||
|
||||
// MARK: - Element Variables
|
||||
|
||||
@Binding
|
||||
var name: String
|
||||
var type: ItemArrayElements
|
||||
|
||||
@Binding
|
||||
var personKind: PersonKind
|
||||
@Binding
|
||||
var personRole: String
|
||||
|
||||
let type: ItemArrayElements
|
||||
let itemAlreadyExists: Bool
|
||||
|
||||
// MARK: - Body
|
||||
|
@ -34,7 +35,7 @@ extension AddItemElementView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Name Input Field
|
||||
// MARK: - Name View
|
||||
|
||||
private var nameView: some View {
|
||||
Section {
|
||||
|
@ -67,7 +68,7 @@ extension AddItemElementView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Person Input Fields
|
||||
// MARK: - Person View
|
||||
|
||||
var personView: some View {
|
||||
Section {
|
||||
|
|
|
@ -13,13 +13,19 @@ extension AddItemElementView {
|
|||
|
||||
struct SearchResultsSection: View {
|
||||
|
||||
@Binding
|
||||
var id: String?
|
||||
// MARK: - Element Variables
|
||||
|
||||
@Binding
|
||||
var name: String
|
||||
@Binding
|
||||
var id: String?
|
||||
|
||||
// MARK: - Element Search Variables
|
||||
|
||||
let type: ItemArrayElements
|
||||
let population: [Element]
|
||||
|
||||
// TODO: Why doesn't environment(\.isSearching) work?
|
||||
let isSearching: Bool
|
||||
|
||||
// MARK: - Body
|
||||
|
@ -50,7 +56,7 @@ extension AddItemElementView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty Matches Results
|
||||
// MARK: - No Results View
|
||||
|
||||
private var noResultsView: some View {
|
||||
Text(L10n.none)
|
||||
|
@ -58,7 +64,7 @@ extension AddItemElementView {
|
|||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
// MARK: - Formatted Matches Results
|
||||
// MARK: - Results View
|
||||
|
||||
private var resultsView: some View {
|
||||
ForEach(population, id: \.self) { result in
|
||||
|
@ -75,7 +81,7 @@ extension AddItemElementView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Element Matches Button Label by Type
|
||||
// MARK: - Label View
|
||||
|
||||
@ViewBuilder
|
||||
private func labelView(_ match: Element) -> some View {
|
||||
|
|
|
@ -13,6 +13,8 @@ extension ItemEditorView {
|
|||
|
||||
struct RefreshMetadataButton: View {
|
||||
|
||||
// MARK: - Environment & State Objects
|
||||
|
||||
// Bug in SwiftUI where Menu item icons will be black in dark mode
|
||||
// when a HierarchicalShapeStyle is applied to the Buttons
|
||||
@Environment(\.colorScheme)
|
||||
|
@ -21,10 +23,10 @@ extension ItemEditorView {
|
|||
@StateObject
|
||||
private var viewModel: RefreshMetadataViewModel
|
||||
|
||||
// MARK: - Error State
|
||||
|
||||
@State
|
||||
private var isPresentingEventAlert = false
|
||||
@State
|
||||
private var error: JellyfinAPIError?
|
||||
private var error: Error?
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
|
@ -103,25 +105,14 @@ extension ItemEditorView {
|
|||
}
|
||||
}
|
||||
.foregroundStyle(.primary, .secondary)
|
||||
.disabled(viewModel.state == .refreshing || isPresentingEventAlert)
|
||||
.disabled(viewModel.state == .refreshing || error != nil)
|
||||
.onReceive(viewModel.events) { event in
|
||||
switch event {
|
||||
case let .error(eventError):
|
||||
error = eventError
|
||||
isPresentingEventAlert = true
|
||||
case .refreshTriggered:
|
||||
UIDevice.impact(.light)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
L10n.error,
|
||||
isPresented: $isPresentingEventAlert,
|
||||
presenting: error
|
||||
) { _ in
|
||||
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
.errorMessage($error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,13 +14,20 @@ extension EditItemElementView {
|
|||
|
||||
struct EditItemElementRow: View {
|
||||
|
||||
// MARK: - Enviroment Variables
|
||||
|
||||
@Environment(\.isEditing)
|
||||
var isEditing
|
||||
@Environment(\.isSelected)
|
||||
var isSelected
|
||||
|
||||
// MARK: - Metadata Variables
|
||||
|
||||
let item: Element
|
||||
let type: ItemArrayElements
|
||||
|
||||
// MARK: - Row Actions
|
||||
|
||||
let onSelect: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
|
|
|
@ -13,25 +13,38 @@ import SwiftUI
|
|||
|
||||
struct EditItemElementView<Element: Hashable>: View {
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
// MARK: - Observed & Environment Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: ItemEditorCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
var viewModel: ItemEditorViewModel<Element>
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
@State
|
||||
private var elements: [Element]
|
||||
|
||||
// MARK: - Type & Route
|
||||
|
||||
private let type: ItemArrayElements
|
||||
private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel<Element>) -> Void
|
||||
|
||||
// MARK: - Dialog States
|
||||
|
||||
@State
|
||||
private var isPresentingDeleteConfirmation = false
|
||||
@State
|
||||
private var isPresentingDeleteSelectionConfirmation = false
|
||||
|
||||
// MARK: - Editing States
|
||||
|
||||
@State
|
||||
private var selectedElements: Set<Element> = []
|
||||
@State
|
||||
|
@ -39,6 +52,11 @@ struct EditItemElementView<Element: Hashable>: View {
|
|||
@State
|
||||
private var isReordering: Bool = false
|
||||
|
||||
// MARK: - Error State
|
||||
|
||||
@State
|
||||
private var error: Error?
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(
|
||||
|
@ -55,95 +73,111 @@ struct EditItemElementView<Element: Hashable>: View {
|
|||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
contentView
|
||||
.navigationBarTitle(type.displayTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(isEditing || isReordering)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if isEditing {
|
||||
navigationBarSelectView
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if isEditing || isReordering {
|
||||
Button(L10n.cancel) {
|
||||
if isEditing {
|
||||
isEditing.toggle()
|
||||
}
|
||||
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)
|
||||
}
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .initial, .content, .updating:
|
||||
contentView
|
||||
case let .error(error):
|
||||
errorView(with: error)
|
||||
}
|
||||
}
|
||||
.navigationBarTitle(type.displayTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(isEditing || isReordering)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if isEditing {
|
||||
navigationBarSelectView
|
||||
}
|
||||
}
|
||||
.navigationBarMenuButton(
|
||||
isLoading: viewModel.backgroundStates.contains(.refreshing),
|
||||
isHidden: isEditing || isReordering
|
||||
) {
|
||||
Button(L10n.add, systemImage: "plus") {
|
||||
route(router, viewModel)
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if isEditing || isReordering {
|
||||
Button(L10n.cancel) {
|
||||
if isEditing {
|
||||
isEditing.toggle()
|
||||
}
|
||||
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.edit, systemImage: "checkmark.circle") {
|
||||
isEditing = true
|
||||
}
|
||||
|
||||
Button(L10n.reorder, systemImage: "arrow.up.arrow.down") {
|
||||
isReordering = true
|
||||
}
|
||||
Button(L10n.reorder, systemImage: "arrow.up.arrow.down") {
|
||||
isReordering = true
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.onReceive(viewModel.events) { events in
|
||||
switch events {
|
||||
case let .error(eventError):
|
||||
error = eventError
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.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
|
||||
private var navigationBarSelectView: some View {
|
||||
|
@ -156,6 +190,16 @@ struct EditItemElementView<Element: Hashable>: View {
|
|||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
// MARK: - ErrorView
|
||||
|
||||
@ViewBuilder
|
||||
private func errorView(with error: some Error) -> some View {
|
||||
ErrorView(error: error)
|
||||
.onRetry {
|
||||
viewModel.send(.load)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
||||
private var contentView: some View {
|
||||
|
|
|
@ -17,10 +17,12 @@ extension EditMetadataView {
|
|||
@Binding
|
||||
var item: BaseItemDto
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Section(L10n.season) {
|
||||
|
||||
// MARK: Season Number
|
||||
// MARK: - Season Number
|
||||
|
||||
ChevronAlertButton(
|
||||
L10n.season,
|
||||
|
@ -35,7 +37,7 @@ extension EditMetadataView {
|
|||
.keyboardType(.numberPad)
|
||||
}
|
||||
|
||||
// MARK: Episode Number
|
||||
// MARK: - Episode Number
|
||||
|
||||
ChevronAlertButton(
|
||||
L10n.episode,
|
||||
|
|
|
@ -14,11 +14,15 @@ extension EditMetadataView {
|
|||
|
||||
struct OverviewSection: View {
|
||||
|
||||
// MARK: - Metadata Variables
|
||||
|
||||
@Binding
|
||||
var item: BaseItemDto
|
||||
|
||||
let itemType: BaseItemKind
|
||||
|
||||
// MARK: - Show Tagline
|
||||
|
||||
private var showTaglines: Bool {
|
||||
[
|
||||
BaseItemKind.movie,
|
||||
|
@ -29,6 +33,8 @@ extension EditMetadataView {
|
|||
].contains(itemType)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
if showTaglines {
|
||||
// There doesn't seem to be a usage anywhere of more than 1 tagline?
|
||||
|
|
|
@ -10,17 +10,22 @@ import Combine
|
|||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Reimagine this whole thing to be much leaner.
|
||||
extension EditMetadataView {
|
||||
|
||||
struct ParentalRatingSection: View {
|
||||
|
||||
@Binding
|
||||
var item: BaseItemDto
|
||||
// MARK: - Observed Object
|
||||
|
||||
@ObservedObject
|
||||
private var viewModel = ParentalRatingsViewModel()
|
||||
|
||||
// MARK: - Item
|
||||
|
||||
@Binding
|
||||
var item: BaseItemDto
|
||||
|
||||
// MARK: - Ratings States
|
||||
|
||||
@State
|
||||
private var officialRatings: [ParentalRating] = []
|
||||
@State
|
||||
|
@ -31,7 +36,7 @@ extension EditMetadataView {
|
|||
var body: some View {
|
||||
Section(L10n.parentalRating) {
|
||||
|
||||
// MARK: Official Rating Picker
|
||||
// MARK: - Official Rating Picker
|
||||
|
||||
Picker(
|
||||
L10n.officialRating,
|
||||
|
@ -53,7 +58,7 @@ extension EditMetadataView {
|
|||
updateOfficialRatings()
|
||||
}
|
||||
|
||||
// MARK: Custom Rating Picker
|
||||
// MARK: - Custom Rating Picker
|
||||
|
||||
Picker(
|
||||
L10n.customRating,
|
||||
|
|
|
@ -17,10 +17,12 @@ extension EditMetadataView {
|
|||
@Binding
|
||||
var item: BaseItemDto
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Section(L10n.reviews) {
|
||||
|
||||
// MARK: Critics Rating
|
||||
// MARK: - Critics Rating
|
||||
|
||||
ChevronAlertButton(
|
||||
L10n.critics,
|
||||
|
@ -40,7 +42,7 @@ extension EditMetadataView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Community Rating
|
||||
// MARK: - Community Rating
|
||||
|
||||
ChevronAlertButton(
|
||||
L10n.community,
|
||||
|
|
|
@ -12,12 +12,16 @@ import SwiftUI
|
|||
|
||||
struct EditMetadataView: View {
|
||||
|
||||
// MARK: - Observed & Environment Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: BasicNavigationViewCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
private var viewModel: ItemEditorViewModel<BaseItemDto>
|
||||
|
||||
// MARK: - Metadata Variables
|
||||
|
||||
@Binding
|
||||
var item: BaseItemDto
|
||||
|
||||
|
@ -26,6 +30,11 @@ struct EditMetadataView: View {
|
|||
|
||||
private let itemType: BaseItemKind
|
||||
|
||||
// MARK: - Error State
|
||||
|
||||
@State
|
||||
private var error: Error?
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(viewModel: ItemEditorViewModel<BaseItemDto>) {
|
||||
|
@ -39,21 +48,47 @@ struct EditMetadataView: View {
|
|||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
contentView
|
||||
.navigationBarTitle(L10n.metadata)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.topBarTrailing {
|
||||
Button(L10n.save) {
|
||||
item = tempItem
|
||||
viewModel.send(.update(tempItem))
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
.disabled(viewModel.item == tempItem)
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .initial, .content, .updating:
|
||||
contentView
|
||||
case let .error(error):
|
||||
errorView(with: error)
|
||||
}
|
||||
.navigationBarCloseButton {
|
||||
}
|
||||
.navigationBarTitle(L10n.metadata)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.topBarTrailing {
|
||||
Button(L10n.save) {
|
||||
item = tempItem
|
||||
viewModel.send(.update(tempItem))
|
||||
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
|
||||
|
|
|
@ -24,12 +24,19 @@ struct ItemEditorView: View {
|
|||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
contentView
|
||||
.navigationBarTitle(L10n.metadata)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
router.dismissCoordinator()
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .initial, .content, .refreshing:
|
||||
contentView
|
||||
case let .error(error):
|
||||
errorView(with: error)
|
||||
}
|
||||
}
|
||||
.navigationBarTitle(L10n.metadata)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
private var refreshButtonView: some View {
|
||||
Section {
|
||||
|
@ -74,6 +93,8 @@ struct ItemEditorView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Editable Routing Buttons
|
||||
|
||||
@ViewBuilder
|
||||
private var editView: some View {
|
||||
Section(L10n.edit) {
|
||||
|
|
|
@ -25,31 +25,39 @@ extension CustomizeViewsSettings {
|
|||
private var enableCollectionManagement
|
||||
|
||||
var body: some View {
|
||||
Section(L10n.items) {
|
||||
/// Enable Editing Items from All Visible LIbraries
|
||||
if userSession?.user.permissions.items.canEditMetadata ?? false {
|
||||
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing)
|
||||
}
|
||||
/// Enable Downloading All Items
|
||||
/* if userSession?.user.permissions.items.canDownload ?? false {
|
||||
if userSession?.user.permissions.items.canEditMetadata ?? false
|
||||
|| userSession?.user.permissions.items.canDelete ?? false
|
||||
// || userSession?.user.permissions.items.canDownload ?? false
|
||||
|| userSession?.user.permissions.items.canManageCollections ?? false
|
||||
// || userSession?.user.permissions.items.canManageLyrics ?? false
|
||||
// || userSession?.user.permissions.items.canManageSubtitles
|
||||
{
|
||||
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)
|
||||
} */
|
||||
/// Enable Deleting or Editing Collections
|
||||
if userSession?.user.permissions.items.canManageCollections ?? false {
|
||||
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement)
|
||||
}
|
||||
/// Manage Item Lyrics
|
||||
/* if userSession?.user.permissions.items.canManageLyrics ?? false {
|
||||
} */
|
||||
/// Enable Deleting or Editing Collections
|
||||
if userSession?.user.permissions.items.canManageCollections ?? false {
|
||||
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement)
|
||||
}
|
||||
/// Manage Item Lyrics
|
||||
/* if userSession?.user.permissions.items.canManageLyrics ?? false {
|
||||
Toggle(L10n.allowLyricsManagement isOn: $enableLyricsManagement)
|
||||
} */
|
||||
/// Manage Item Subtitles
|
||||
/* if userSession?.user.items.canManageSubtitles ?? false {
|
||||
} */
|
||||
/// Manage Item Subtitles
|
||||
/* if userSession?.user.items.canManageSubtitles ?? false {
|
||||
Toggle(L10n.allowSubtitleManagement, isOn: $enableSubtitleManagement)
|
||||
} */
|
||||
}
|
||||
/// Enable Deleting Items from Approved Libraries
|
||||
if userSession?.user.permissions.items.canDelete ?? false {
|
||||
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
|
||||
} */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue