diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index fc73e711..8a028185 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -28,6 +28,8 @@ final class ItemCoordinator: NavigationCoordinatable { @Route(.modal) var itemOverview = makeItemOverview @Route(.modal) + var itemEditor = makeItemEditor + @Route(.modal) var mediaSourceInfo = makeMediaSourceInfo @Route(.modal) var downloadTask = makeDownloadTask @@ -78,6 +80,10 @@ final class ItemCoordinator: NavigationCoordinatable { } #if os(iOS) + func makeItemEditor(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemEditorCoordinator(item: item)) + } + func makeDownloadTask(downloadTask: DownloadTask) -> NavigationViewCoordinator { NavigationViewCoordinator(DownloadTaskCoordinator(downloadTask: downloadTask)) } diff --git a/Shared/Coordinators/ItemEditorCoordinator.swift b/Shared/Coordinators/ItemEditorCoordinator.swift new file mode 100644 index 00000000..c7b785b0 --- /dev/null +++ b/Shared/Coordinators/ItemEditorCoordinator.swift @@ -0,0 +1,30 @@ +// +// 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 Stinsen +import SwiftUI + +final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable { + + let stack = NavigationStack(initial: \ItemEditorCoordinator.start) + + @Root + var start = makeStart + + private let item: BaseItemDto + + init(item: BaseItemDto) { + self.item = item + } + + @ViewBuilder + func makeStart() -> some View { + ItemEditorView(item: item) + } +} diff --git a/Shared/Services/SwiftfinNotifications.swift b/Shared/Services/SwiftfinNotifications.swift index 9c16a616..8d30b367 100644 --- a/Shared/Services/SwiftfinNotifications.swift +++ b/Shared/Services/SwiftfinNotifications.swift @@ -81,6 +81,7 @@ extension Notifications.Key { static let didFailMigration = NotificationKey("didFailMigration") static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange") + static let didDeleteItem = NotificationKey("didDeleteItem") static let didConnectToServer = NotificationKey("didConnectToServer") static let didDeleteServer = NotificationKey("didDeleteServer") diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 0725790e..f61d1987 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -52,6 +52,10 @@ internal enum L10n { internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres") /// All Media internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") + /// Allow media item deletion + internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion") + /// Allow media item editing + internal static let allowItemEditing = L10n.tr("Localizable", "allowItemEditing", fallback: "Allow media item editing") /// Select Server View - Select All Servers internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers") /// View and manage all registered users on the server, including their permissions and activity status. @@ -304,6 +308,8 @@ internal enum L10n { } /// Are you sure you wish to delete this device? This session will be logged out. internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.") + /// Are you sure you want to delete this item? This action cannot be undone. + internal static let deleteItemConfirmationMessage = L10n.tr("Localizable", "deleteItemConfirmationMessage", fallback: "Are you sure you want to delete this item? This action cannot be undone.") /// Delete Selected Devices internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices") /// Delete Selected Users @@ -410,6 +416,10 @@ internal enum L10n { internal static let filterResults = L10n.tr("Localizable", "filterResults", fallback: "Filter Results") /// Filters internal static let filters = L10n.tr("Localizable", "filters", fallback: "Filters") + /// Find Missing + internal static let findMissing = L10n.tr("Localizable", "findMissing", fallback: "Find Missing") + /// Find missing metadata and images. + internal static let findMissingDescription = L10n.tr("Localizable", "findMissingDescription", fallback: "Find missing metadata and images.") /// Transcode FPS internal static func fpsWithString(_ p1: Any) -> String { return L10n.tr("Localizable", "fpsWithString", String(describing: p1), fallback: "%@fps") @@ -448,6 +458,8 @@ internal enum L10n { internal static func itemAtItem(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "itemAtItem", String(describing: p1), String(describing: p2), fallback: "%1$@ at %2$@") } + /// You do not have permission to delete this item. + internal static let itemDeletionPermissionFailure = L10n.tr("Localizable", "itemDeletionPermissionFailure", fallback: "You do not have permission to delete this item.") /// SessionPlaybackMethod Remaining Time internal static func itemOverItem(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "itemOverItem", String(describing: p1), String(describing: p2), fallback: "%1$@ / %2$@") @@ -490,6 +502,8 @@ internal enum L10n { internal static func latestWithString(_ p1: Any) -> String { return L10n.tr("Localizable", "latestWithString", String(describing: p1), fallback: "Latest %@") } + /// Learn more... + internal static let learnMoreEllipsis = L10n.tr("Localizable", "learnMoreEllipsis", fallback: "Learn more...") /// Left internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left") /// Letter Picker @@ -526,6 +540,8 @@ internal enum L10n { internal static let media = L10n.tr("Localizable", "media", fallback: "Media") /// Menu Buttons internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons") + /// Metadata + internal static let metadata = L10n.tr("Localizable", "metadata", fallback: "Metadata") /// The play method (e.g., Direct Play, Transcoding) internal static let method = L10n.tr("Localizable", "method", fallback: "Method") /// Minutes @@ -732,6 +748,8 @@ internal enum L10n { internal static let refFramesNotSupported = L10n.tr("Localizable", "refFramesNotSupported", fallback: "The number of reference frames is not supported") /// Refresh internal static let refresh = L10n.tr("Localizable", "refresh", fallback: "Refresh") + /// Refresh Metadata + internal static let refreshMetadata = L10n.tr("Localizable", "refreshMetadata", fallback: "Refresh Metadata") /// Regular internal static let regular = L10n.tr("Localizable", "regular", fallback: "Regular") /// Released @@ -752,6 +770,18 @@ internal enum L10n { internal static let removeFromResume = L10n.tr("Localizable", "removeFromResume", fallback: "Remove From Resume") /// PlayMethod - Remux internal static let remux = L10n.tr("Localizable", "remux", fallback: "Remux") + /// Replace All + internal static let replaceAll = L10n.tr("Localizable", "replaceAll", fallback: "Replace All") + /// Replace all unlocked metadata and images with new information. + internal static let replaceAllDescription = L10n.tr("Localizable", "replaceAllDescription", fallback: "Replace all unlocked metadata and images with new information.") + /// Replace Images + internal static let replaceImages = L10n.tr("Localizable", "replaceImages", fallback: "Replace Images") + /// Replace all images with new images. + internal static let replaceImagesDescription = L10n.tr("Localizable", "replaceImagesDescription", fallback: "Replace all images with new images.") + /// Replace Metadata + internal static let replaceMetadata = L10n.tr("Localizable", "replaceMetadata", fallback: "Replace Metadata") + /// Replace unlocked metadata with new information. + internal static let replaceMetadataDescription = L10n.tr("Localizable", "replaceMetadataDescription", fallback: "Replace unlocked metadata with new information.") /// Report an Issue internal static let reportIssue = L10n.tr("Localizable", "reportIssue", fallback: "Report an Issue") /// Request a Feature diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift index 2d994e1f..13f5cc6d 100644 --- a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -148,5 +148,21 @@ extension StoredValues.Keys { default: [] ) } + + static var enableItemEditor: Key { + CurrentUserKey( + "enableItemEditor", + domain: "enableItemEditor", + default: false + ) + } + + static var enableItemDeletion: Key { + CurrentUserKey( + "enableItemDeletion", + domain: "enableItemDeletion", + default: false + ) + } } } diff --git a/Shared/SwiftfinStore/SwiftinStore+UserState.swift b/Shared/SwiftfinStore/SwiftinStore+UserState.swift index 4737478b..eef4f440 100644 --- a/Shared/SwiftfinStore/SwiftinStore+UserState.swift +++ b/Shared/SwiftfinStore/SwiftinStore+UserState.swift @@ -68,6 +68,11 @@ extension UserState { data.policy?.isAdministrator ?? false } + // Validate that the use has permission to delete something whether from a folder or all folders + var hasDeletionPermissions: Bool { + data.policy?.enableContentDeletion ?? false || data.policy?.enableContentDeletionFromFolders != [] + } + var pinHint: String { get { StoredValues[.User.pinHint(id: id)] diff --git a/Shared/ViewModels/ItemEditorViewModel/DeleteItemViewModel.swift b/Shared/ViewModels/ItemEditorViewModel/DeleteItemViewModel.swift new file mode 100644 index 00000000..0e63da81 --- /dev/null +++ b/Shared/ViewModels/ItemEditorViewModel/DeleteItemViewModel.swift @@ -0,0 +1,111 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI + +class DeleteItemViewModel: ViewModel, Stateful, Eventful { + + // MARK: Events + + enum Event: Equatable { + case error(JellyfinAPIError) + case deleted + } + + // MARK: Action + + enum Action: Equatable { + case error(JellyfinAPIError) + case delete + } + + // MARK: State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + case refreshing + } + + @Published + var item: BaseItemDto? + + @Published + final var state: State = .initial + + private var deleteTask: AnyCancellable? + + // MARK: Event Variables + + private var eventSubject: PassthroughSubject = .init() + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + // MARK: Init + + init(item: BaseItemDto) { + self.item = item + super.init() + } + + // MARK: Respond + + func respond(to action: Action) -> State { + switch action { + case let .error(error): + return .error(error) + + case .delete: + deleteTask?.cancel() + + deleteTask = Task { [weak self] in + guard let self = self else { return } + do { + try await self.deleteItem() + await MainActor.run { + self.state = .content + 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))) + } + } + } + .asAnyCancellable() + + return .refreshing + } + } + + // MARK: Metadata Refresh Logic + + private func deleteItem() async throws { + guard let itemID = item?.id else { + throw JellyfinAPIError(L10n.unknownError) + } + + let request = Paths.deleteItem(itemID: itemID) + _ = try await userSession.client.send(request) + + await MainActor.run { + Notifications[.didDeleteItem].post(object: item) + self.item = nil + } + } +} diff --git a/Shared/ViewModels/ItemEditorViewModel/RefreshMetadataViewModel.swift b/Shared/ViewModels/ItemEditorViewModel/RefreshMetadataViewModel.swift new file mode 100644 index 00000000..575c9b1e --- /dev/null +++ b/Shared/ViewModels/ItemEditorViewModel/RefreshMetadataViewModel.swift @@ -0,0 +1,175 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI + +class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { + + // MARK: Events + + enum Event: Equatable { + case error(JellyfinAPIError) + case refreshTriggered + } + + // MARK: Action + + enum Action: Equatable { + case error(JellyfinAPIError) + case refreshMetadata( + metadataRefreshMode: MetadataRefreshMode, + imageRefreshMode: MetadataRefreshMode, + replaceMetadata: Bool, + replaceImages: Bool + ) + } + + // MARK: State + + 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 + + private var itemTask: AnyCancellable? + private var eventSubject = PassthroughSubject() + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + // MARK: Init + + init(item: BaseItemDto) { + self.item = item + super.init() + } + + // 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() + + itemTask = Task { [weak self] in + guard let self else { return } + do { + await MainActor.run { + self.state = .content + self.eventSubject.send(.refreshTriggered) + } + + try await self.refreshMetadata( + metadataRefreshMode: metadataRefreshMode, + imageRefreshMode: imageRefreshMode, + replaceMetadata: replaceMetadata, + replaceImages: replaceImages + ) + + await MainActor.run { + self.state = .refreshing + self.eventSubject.send(.refreshTriggered) + } + + try await self.refreshItem() + + await MainActor.run { + self.state = .content + } + + } catch { + guard !Task.isCancelled else { return } + + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .error(apiError) + self.eventSubject.send(.error(apiError)) + } + } + } + .asAnyCancellable() + + return .refreshing + } + } + + // MARK: Metadata Refresh Logic + + private func refreshMetadata( + metadataRefreshMode: MetadataRefreshMode, + imageRefreshMode: MetadataRefreshMode, + replaceMetadata: Bool = false, + replaceImages: Bool = false + ) async throws { + guard let itemId = item.id else { return } + + var parameters = Paths.RefreshItemParameters() + parameters.metadataRefreshMode = metadataRefreshMode + parameters.imageRefreshMode = imageRefreshMode + parameters.isReplaceAllMetadata = replaceMetadata + parameters.isReplaceAllImages = replaceImages + + let request = Paths.refreshItem( + itemID: itemId, + parameters: parameters + ) + _ = try await userSession.client.send(request) + } + + // MARK: Refresh Item After Request Queued + + private func refreshItem() async throws { + guard let itemId = item.id else { return } + + 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. + for i in 1 ... steps { + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + + let currentProgress = Double(i) / Double(steps) + await MainActor.run { + 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(object: itemId) + } + } +} diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift index 7bd88e7e..e0a1e93c 100644 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -159,6 +159,13 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { super.init() + Notifications[.didDeleteItem].publisher + .sink(receiveCompletion: { _ in }) { [weak self] notification in + guard let item = notification.object as? Element else { return } + self?.elements.remove(item) + } + .store(in: &cancellables) + if let filterViewModel { filterViewModel.$currentFilters .dropFirst() diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 0ba90610..965bd330 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; + 4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; }; 4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; 4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; @@ -73,6 +74,13 @@ 4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; + 4E8F74A22CE03C9000CC8969 /* ItemEditorCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */; }; + 4E8F74A52CE03D3C00CC8969 /* ItemEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */; }; + 4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */; }; + 4E8F74AC2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */; }; + 4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */; }; + 4E8F74B12CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */; }; + 4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */; }; 4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */; }; 4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */; }; 4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */; }; @@ -123,6 +131,7 @@ 4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; }; 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; }; 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; }; + 4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; }; @@ -1066,6 +1075,7 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; + 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = ""; }; 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = ""; }; 4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilitiesSection.swift; sourceTree = ""; }; @@ -1112,6 +1122,11 @@ 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; + 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorCoordinator.swift; sourceTree = ""; }; + 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorView.swift; sourceTree = ""; }; + 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteItemViewModel.swift; sourceTree = ""; }; + 4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataButton.swift; sourceTree = ""; }; + 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataViewModel.swift; sourceTree = ""; }; 4E90F7592CC72B1F00417C31 /* DetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsSection.swift; sourceTree = ""; }; 4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastErrorSection.swift; sourceTree = ""; }; 4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastRunSection.swift; sourceTree = ""; }; @@ -1156,6 +1171,7 @@ 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = ""; }; 4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = ""; }; + 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreButton.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -2073,6 +2089,7 @@ 4E699BB72CB33FB0007CBD5D /* Sections */ = { isa = PBXGroup; children = ( + 4E0195E32CE04678007844F4 /* ItemSection.swift */, 4E699BB82CB33FB5007CBD5D /* HomeSection.swift */, ); path = Sections; @@ -2113,6 +2130,32 @@ path = ActiveSessionDetailView; sourceTree = ""; }; + 4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = { + isa = PBXGroup; + children = ( + 4E8F74A62CE03D4C00CC8969 /* Components */, + 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */, + ); + path = ItemEditorView; + sourceTree = ""; + }; + 4E8F74A62CE03D4C00CC8969 /* Components */ = { + isa = PBXGroup; + children = ( + 4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */ = { + isa = PBXGroup; + children = ( + 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */, + 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */, + ); + path = ItemEditorViewModel; + sourceTree = ""; + }; 4E90F75E2CC72B1F00417C31 /* Sections */ = { isa = PBXGroup; children = ( @@ -2322,6 +2365,7 @@ E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, + 4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */, E107BB9127880A4000354E07 /* ItemViewModel */, E1EDA8D52B924CA500F9A57E /* LibraryViewModel */, C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */, @@ -2719,6 +2763,7 @@ E178B0752BE435D70023651B /* HourMinutePicker.swift */, E1DC7AC92C63337C00AEE368 /* iOS15View.swift */, E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */, + 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */, 4E16FD4E2C0183B500110147 /* LetterPickerBar */, E1A8FDEB2C0574A800D0A51C /* ListRow.swift */, E1AEFA362BE317E200CFAFD8 /* ListRowButton.swift */, @@ -2809,6 +2854,7 @@ 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */, 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */, 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, + 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */, 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */, E102312B2BCF8A08009D71FC /* LiveTVCoordinator */, C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */, @@ -3296,6 +3342,7 @@ 62C83B07288C6A630004ED0C /* FontPickerView.swift */, E168BD07289A4162001A6922 /* HomeView */, E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */, + 4E8F74A32CE03D3100CC8969 /* ItemEditorView */, E14F7D0A26DB3714007C3AE6 /* ItemView */, E170D104294D21FA0017224C /* MediaSourceInfoView.swift */, E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, @@ -4634,6 +4681,7 @@ E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */, E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */, E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */, + 4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */, 4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */, @@ -4871,6 +4919,7 @@ E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */, E1AEFA382BE36C4900CFAFD8 /* SwiftinStore+UserState.swift in Sources */, E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, + 4E8F74B12CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */, E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, @@ -5099,6 +5148,7 @@ E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */, 4E14DC032CD43DD2001B621B /* AdminDashboardCoordinator.swift in Sources */, E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, + 4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */, E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */, E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, @@ -5116,12 +5166,14 @@ E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E15756322935642A00976E1F /* Double.swift in Sources */, + 4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */, E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */, E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */, E1E6C45029B104840064123F /* Button.swift in Sources */, 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */, E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */, + 4E8F74A52CE03D3C00CC8969 /* ItemEditorView.swift in Sources */, E10432F62BE4426F006FF9DD /* FormatStyle.swift in Sources */, E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */, E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, @@ -5226,10 +5278,13 @@ 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, 4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */, 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */, + 4E8F74AC2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */, 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, + 4E8F74A22CE03C9000CC8969 /* ItemEditorCoordinator.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */, + 4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */, E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, @@ -5449,6 +5504,7 @@ E18E01EA288747230022598C /* MovieItemView.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */, + 4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */, E148128528C15472003B8787 /* SortOrder+ItemSortOrder.swift in Sources */, E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */, E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, diff --git a/Swiftfin/Components/LearnMoreButton.swift b/Swiftfin/Components/LearnMoreButton.swift new file mode 100644 index 00000000..e3830045 --- /dev/null +++ b/Swiftfin/Components/LearnMoreButton.swift @@ -0,0 +1,62 @@ +// +// 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 LearnMoreButton: View { + + @State + private var isPresented: Bool = false + + private let title: String + private let items: [TextPair] + + // MARK: - Initializer + + init(_ title: String, @ArrayBuilder items: () -> [TextPair]) { + self.title = title + self.items = items() + } + + // MARK: - Body + + var body: some View { + Button(L10n.learnMoreEllipsis) { + isPresented = true + } + .foregroundStyle(Color.accentColor) + .font(.subheadline) + .sheet(isPresented: $isPresented) { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + ForEach(items) { content in + VStack(alignment: .leading, spacing: 8) { + Text(content.title) + .font(.headline) + .foregroundStyle(.primary) + + Text(content.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Divider() + } + } + .edgePadding() + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + isPresented = false + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift b/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift new file mode 100644 index 00000000..b388f2bc --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift @@ -0,0 +1,127 @@ +// +// 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 ItemEditorView { + + struct RefreshMetadataButton: View { + + // Bug in SwiftUI where Menu item icons will be black in dark mode + // when a HierarchicalShapeStyle is applied to the Buttons + @Environment(\.colorScheme) + private var colorScheme: ColorScheme + + @StateObject + private var viewModel: RefreshMetadataViewModel + + @State + private var isPresentingEventAlert = false + @State + private var error: JellyfinAPIError? + + // 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 + ) + ) + } + } + .foregroundStyle(colorScheme == .dark ? Color.white : Color.black) + } label: { + HStack { + Text(L10n.refreshMetadata) + .foregroundStyle(.primary) + + Spacer() + + if viewModel.state == .refreshing { + ProgressView(value: viewModel.progress) + .progressViewStyle(.gauge) + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + .frame(width: 25, height: 25) + } else { + Image(systemName: "arrow.clockwise") + .foregroundStyle(.secondary) + .backport + .fontWeight(.semibold) + } + } + } + .foregroundStyle(.primary, .secondary) + .disabled(viewModel.state == .refreshing || isPresentingEventAlert) + .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) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift new file mode 100644 index 00000000..01eba1b0 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift @@ -0,0 +1,73 @@ +// +// 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 Factory +import JellyfinAPI +import SwiftUI + +struct ItemEditorView: View { + + @Injected(\.currentUserSession) + private var userSession + + @EnvironmentObject + private var router: ItemEditorCoordinator.Router + + @State + var item: BaseItemDto + + // MARK: - Body + + var body: some View { + contentView + .navigationBarTitle(L10n.metadata) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + .onNotification(.itemMetadataDidChange) { notification in + guard let newItem = notification.object as? BaseItemDto else { return } + item = newItem + } + } + + // MARK: - Content View + + private var contentView: some View { + List { + ListTitleSection( + item.name ?? L10n.unknown, + description: item.path + ) + + Section { + RefreshMetadataButton(item: item) + .environment(\.isEnabled, userSession?.user.isAdministrator ?? false) + } footer: { + LearnMoreButton(L10n.metadata) { + TextPair( + title: L10n.findMissing, + subtitle: L10n.findMissingDescription + ) + TextPair( + title: L10n.replaceMetadata, + subtitle: L10n.replaceMetadataDescription + ) + TextPair( + title: L10n.replaceImages, + subtitle: L10n.replaceImagesDescription + ) + TextPair( + title: L10n.replaceAll, + subtitle: L10n.replaceAllDescription + ) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index a9141ccc..b28c9538 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -15,8 +15,34 @@ import SwiftUI struct ItemView: View { + @EnvironmentObject + private var router: ItemCoordinator.Router + @StateObject private var viewModel: ItemViewModel + @StateObject + private var deleteViewModel: DeleteItemViewModel + + @State + private var showConfirmationDialog = false + @State + private var isPresentingEventAlert = false + @State + private var error: JellyfinAPIError? + + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion: Bool + @StoredValue(.User.enableItemEditor) + private var enableItemEditor: Bool + + private var canDelete: Bool { + enableItemDeletion && viewModel.item.canDelete ?? false + } + + // As more menu items exist, this can either be expanded to include more validation or removed if there are permanent menu items. + private var enableMenu: Bool { + canDelete || enableItemEditor + } private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { switch item.type { @@ -36,6 +62,7 @@ struct ItemView: View { init(item: BaseItemDto) { self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) + self._deleteViewModel = StateObject(wrappedValue: DeleteItemViewModel(item: item)) } @ViewBuilder @@ -100,6 +127,59 @@ struct ItemView: View { if viewModel.backgroundStates.contains(.refresh) { ProgressView() } + if enableMenu { + itemActionMenu + } + } + .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 + isPresentingEventAlert = true + case .deleted: + router.dismissCoordinator() + } + } + .alert( + L10n.error, + isPresented: $isPresentingEventAlert, + presenting: error + ) { _ in + } message: { error in + Text(error.localizedDescription) } } + + @ViewBuilder + private var itemActionMenu: some View { + + Menu(L10n.options, systemImage: "ellipsis.circle") { + + if enableItemEditor { + Button(L10n.edit, systemImage: "pencil") { + router.route(to: \.itemEditor, viewModel.item) + } + } + + if canDelete { + Divider() + Button(L10n.delete, systemImage: "trash", role: .destructive) { + showConfirmationDialog = true + } + } + } + .labelStyle(.iconOnly) + .backport + .fontWeight(.semibold) + } } diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift new file mode 100644 index 00000000..ee9eeb31 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -0,0 +1,38 @@ +// +// 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.enableItemEditor) + private var enableItemEditor + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion + + var body: some View { + Section(L10n.items) { + + if userSession?.user.isAdministrator ?? false { + Toggle(L10n.allowItemEditing, isOn: $enableItemEditor) + } + + if userSession?.user.hasDeletionPermissions ?? false { + Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift index b4ae734b..64e3dc01 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -158,6 +158,8 @@ struct CustomizeViewsSettings: View { } } + ItemSection() + HomeSection() Section { diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index b7873745..70e9cb5c 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1213,6 +1213,61 @@ // Used as the button label in the options menu when there are users to edit "editUsers" = "Edit Users"; +// Refresh - Button +// Button title for the menu to refresh metadata +// Used as the label for the refresh metadata button +"refreshMetadata" = "Refresh Metadata"; + +// Find Missing - Menu Option +// Menu option for finding missing metadata +// Used to trigger a full metadata refresh +"findMissing" = "Find Missing"; + +// Replace Metadata - Menu Option +// Menu option for replacing existing metadata +// Used to trigger replacing metadata only +"replaceMetadata" = "Replace Metadata"; + +// Replace Images - Menu Option +// Menu option for replacing existing images +// Used to trigger replacing images only +"replaceImages" = "Replace Images"; + +// Replace All - Menu Option +// Menu option for replacing both metadata and images +// Used to trigger a full replacement of metadata and images +"replaceAll" = "Replace All"; + +// Delete Item Confirmation Message - Warning message +// Warning message to confirm deleting a media item +// Used in a confirmation for item deletion +"deleteItemConfirmationMessage" = "Are you sure you want to delete this item? This action cannot be undone."; + +// Allow Media Item Editing - Toggle +// Toggle option for enabling media item editing +// Used to allow users to edit metadata of media items +"allowItemEditing" = "Allow media item editing"; + +// Allow Media Item Deletion - Toggle +// Toggle option for enabling media item deletion +// Used to allow users to delete media items +"allowItemDeletion" = "Allow media item deletion"; + +// Item Deletion Permission Failure - Error Message +// Alert the user they should not be able to delete something +// Used to inform the user a deletion failed and why it failed +"itemDeletionPermissionFailure" = "You do not have permission to delete this item."; + +// Metadata - Section Title +// Title for the ItemEditorView and Metadata related views +// Used as a title for sections/views related to Metadata +"metadata" = "Metadata"; + +// Learn More - Button +// Opens a modal with more information +// Used as a button to show details +"learnMoreEllipsis" = "Learn more..."; + /// Current Password - Placeholder /// Placeholder text for the current password input field /// Used in the ResetUserPasswordView @@ -1247,3 +1302,23 @@ /// Message displayed to alert the user what the password change does and does not do /// Used in the ResetUserPasswordView "passwordChangeWarning" = "Changes the Jellyfin server user password. This does not change any Swiftfin settings."; + +/// Find Missing - Button +/// Search for missing metadata and images +/// Used to locate media files missing information or images +"findMissingDescription" = "Find missing metadata and images."; + +/// Replace Metadata - Button +/// Overwrite metadata without affecting images +/// Used when updating only metadata information +"replaceMetadataDescription" = "Replace unlocked metadata with new information."; + +/// Replace Images - Button +/// Overwrite existing images with new ones +/// Used to refresh media artwork +"replaceImagesDescription" = "Replace all images with new images."; + +/// Replace All - Button +/// Replace all metadata and images +/// Full refresh that replaces all unlocked metadata and images +"replaceAllDescription" = "Replace all unlocked metadata and images with new information.";