[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))
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
HomeSection()
|
||||||
}
|
}
|
||||||
.withDescriptionTopPadding()
|
.withDescriptionTopPadding()
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue