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

* Mirror tvOS to iOS

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

* reset dev team info

* View Modifier and ViewModel cleanup

* Remove testing comments / events

* Cleanup `.errorMessage($error)`

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

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

View File

@ -0,0 +1,35 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct ErrorMessageModifier: ViewModifier {
@Binding
var error: Error?
let dismissActions: (() -> Void)?
// MARK: - Body
func body(content: Content) -> some View {
content
.alert(
L10n.error.text,
isPresented: .constant(error != nil),
presenting: error
) { _ in
Button(L10n.dismiss, role: .cancel) {
error = nil
dismissActions?()
}
} message: { error in
Text(error.localizedDescription)
}
}
}

View File

@ -148,6 +148,15 @@ extension View {
modifier(BottomEdgeGradientModifier(bottomColor: bottomColor))
}
/// 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,105 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension ItemView {
struct RefreshMetadataButton: View {
// MARK: - State Object
@StateObject
private var viewModel: RefreshMetadataViewModel
// MARK: - Error State
@State
private var error: Error?
// MARK: - Initializer
init(item: BaseItemDto) {
_viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item))
}
// MARK: - Body
var body: some View {
Menu {
Group {
Button(L10n.findMissing, systemImage: "magnifyingglass") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .fullRefresh,
replaceMetadata: false,
replaceImages: false
)
)
}
Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .none,
replaceMetadata: true,
replaceImages: false
)
)
}
Button(L10n.replaceImages, systemImage: "photo") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .none,
imageRefreshMode: .fullRefresh,
replaceMetadata: false,
replaceImages: true
)
)
}
Button(L10n.replaceAll, systemImage: "staroflife") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .fullRefresh,
replaceMetadata: true,
replaceImages: true
)
)
}
}
} label: {
HStack {
Text(L10n.refreshMetadata)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "arrow.clockwise")
.foregroundStyle(.secondary)
.backport
.fontWeight(.semibold)
}
}
.foregroundStyle(.primary, .secondary)
.disabled(viewModel.state == .refreshing || error != nil)
.onReceive(viewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
}
}
.errorMessage($error)
}
}
}

View File

@ -0,0 +1,50 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import SwiftUI
extension CustomizeViewsSettings {
struct ItemSection: View {
@Injected(\.currentUserSession)
private var userSession
@StoredValue(.User.enableItemEditing)
private var enableItemEditing
@StoredValue(.User.enableItemDeletion)
private var enableItemDeletion
@StoredValue(.User.enableCollectionManagement)
private var enableCollectionManagement
var body: some View {
if userSession?.user.permissions.items.canEditMetadata ?? false ||
userSession?.user.permissions.items.canDelete ?? false ||
userSession?.user.permissions.items.canManageCollections ?? false
{
Section(L10n.items) {
/// Enable Refreshing Items from All Visible LIbraries
if userSession?.user.permissions.items.canEditMetadata ?? false {
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing)
}
/// Enable Deleting Items from Approved Libraries
if userSession?.user.permissions.items.canDelete ?? false {
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
}
/// Enable Refreshing & Deleting Collections
if userSession?.user.permissions.items.canManageCollections ?? false {
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement)
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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