diff --git a/Shared/Coordinators/ItemEditorCoordinator.swift b/Shared/Coordinators/ItemEditorCoordinator.swift index f682cc8ac..37e698eac 100644 --- a/Shared/Coordinators/ItemEditorCoordinator.swift +++ b/Shared/Coordinators/ItemEditorCoordinator.swift @@ -19,19 +19,131 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable { private let viewModel: ItemViewModel + // MARK: - Route to Metadata + @Route(.modal) var editMetadata = makeEditMetadata + // MARK: - Route to Genres + + @Route(.push) + var editGenres = makeEditGenres + @Route(.modal) + var addGenre = makeAddGenre + + // MARK: - Route to Tags + + @Route(.push) + var editTags = makeEditTags + @Route(.modal) + var addTag = makeAddTag + + // MARK: - Route to Studios + + @Route(.push) + var editStudios = makeEditStudios + @Route(.modal) + var addStudio = makeAddStudio + + // MARK: - Route to People + + @Route(.push) + var editPeople = makeEditPeople + @Route(.modal) + var addPeople = makeAddPeople + + // MARK: - Initializer + init(viewModel: ItemViewModel) { self.viewModel = viewModel } + // MARK: - Item Metadata + func makeEditMetadata(item: BaseItemDto) -> NavigationViewCoordinator { NavigationViewCoordinator { EditMetadataView(viewModel: ItemEditorViewModel(item: item)) } } + // MARK: - Item Genres + + @ViewBuilder + func makeEditGenres(item: BaseItemDto) -> some View { + EditItemElementView( + viewModel: GenreEditorViewModel(item: item), + type: .genres, + route: { router, viewModel in + router.route(to: \.addGenre, viewModel as! GenreEditorViewModel) + } + ) + } + + func makeAddGenre(viewModel: GenreEditorViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddItemElementView(viewModel: viewModel, type: .genres) + } + } + + // MARK: - Item Tags + + @ViewBuilder + func makeEditTags(item: BaseItemDto) -> some View { + EditItemElementView( + viewModel: TagEditorViewModel(item: item), + type: .tags, + route: { router, viewModel in + router.route(to: \.addTag, viewModel as! TagEditorViewModel) + } + ) + } + + func makeAddTag(viewModel: TagEditorViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddItemElementView(viewModel: viewModel, type: .tags) + } + } + + // MARK: - Item Studios + + @ViewBuilder + func makeEditStudios(item: BaseItemDto) -> some View { + EditItemElementView( + viewModel: StudioEditorViewModel(item: item), + type: .studios, + route: { router, viewModel in + router.route(to: \.addStudio, viewModel as! StudioEditorViewModel) + } + ) + } + + func makeAddStudio(viewModel: StudioEditorViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddItemElementView(viewModel: viewModel, type: .studios) + } + } + + // MARK: - Item People + + @ViewBuilder + func makeEditPeople(item: BaseItemDto) -> some View { + EditItemElementView( + viewModel: PeopleEditorViewModel(item: item), + type: .people, + route: { router, viewModel in + router.route(to: \.addPeople, viewModel as! PeopleEditorViewModel) + } + ) + } + + func makeAddPeople(viewModel: PeopleEditorViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddItemElementView(viewModel: viewModel, type: .people) + } + } + + // MARK: - Start + @ViewBuilder func makeStart() -> some View { ItemEditorView(viewModel: viewModel) diff --git a/Shared/Extensions/Collection.swift b/Shared/Extensions/Collection.swift index 85e9a28a9..c56236a57 100644 --- a/Shared/Extensions/Collection.swift +++ b/Shared/Extensions/Collection.swift @@ -21,4 +21,8 @@ extension Collection { subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } + + func keyed(using: KeyPath) -> [Key: Element] { + Dictionary(uniqueKeysWithValues: map { ($0[keyPath: using], $0) }) + } } diff --git a/Shared/Extensions/JellyfinAPI/PersonKind.swift b/Shared/Extensions/JellyfinAPI/PersonKind.swift new file mode 100644 index 000000000..bdd953118 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/PersonKind.swift @@ -0,0 +1,97 @@ +// +// 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 Foundation +import JellyfinAPI + +// TODO: No longer needed in 10.9+ +public enum PersonKind: String, Codable, CaseIterable { + case unknown = "Unknown" + case actor = "Actor" + case director = "Director" + case composer = "Composer" + case writer = "Writer" + case guestStar = "GuestStar" + case producer = "Producer" + case conductor = "Conductor" + case lyricist = "Lyricist" + case arranger = "Arranger" + case engineer = "Engineer" + case mixer = "Mixer" + case remixer = "Remixer" + case creator = "Creator" + case artist = "Artist" + case albumArtist = "AlbumArtist" + case author = "Author" + case illustrator = "Illustrator" + case penciller = "Penciller" + case inker = "Inker" + case colorist = "Colorist" + case letterer = "Letterer" + case coverArtist = "CoverArtist" + case editor = "Editor" + case translator = "Translator" +} + +// TODO: Still needed in 10.9+ +extension PersonKind: Displayable { + var displayTitle: String { + switch self { + case .unknown: + return L10n.unknown + case .actor: + return L10n.actor + case .director: + return L10n.director + case .composer: + return L10n.composer + case .writer: + return L10n.writer + case .guestStar: + return L10n.guestStar + case .producer: + return L10n.producer + case .conductor: + return L10n.conductor + case .lyricist: + return L10n.lyricist + case .arranger: + return L10n.arranger + case .engineer: + return L10n.engineer + case .mixer: + return L10n.mixer + case .remixer: + return L10n.remixer + case .creator: + return L10n.creator + case .artist: + return L10n.artist + case .albumArtist: + return L10n.albumArtist + case .author: + return L10n.author + case .illustrator: + return L10n.illustrator + case .penciller: + return L10n.penciller + case .inker: + return L10n.inker + case .colorist: + return L10n.colorist + case .letterer: + return L10n.letterer + case .coverArtist: + return L10n.coverArtist + case .editor: + return L10n.editor + case .translator: + return L10n.translator + } + } +} diff --git a/Shared/Objects/ItemArrayElements.swift b/Shared/Objects/ItemArrayElements.swift new file mode 100644 index 000000000..23b2df193 --- /dev/null +++ b/Shared/Objects/ItemArrayElements.swift @@ -0,0 +1,112 @@ +// +// 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 Foundation +import JellyfinAPI +import SwiftUI + +enum ItemArrayElements: Displayable { + case studios + case genres + case tags + case people + + // MARK: - Localized Title + + var displayTitle: String { + switch self { + case .studios: + return L10n.studios + case .genres: + return L10n.genres + case .tags: + return L10n.tags + case .people: + return L10n.people + } + } + + // MARK: - Localized Description + + var description: String { + switch self { + case .studios: + return L10n.studiosDescription + case .genres: + return L10n.genresDescription + case .tags: + return L10n.tagsDescription + case .people: + return L10n.peopleDescription + } + } + + // MARK: - Create Element from Components + + func createElement( + name: String, + id: String?, + personRole: String?, + personKind: String? + ) -> T { + switch self { + case .genres, .tags: + return name as! T + case .studios: + return NameGuidPair(id: id, name: name) as! T + case .people: + return BaseItemPerson( + id: id, + name: name, + role: personRole, + type: personKind + ) as! T + } + } + + // MARK: - Get the Element from the BaseItemDto Based on Type + + func getElement(for item: BaseItemDto) -> [T] { + switch self { + case .studios: + return item.studios as? [T] ?? [] + case .genres: + return item.genres as? [T] ?? [] + case .tags: + return item.tags as? [T] ?? [] + case .people: + return item.people as? [T] ?? [] + } + } + + // MARK: - Get the Name from the Element Based on Type + + func getId(for element: AnyHashable) -> String? { + switch self { + case .genres, .tags: + return nil + case .studios: + return (element.base as? NameGuidPair)?.id + case .people: + return (element.base as? BaseItemPerson)?.id + } + } + + // MARK: - Get the Id from the Element Based on Type + + func getName(for element: AnyHashable) -> String { + switch self { + case .genres, .tags: + return element.base as? String ?? L10n.unknown + case .studios: + return (element.base as? NameGuidPair)?.name ?? L10n.unknown + case .people: + return (element.base as? BaseItemPerson)?.name ?? L10n.unknown + } + } +} diff --git a/Shared/Objects/LibraryDisplayType.swift b/Shared/Objects/LibraryDisplayType.swift index 175856b46..472b8fd14 100644 --- a/Shared/Objects/LibraryDisplayType.swift +++ b/Shared/Objects/LibraryDisplayType.swift @@ -19,9 +19,9 @@ enum LibraryDisplayType: String, CaseIterable, Displayable, Storable, SystemImag var displayTitle: String { switch self { case .grid: - "Grid" + L10n.grid case .list: - "List" + L10n.list } } diff --git a/Shared/Objects/Trie.swift b/Shared/Objects/Trie.swift new file mode 100644 index 000000000..eb194709b --- /dev/null +++ b/Shared/Objects/Trie.swift @@ -0,0 +1,69 @@ +// +// 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 +// + +class Trie where Key.Element: Hashable { + + class TrieNode { + var children: [Key.Element: TrieNode] = [:] + var isLeafNode: Bool = false + var elements: [Element] = [] + } + + private let root = TrieNode() +} + +extension Trie { + + func contains(key: Key) -> Bool { + var currentNode = root + + for key in key { + guard let nextNode = currentNode.children[key] else { + return false + } + currentNode = nextNode + } + + return currentNode.isLeafNode + } + + func insert(key: Key, element: Element) { + var currentNode = root + + for key in key { + if currentNode.children[key] == nil { + currentNode.children[key] = TrieNode() + } + currentNode = currentNode.children[key]! + currentNode.elements.append(element) + } + currentNode.isLeafNode = true + } + + func insert(contentsOf contents: [Key: Element]) { + for (key, element) in contents { + insert(key: key, element: element) + } + } + + func search(prefix: Key) -> [Element] { + + guard prefix.isNotEmpty else { return [] } + + var currentNode = root + + for key in prefix { + guard let nextNode = currentNode.children[key] else { + return [] + } + currentNode = nextNode + } + + return currentNode.elements + } +} diff --git a/Shared/Objects/UserPermissions.swift b/Shared/Objects/UserPermissions.swift new file mode 100644 index 000000000..706f5c3e5 --- /dev/null +++ b/Shared/Objects/UserPermissions.swift @@ -0,0 +1,41 @@ +// +// 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 + +struct UserPermissions { + + let isAdministrator: Bool + let items: UserItemPermissions + + init(_ policy: UserPolicy?) { + self.isAdministrator = policy?.isAdministrator ?? false + self.items = UserItemPermissions(policy, isAdministrator: isAdministrator) + } + + struct UserItemPermissions { + + let canDelete: Bool + let canDownload: Bool + let canEditMetadata: Bool + let canManageSubtitles: Bool + let canManageCollections: Bool + let canManageLyrics: Bool + + init(_ policy: UserPolicy?, isAdministrator: Bool) { + self.canDelete = policy?.enableContentDeletion ?? false || policy?.enableContentDeletionFromFolders != [] + self.canDownload = policy?.enableContentDownloading ?? false + self.canEditMetadata = isAdministrator + // TODO: SDK 10.9 Enable Comments + self.canManageSubtitles = isAdministrator // || policy?.enableSubtitleManagement ?? false + self.canManageCollections = isAdministrator // || policy?.enableCollectionManagement ?? false + // TODO: SDK 10.10 Enable Comments + self.canManageLyrics = isAdministrator // || policy?.enableSubtitleManagement ?? false + } + } +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 8a71b2aa6..8957324b8 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -28,6 +28,8 @@ internal enum L10n { internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices") /// Activity internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity") + /// Actor + internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor") /// Add internal static let add = L10n.tr("Localizable", "add", fallback: "Add") /// Add API key @@ -54,12 +56,16 @@ internal enum L10n { internal static func airWithDate(_ p1: UnsafePointer) -> String { return L10n.tr("Localizable", "airWithDate", p1, fallback: "Airs %s") } + /// Album Artist + internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "Album Artist") /// View all past and present devices that have connected. internal static let allDevicesDescription = L10n.tr("Localizable", "allDevicesDescription", fallback: "View all past and present devices that have connected.") /// All Genres 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 collection management + internal static let allowCollectionManagement = L10n.tr("Localizable", "allowCollectionManagement", fallback: "Allow collection management") /// Allow media item deletion internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion") /// Allow media item editing @@ -92,6 +98,10 @@ internal enum L10n { internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "Application Name") /// Apply internal static let apply = L10n.tr("Localizable", "apply", fallback: "Apply") + /// Arranger + internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger") + /// Artist + internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist") /// Aspect Fill internal static let aspectFill = L10n.tr("Localizable", "aspectFill", fallback: "Aspect Fill") /// Audio @@ -118,6 +128,8 @@ internal enum L10n { internal static let audioTrack = L10n.tr("Localizable", "audioTrack", fallback: "Audio Track") /// Audio transcoding internal static let audioTranscoding = L10n.tr("Localizable", "audioTranscoding", fallback: "Audio transcoding") + /// Author + internal static let author = L10n.tr("Localizable", "author", fallback: "Author") /// Authorize internal static let authorize = L10n.tr("Localizable", "authorize", fallback: "Authorize") /// Auto @@ -232,6 +244,8 @@ internal enum L10n { internal static let collections = L10n.tr("Localizable", "collections", fallback: "Collections") /// Color internal static let color = L10n.tr("Localizable", "color", fallback: "Color") + /// Colorist + internal static let colorist = L10n.tr("Localizable", "colorist", fallback: "Colorist") /// Columns internal static let columns = L10n.tr("Localizable", "columns", fallback: "Columns") /// Coming soon @@ -250,6 +264,10 @@ internal enum L10n { internal static let compatible = L10n.tr("Localizable", "compatible", fallback: "Most Compatible") /// Converts all media to H.264 video and AAC audio for maximum compatibility. May require server transcoding for non-compatible media types. internal static let compatibleDescription = L10n.tr("Localizable", "compatibleDescription", fallback: "Converts all media to H.264 video and AAC audio for maximum compatibility. May require server transcoding for non-compatible media types.") + /// Composer + internal static let composer = L10n.tr("Localizable", "composer", fallback: "Composer") + /// Conductor + internal static let conductor = L10n.tr("Localizable", "conductor", fallback: "Conductor") /// Confirm internal static let confirm = L10n.tr("Localizable", "confirm", fallback: "Confirm") /// Confirm Close @@ -288,12 +306,16 @@ internal enum L10n { internal static let controlSharedDevices = L10n.tr("Localizable", "controlSharedDevices", fallback: "Control shared devices") /// Country internal static let country = L10n.tr("Localizable", "country", fallback: "Country") + /// Cover Artist + internal static let coverArtist = L10n.tr("Localizable", "coverArtist", fallback: "Cover Artist") /// Create & Join Groups internal static let createAndJoinGroups = L10n.tr("Localizable", "createAndJoinGroups", fallback: "Create & Join Groups") /// Create API Key internal static let createAPIKey = L10n.tr("Localizable", "createAPIKey", fallback: "Create API Key") /// Enter the application name for the new API key. internal static let createAPIKeyMessage = L10n.tr("Localizable", "createAPIKeyMessage", fallback: "Enter the application name for the new API key.") + /// Creator + internal static let creator = L10n.tr("Localizable", "creator", fallback: "Creator") /// Critics internal static let critics = L10n.tr("Localizable", "critics", fallback: "Critics") /// Current @@ -376,8 +398,12 @@ 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? + internal static let deleteItemConfirmation = L10n.tr("Localizable", "deleteItemConfirmation", fallback: "Are you sure you want to delete this item?") /// 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.") + /// Are you sure you want to delete the selected items? + internal static let deleteSelectedConfirmation = L10n.tr("Localizable", "deleteSelectedConfirmation", fallback: "Are you sure you want to delete the selected items?") /// Delete Selected Devices internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices") /// Delete Selected Users @@ -422,8 +448,8 @@ internal enum L10n { internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play") /// Plays content in its original format. May cause playback issues on unsupported media types. internal static let directDescription = L10n.tr("Localizable", "directDescription", fallback: "Plays content in its original format. May cause playback issues on unsupported media types.") - /// DIRECTOR - internal static let director = L10n.tr("Localizable", "director", fallback: "DIRECTOR") + /// Director + internal static let director = L10n.tr("Localizable", "director", fallback: "Director") /// Direct Play internal static let directPlay = L10n.tr("Localizable", "directPlay", fallback: "Direct Play") /// An error occurred during direct play @@ -450,6 +476,8 @@ internal enum L10n { internal static let edit = L10n.tr("Localizable", "edit", fallback: "Edit") /// Edit Jump Lengths internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths") + /// Editor + internal static let editor = L10n.tr("Localizable", "editor", fallback: "Editor") /// Edit Server internal static let editServer = L10n.tr("Localizable", "editServer", fallback: "Edit Server") /// Edit Users @@ -464,6 +492,8 @@ internal enum L10n { internal static let endDate = L10n.tr("Localizable", "endDate", fallback: "End Date") /// Ended internal static let ended = L10n.tr("Localizable", "ended", fallback: "Ended") + /// Engineer + internal static let engineer = L10n.tr("Localizable", "engineer", fallback: "Engineer") /// Enter custom bitrate in Mbps internal static let enterCustomBitrate = L10n.tr("Localizable", "enterCustomBitrate", fallback: "Enter custom bitrate in Mbps") /// Enter custom failed logins limit @@ -498,10 +528,14 @@ internal enum L10n { } /// Executed internal static let executed = L10n.tr("Localizable", "executed", fallback: "Executed") + /// Existing items + internal static let existingItems = L10n.tr("Localizable", "existingItems", fallback: "Existing items") /// Existing Server internal static let existingServer = L10n.tr("Localizable", "existingServer", fallback: "Existing Server") /// Existing User internal static let existingUser = L10n.tr("Localizable", "existingUser", fallback: "Existing User") + /// This item exists on your Jellyfin Server. + internal static let existsOnServer = L10n.tr("Localizable", "existsOnServer", fallback: "This item exists on your Jellyfin Server.") /// Experimental internal static let experimental = L10n.tr("Localizable", "experimental", fallback: "Experimental") /// Failed logins @@ -542,6 +576,8 @@ internal enum L10n { internal static let fullTopAndBottom = L10n.tr("Localizable", "fullTopAndBottom", fallback: "Full Top and Bottom") /// Genres internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres") + /// Categories that describe the themes or styles of media. + internal static let genresDescription = L10n.tr("Localizable", "genresDescription", fallback: "Categories that describe the themes or styles of media.") /// Gestures internal static let gestures = L10n.tr("Localizable", "gestures", fallback: "Gestures") /// Gbps @@ -550,6 +586,8 @@ internal enum L10n { internal static let green = L10n.tr("Localizable", "green", fallback: "Green") /// Grid internal static let grid = L10n.tr("Localizable", "grid", fallback: "Grid") + /// Guest Star + internal static let guestStar = L10n.tr("Localizable", "guestStar", fallback: "Guest Star") /// Half Side-by-Side internal static let halfSideBySide = L10n.tr("Localizable", "halfSideBySide", fallback: "Half Side-by-Side") /// Half Top and Bottom @@ -566,10 +604,14 @@ internal enum L10n { internal static let hours = L10n.tr("Localizable", "hours", fallback: "Hours") /// Idle internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle") + /// Illustrator + internal static let illustrator = L10n.tr("Localizable", "illustrator", fallback: "Illustrator") /// Indicators internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators") /// Information internal static let information = L10n.tr("Localizable", "information", fallback: "Information") + /// Inker + internal static let inker = L10n.tr("Localizable", "inker", fallback: "Inker") /// Interlaced video is not supported internal static let interlacedVideoNotSupported = L10n.tr("Localizable", "interlacedVideoNotSupported", fallback: "Interlaced video is not supported") /// Interval @@ -638,6 +680,8 @@ internal enum L10n { internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left") /// Letter internal static let letter = L10n.tr("Localizable", "letter", fallback: "Letter") + /// Letterer + internal static let letterer = L10n.tr("Localizable", "letterer", fallback: "Letterer") /// Letter Picker internal static let letterPicker = L10n.tr("Localizable", "letterPicker", fallback: "Letter Picker") /// Library @@ -674,6 +718,8 @@ internal enum L10n { internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs") /// Access the Jellyfin server logs for troubleshooting and monitoring purposes. internal static let logsDescription = L10n.tr("Localizable", "logsDescription", fallback: "Access the Jellyfin server logs for troubleshooting and monitoring purposes.") + /// Lyricist + internal static let lyricist = L10n.tr("Localizable", "lyricist", fallback: "Lyricist") /// Lyrics internal static let lyrics = L10n.tr("Localizable", "lyrics", fallback: "Lyrics") /// Management @@ -720,6 +766,8 @@ internal enum L10n { internal static let missing = L10n.tr("Localizable", "missing", fallback: "Missing") /// Missing Items internal static let missingItems = L10n.tr("Localizable", "missingItems", fallback: "Missing Items") + /// Mixer + internal static let mixer = L10n.tr("Localizable", "mixer", fallback: "Mixer") /// More Like This internal static let moreLikeThis = L10n.tr("Localizable", "moreLikeThis", fallback: "More Like This") /// Movies @@ -848,8 +896,12 @@ internal enum L10n { internal static let passwordsDoNotMatch = L10n.tr("Localizable", "passwordsDoNotMatch", fallback: "New passwords do not match.") /// Pause on background internal static let pauseOnBackground = L10n.tr("Localizable", "pauseOnBackground", fallback: "Pause on background") + /// Penciller + internal static let penciller = L10n.tr("Localizable", "penciller", fallback: "Penciller") /// People internal static let people = L10n.tr("Localizable", "people", fallback: "People") + /// People who helped create or perform specific media. + internal static let peopleDescription = L10n.tr("Localizable", "peopleDescription", fallback: "People who helped create or perform specific media.") /// Permissions internal static let permissions = L10n.tr("Localizable", "permissions", fallback: "Permissions") /// Play @@ -892,6 +944,8 @@ internal enum L10n { internal static let previousItem = L10n.tr("Localizable", "previousItem", fallback: "Previous Item") /// Primary internal static let primary = L10n.tr("Localizable", "primary", fallback: "Primary") + /// Producer + internal static let producer = L10n.tr("Localizable", "producer", fallback: "Producer") /// Production internal static let production = L10n.tr("Localizable", "production", fallback: "Production") /// Production Locations @@ -958,6 +1012,8 @@ internal enum L10n { internal static let reload = L10n.tr("Localizable", "reload", fallback: "Reload") /// Remaining Time internal static let remainingTime = L10n.tr("Localizable", "remainingTime", fallback: "Remaining Time") + /// Remixer + internal static let remixer = L10n.tr("Localizable", "remixer", fallback: "Remixer") /// Remote connections internal static let remoteConnections = L10n.tr("Localizable", "remoteConnections", fallback: "Remote connections") /// Remote control @@ -974,6 +1030,8 @@ internal enum L10n { internal static let removeFromResume = L10n.tr("Localizable", "removeFromResume", fallback: "Remove From Resume") /// Remux internal static let remux = L10n.tr("Localizable", "remux", fallback: "Remux") + /// Reorder + internal static let reorder = L10n.tr("Localizable", "reorder", fallback: "Reorder") /// Replace All internal static let replaceAll = L10n.tr("Localizable", "replaceAll", fallback: "Replace All") /// Replace all unlocked metadata and images with new information. @@ -990,6 +1048,8 @@ internal enum L10n { internal static let reportIssue = L10n.tr("Localizable", "reportIssue", fallback: "Report an Issue") /// Request a Feature internal static let requestFeature = L10n.tr("Localizable", "requestFeature", fallback: "Request a Feature") + /// Required + internal static let `required` = L10n.tr("Localizable", "required", fallback: "Required") /// Reset internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset") /// Reset all settings back to defaults. @@ -1026,10 +1086,10 @@ internal enum L10n { internal static let run = L10n.tr("Localizable", "run", fallback: "Run") /// Running... internal static let running = L10n.tr("Localizable", "running", fallback: "Running...") - /// Run Time - internal static let runTime = L10n.tr("Localizable", "runTime", fallback: "Run Time") /// Runtime internal static let runtime = L10n.tr("Localizable", "runtime", fallback: "Runtime") + /// Run Time + internal static let runTime = L10n.tr("Localizable", "runTime", fallback: "Run Time") /// Save internal static let save = L10n.tr("Localizable", "save", fallback: "Save") /// Scan All Libraries @@ -1178,6 +1238,8 @@ internal enum L10n { internal static let studio = L10n.tr("Localizable", "studio", fallback: "STUDIO") /// Studios internal static let studios = L10n.tr("Localizable", "studios", fallback: "Studios") + /// Studio(s) involved in the creation of media. + internal static let studiosDescription = L10n.tr("Localizable", "studiosDescription", fallback: "Studio(s) involved in the creation of media.") /// Subtitle internal static let subtitle = L10n.tr("Localizable", "subtitle", fallback: "Subtitle") /// The subtitle codec is not supported @@ -1220,6 +1282,8 @@ internal enum L10n { internal static let taglines = L10n.tr("Localizable", "taglines", fallback: "Taglines") /// Tags internal static let tags = L10n.tr("Localizable", "tags", fallback: "Tags") + /// Labels used to organize or highlight specific attributes of media. + internal static let tagsDescription = L10n.tr("Localizable", "tagsDescription", fallback: "Labels used to organize or highlight specific attributes of media.") /// Task internal static let task = L10n.tr("Localizable", "task", fallback: "Task") /// Aborted @@ -1270,6 +1334,8 @@ internal enum L10n { internal static let transcodeReasons = L10n.tr("Localizable", "transcodeReasons", fallback: "Transcode Reason(s)") /// Transition internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition") + /// Translator + internal static let translator = L10n.tr("Localizable", "translator", fallback: "Translator") /// Trigger already exists internal static let triggerAlreadyExists = L10n.tr("Localizable", "triggerAlreadyExists", fallback: "Trigger already exists") /// Triggers @@ -1364,8 +1430,12 @@ internal enum L10n { internal static let weekly = L10n.tr("Localizable", "weekly", fallback: "Weekly") /// Who's watching? internal static let whosWatching = L10n.tr("Localizable", "WhosWatching", fallback: "Who's watching?") + /// This will be created as a new item on your Jellyfin Server. + internal static let willBeCreatedOnServer = L10n.tr("Localizable", "willBeCreatedOnServer", fallback: "This will be created as a new item on your Jellyfin Server.") /// WIP internal static let wip = L10n.tr("Localizable", "wip", fallback: "WIP") + /// Writer + internal static let writer = L10n.tr("Localizable", "writer", fallback: "Writer") /// Year internal static let year = L10n.tr("Localizable", "year", fallback: "Year") /// Years diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift index 13f5cc6db..476b9a4e8 100644 --- a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -149,10 +149,10 @@ extension StoredValues.Keys { ) } - static var enableItemEditor: Key { + static var enableItemEditing: Key { CurrentUserKey( - "enableItemEditor", - domain: "enableItemEditor", + "enableItemEditing", + domain: "enableItemEditing", default: false ) } @@ -164,5 +164,13 @@ extension StoredValues.Keys { default: false ) } + + static var enableCollectionManagement: Key { + CurrentUserKey( + "enableCollectionManagement", + domain: "enableCollectionManagement", + default: false + ) + } } } diff --git a/Shared/SwiftfinStore/SwiftinStore+UserState.swift b/Shared/SwiftfinStore/SwiftinStore+UserState.swift index eef4f4408..91d53211a 100644 --- a/Shared/SwiftfinStore/SwiftinStore+UserState.swift +++ b/Shared/SwiftfinStore/SwiftinStore+UserState.swift @@ -64,13 +64,8 @@ extension UserState { } } - var isAdministrator: Bool { - 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 permissions: UserPermissions { + UserPermissions(data.policy) } var pinHint: String { diff --git a/Shared/ViewModels/ItemEditorViewModel/DeleteItemViewModel.swift b/Shared/ViewModels/ItemAdministration/DeleteItemViewModel.swift similarity index 100% rename from Shared/ViewModels/ItemEditorViewModel/DeleteItemViewModel.swift rename to Shared/ViewModels/ItemAdministration/DeleteItemViewModel.swift diff --git a/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/GenreEditorViewModel.swift b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/GenreEditorViewModel.swift new file mode 100644 index 000000000..6edac7055 --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/GenreEditorViewModel.swift @@ -0,0 +1,60 @@ +// +// 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 GenreEditorViewModel: ItemEditorViewModel { + + // MARK: - Populate the Trie + + override func populateTrie() { + trie.insert(contentsOf: elements.keyed(using: \.localizedLowercase)) + } + + // MARK: - Add Genre(s) + + override func addComponents(_ genres: [String]) async throws { + var updatedItem = item + if updatedItem.genres == nil { + updatedItem.genres = [] + } + updatedItem.genres?.append(contentsOf: genres) + try await updateItem(updatedItem) + } + + // MARK: - Remove Genre(s) + + override func removeComponents(_ genres: [String]) async throws { + var updatedItem = item + updatedItem.genres?.removeAll { genres.contains($0) } + try await updateItem(updatedItem) + } + + // MARK: - Reorder Tag(s) + + override func reorderComponents(_ genres: [String]) async throws { + var updatedItem = item + updatedItem.genres = genres + try await updateItem(updatedItem) + } + + // MARK: - Fetch All Possible Genres + + override func fetchElements() async throws -> [String] { + let request = Paths.getGenres() + let response = try await userSession.client.send(request) + + if let genres = response.value.items { + return genres.compactMap(\.name).compactMap { $0 } + } else { + return [] + } + } +} diff --git a/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift new file mode 100644 index 000000000..59d6e9069 --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift @@ -0,0 +1,297 @@ +// +// 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 +import OrderedCollections + +class ItemEditorViewModel: ViewModel, Stateful, Eventful { + + // MARK: - Events + + enum Event: Equatable { + case updated + case loaded + case error(JellyfinAPIError) + } + + // MARK: - Actions + + enum Action: Equatable { + case load + case search(String) + case add([Element]) + case remove([Element]) + case reorder([Element]) + case update(BaseItemDto) + } + + // MARK: BackgroundState + + enum BackgroundState: Hashable { + case loading + case searching + case refreshing + } + + // MARK: - State + + enum State: Hashable { + case initial + case content + case updating + case error(JellyfinAPIError) + } + + @Published + var backgroundStates: OrderedSet = [] + @Published + var item: BaseItemDto + @Published + var elements: [Element] = [] + @Published + var matches: [Element] = [] + @Published + var state: State = .initial + + final var trie = Trie() + + private var loadTask: AnyCancellable? + private var updateTask: AnyCancellable? + private var searchTask: AnyCancellable? + private var searchQuery = CurrentValueSubject("") + + private let eventSubject = PassthroughSubject() + + var events: AnyPublisher { + eventSubject.receive(on: RunLoop.main).eraseToAnyPublisher() + } + + // MARK: - Initializer + + init(item: BaseItemDto) { + self.item = item + + super.init() + + setupSearchDebounce() + } + + // MARK: - Setup Debouncing + + private func setupSearchDebounce() { + searchQuery + .debounce(for: .seconds(0.5), scheduler: RunLoop.main) + .removeDuplicates() + .sink { [weak self] searchTerm in + guard let self else { return } + guard searchTerm.isNotEmpty else { return } + + self.executeSearch(for: searchTerm) + } + .store(in: &cancellables) + } + + // MARK: - Respond to Actions + + func respond(to action: Action) -> State { + switch action { + case .load: + loadTask?.cancel() + + loadTask = Task { [weak self] in + guard let self else { return } + + do { + await MainActor.run { + self.matches = [] + self.state = .initial + _ = self.backgroundStates.append(.loading) + } + + let allElements = try await self.fetchElements() + + await MainActor.run { + self.elements = allElements + self.state = .content + self.eventSubject.send(.loaded) + + _ = self.backgroundStates.remove(.loading) + } + + populateTrie() + + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .error(apiError) + _ = self.backgroundStates.remove(.loading) + } + } + }.asAnyCancellable() + + return state + + case let .search(searchTerm): + searchQuery.send(searchTerm) + return state + + case let .add(addItems): + executeAction { + try await self.addComponents(addItems) + } + return state + + case let .remove(removeItems): + executeAction { + try await self.removeComponents(removeItems) + } + return state + + case let .reorder(orderedItems): + executeAction { + try await self.reorderComponents(orderedItems) + } + return state + + case let .update(updateItem): + executeAction { + try await self.updateItem(updateItem) + } + return state + } + } + + // MARK: - Execute Debounced Search + + private func executeSearch(for searchTerm: String) { + searchTask?.cancel() + + searchTask = Task { [weak self] in + guard let self else { return } + + do { + await MainActor.run { + _ = self.backgroundStates.append(.searching) + } + + let results = try await self.searchElements(searchTerm) + + await MainActor.run { + self.matches = results + _ = self.backgroundStates.remove(.searching) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .error(apiError) + _ = self.backgroundStates.remove(.searching) + } + } + }.asAnyCancellable() + } + + // MARK: - Helper: Execute Task for Add/Remove/Reorder/Update + + private func executeAction(action: @escaping () async throws -> Void) { + updateTask?.cancel() + + updateTask = Task { [weak self] in + guard let self else { return } + + do { + await MainActor.run { + self.state = .updating + } + + try await action() + + await MainActor.run { + self.state = .content + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .content + self.eventSubject.send(.error(apiError)) + } + } + }.asAnyCancellable() + } + + // MARK: - Save Updated Item to Server + + func updateItem(_ newItem: BaseItemDto) async throws { + guard let itemId = item.id else { return } + + let request = Paths.updateItem(itemID: itemId, newItem) + _ = try await userSession.client.send(request) + + try await refreshItem() + + await MainActor.run { + Notifications[.itemMetadataDidChange].post(object: newItem) + } + } + + // MARK: - Refresh Item + + private func refreshItem() async throws { + guard let itemId = item.id else { return } + + await MainActor.run { + _ = self.backgroundStates.append(.refreshing) + } + + 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.backgroundStates.remove(.refreshing) + } + } + + // MARK: - Populate the Trie + + func populateTrie() { + fatalError("This method should be overridden in subclasses") + } + + // MARK: - Add Element Component to Item (To Be Overridden) + + func addComponents(_ components: [Element]) async throws { + fatalError("This method should be overridden in subclasses") + } + + // MARK: - Remove Element Component from Item (To Be Overridden) + + func removeComponents(_ components: [Element]) async throws { + fatalError("This method should be overridden in subclasses") + } + + // MARK: - Reorder Elements (To Be Overridden) + + func reorderComponents(_ tags: [Element]) async throws { + fatalError("This method should be overridden in subclasses") + } + + // MARK: - Fetch All Possible Elements (To Be Overridden) + + func fetchElements() async throws -> [Element] { + fatalError("This method should be overridden in subclasses") + } + + // MARK: - Return Matching Elements (To Be Overridden) + + func searchElements(_ searchTerm: String) async throws -> [Element] { + trie.search(prefix: searchTerm.localizedLowercase) + } +} diff --git a/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/PeopleEditorViewModel.swift b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/PeopleEditorViewModel.swift new file mode 100644 index 000000000..3386c7db6 --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/PeopleEditorViewModel.swift @@ -0,0 +1,68 @@ +// +// 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 PeopleEditorViewModel: ItemEditorViewModel { + + // MARK: - Populate the Trie + + override func populateTrie() { + let elements = elements + .compacted(using: \.name) + .reduce(into: [String: BaseItemPerson]()) { result, element in + result[element.name!.localizedLowercase] = element + } + + trie.insert(contentsOf: elements) + } + + // MARK: - Add People(s) + + override func addComponents(_ people: [BaseItemPerson]) async throws { + var updatedItem = item + if updatedItem.people == nil { + updatedItem.people = [] + } + updatedItem.people?.append(contentsOf: people) + try await updateItem(updatedItem) + } + + // MARK: - Remove People(s) + + override func removeComponents(_ people: [BaseItemPerson]) async throws { + var updatedItem = item + updatedItem.people?.removeAll { people.contains($0) } + try await updateItem(updatedItem) + } + + // MARK: - Reorder Tag(s) + + override func reorderComponents(_ people: [BaseItemPerson]) async throws { + var updatedItem = item + updatedItem.people = people + try await updateItem(updatedItem) + } + + // MARK: - Fetch All Possible People + + override func fetchElements() async throws -> [BaseItemPerson] { + let request = Paths.getPersons() + let response = try await userSession.client.send(request) + + if let people = response.value.items { + return people.map { person in + BaseItemPerson(id: person.id, name: person.name) + } + } else { + return [] + } + } +} diff --git a/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/StudioEditorViewModel.swift b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/StudioEditorViewModel.swift new file mode 100644 index 000000000..2c91995c2 --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/StudioEditorViewModel.swift @@ -0,0 +1,68 @@ +// +// 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 StudioEditorViewModel: ItemEditorViewModel { + + // MARK: - Populate the Trie + + override func populateTrie() { + let elements = elements + .compacted(using: \.name) + .reduce(into: [String: NameGuidPair]()) { result, element in + result[element.name!] = element + } + + trie.insert(contentsOf: elements) + } + + // MARK: - Add Studio(s) + + override func addComponents(_ studios: [NameGuidPair]) async throws { + var updatedItem = item + if updatedItem.studios == nil { + updatedItem.studios = [] + } + updatedItem.studios?.append(contentsOf: studios) + try await updateItem(updatedItem) + } + + // MARK: - Remove Studio(s) + + override func removeComponents(_ studios: [NameGuidPair]) async throws { + var updatedItem = item + updatedItem.studios?.removeAll { studios.contains($0) } + try await updateItem(updatedItem) + } + + // MARK: - Reorder Tag(s) + + override func reorderComponents(_ studios: [NameGuidPair]) async throws { + var updatedItem = item + updatedItem.studios = studios + try await updateItem(updatedItem) + } + + // MARK: - Fetch All Possible Studios + + override func fetchElements() async throws -> [NameGuidPair] { + let request = Paths.getStudios() + let response = try await userSession.client.send(request) + + if let studios = response.value.items { + return studios.map { studio in + NameGuidPair(id: studio.id, name: studio.name) + } + } else { + return [] + } + } +} diff --git a/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/TagEditorViewModel.swift b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/TagEditorViewModel.swift new file mode 100644 index 000000000..1e67c904b --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/TagEditorViewModel.swift @@ -0,0 +1,57 @@ +// +// 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 TagEditorViewModel: ItemEditorViewModel { + + // MARK: - Populate the Trie + + override func populateTrie() { + trie.insert(contentsOf: elements.keyed(using: \.localizedLowercase)) + } + + // MARK: - Add Tag(s) + + override func addComponents(_ tags: [String]) async throws { + var updatedItem = item + if updatedItem.tags == nil { + updatedItem.tags = [] + } + updatedItem.tags?.append(contentsOf: tags) + try await updateItem(updatedItem) + } + + // MARK: - Remove Tag(s) + + override func removeComponents(_ tags: [String]) async throws { + var updatedItem = item + updatedItem.tags?.removeAll { tags.contains($0) } + try await updateItem(updatedItem) + } + + // MARK: - Reorder Tag(s) + + override func reorderComponents(_ tags: [String]) async throws { + var updatedItem = item + updatedItem.tags = tags + try await updateItem(updatedItem) + } + + // MARK: - Fetch All Possible Tags + + override func fetchElements() async throws -> [String] { + let parameters = Paths.GetQueryFiltersLegacyParameters(userID: userSession.user.id) + let request = Paths.getQueryFiltersLegacy(parameters: parameters) + guard let response = try? await userSession.client.send(request) else { return [] } + + return response.value.tags ?? [] + } +} diff --git a/Shared/ViewModels/ItemEditorViewModel/RefreshMetadataViewModel.swift b/Shared/ViewModels/ItemAdministration/RefreshMetadataViewModel.swift similarity index 100% rename from Shared/ViewModels/ItemEditorViewModel/RefreshMetadataViewModel.swift rename to Shared/ViewModels/ItemAdministration/RefreshMetadataViewModel.swift diff --git a/Shared/ViewModels/ItemEditorViewModel/ItemEditorViewModel.swift b/Shared/ViewModels/ItemEditorViewModel/ItemEditorViewModel.swift deleted file mode 100644 index 146c52d57..000000000 --- a/Shared/ViewModels/ItemEditorViewModel/ItemEditorViewModel.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// 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 -import OrderedCollections - -class ItemEditorViewModel: ViewModel, Stateful, Eventful { - - // MARK: - Events - - enum Event: Equatable { - case updated - case error(JellyfinAPIError) - } - - // MARK: - Actions - - enum Action: Equatable { - case add([ItemType]) - case remove([ItemType]) - case update(BaseItemDto) - } - - // MARK: BackgroundState - - enum BackgroundState: Hashable { - case refreshing - } - - // MARK: - State - - enum State: Hashable { - case initial - case error(JellyfinAPIError) - case updating - } - - @Published - var backgroundStates: OrderedSet = [] - - @Published - var item: BaseItemDto - - @Published - var state: State = .initial - - private var task: AnyCancellable? - private let eventSubject = PassthroughSubject() - - var events: AnyPublisher { - eventSubject.receive(on: RunLoop.main).eraseToAnyPublisher() - } - - // MARK: - Init - - init(item: BaseItemDto) { - self.item = item - super.init() - } - - // MARK: - Respond to Actions - - func respond(to action: Action) -> State { - switch action { - case let .add(items): - task?.cancel() - - task = Task { [weak self] in - guard let self = self else { return } - do { - await MainActor.run { self.state = .updating } - - try await self.addComponents(items) - - await MainActor.run { - self.state = .initial - self.eventSubject.send(.updated) - } - } catch { - let apiError = JellyfinAPIError(error.localizedDescription) - await MainActor.run { - self.state = .error(apiError) - self.eventSubject.send(.error(apiError)) - } - } - }.asAnyCancellable() - - return state - - case let .remove(items): - task?.cancel() - - task = Task { [weak self] in - guard let self = self else { return } - do { - await MainActor.run { self.state = .updating } - - try await self.removeComponents(items) - - await MainActor.run { - self.state = .initial - self.eventSubject.send(.updated) - } - } catch { - let apiError = JellyfinAPIError(error.localizedDescription) - await MainActor.run { - self.state = .error(apiError) - self.eventSubject.send(.error(apiError)) - } - } - }.asAnyCancellable() - - return state - - case let .update(newItem): - task?.cancel() - - task = Task { [weak self] in - guard let self = self else { return } - do { - await MainActor.run { self.state = .updating } - - try await self.updateItem(newItem) - - await MainActor.run { - self.state = .initial - self.eventSubject.send(.updated) - } - } catch { - let apiError = JellyfinAPIError(error.localizedDescription) - await MainActor.run { - self.state = .error(apiError) - self.eventSubject.send(.error(apiError)) - } - } - }.asAnyCancellable() - - return state - } - } - - // MARK: - Save Updated Item to Server - - func updateItem(_ newItem: BaseItemDto, refresh: Bool = false) async throws { - guard let itemId = item.id else { return } - - let request = Paths.updateItem(itemID: itemId, newItem) - _ = try await userSession.client.send(request) - - if refresh { - try await refreshItem() - } - - await MainActor.run { - Notifications[.itemMetadataDidChange].post(object: newItem) - } - } - - // MARK: - Refresh Item - - private func refreshItem() async throws { - guard let itemId = item.id else { return } - - await MainActor.run { - _ = backgroundStates.append(.refreshing) - } - - 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 - _ = backgroundStates.remove(.refreshing) - } - } - - // MARK: - Add Items (To be overridden) - - func addComponents(_ items: [ItemType]) async throws { - fatalError("This method should be overridden in subclasses") - } - - // MARK: - Remove Items (To be overridden) - - func removeComponents(_ items: [ItemType]) async throws { - fatalError("This method should be overridden in subclasses") - } -} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 5d8282a75..da6e05af3 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* 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 */; }; + 4E01446C2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; + 4E01446D2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; 4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; }; 4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; 4E026A8B2CE804E7005471B5 /* ResetUserPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */; }; @@ -46,6 +48,8 @@ 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; }; 4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; }; 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; }; + 4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */; }; + 4E31EFA52CFFFB690053DFE7 /* EditItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */; }; 4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE562CBED3F300DBD886 /* TimeRow.swift */; }; 4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE572CBED3F300DBD886 /* TriggerTypeRow.swift */; }; 4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE5A2CBED3F300DBD886 /* AddTaskTriggerView.swift */; }; @@ -60,6 +64,8 @@ 4E35CE6C2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; 4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; }; + 4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; }; + 4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; }; 4E49DECB2CE54AA200352DCD /* SessionsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECA2CE54A9200352DCD /* SessionsSection.swift */; }; 4E49DECD2CE54C7A00352DCD /* PermissionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECC2CE54C7200352DCD /* PermissionSection.swift */; }; 4E49DECF2CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */; }; @@ -75,7 +81,18 @@ 4E49DEE42CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; }; 4E49DEE62CE5616800352DCD /* UserProfileImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */; }; 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; }; + 4E4E9C672CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */; }; + 4E4E9C682CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */; }; + 4E4E9C6A2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */; }; + 4E4E9C6B2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */; }; + 4E5071D72CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */; }; + 4E5071D82CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */; }; + 4E5071DA2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */; }; + 4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */; }; + 4E5071E42CFCEFD3003FA2AD /* AddItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */; }; 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; }; + 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; + 4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; 4E63B9FA2C8A5BEF00C25378 /* AdminDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */; }; 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; @@ -185,6 +202,9 @@ 4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */; }; 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; }; 4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */; }; + 4EFE0C7D2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; }; + 4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; }; + 4EFE0C802D02055900D4834D /* ItemArrayElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7F2D02054300D4834D /* ItemArrayElements.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 */; }; @@ -1126,6 +1146,7 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; + 4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = ""; }; @@ -1152,6 +1173,8 @@ 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; + 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementRow.swift; sourceTree = ""; }; + 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementView.swift; sourceTree = ""; }; 4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekRow.swift; sourceTree = ""; }; 4E35CE542CBED3F300DBD886 /* IntervalRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalRow.swift; sourceTree = ""; }; 4E35CE552CBED3F300DBD886 /* TimeLimitSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLimitSection.swift; sourceTree = ""; }; @@ -1163,6 +1186,8 @@ 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = ""; }; 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskState.swift; sourceTree = ""; }; 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.swift; sourceTree = ""; }; + 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsSection.swift; sourceTree = ""; }; + 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameInput.swift; sourceTree = ""; }; 4E49DECA2CE54A9200352DCD /* SessionsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsSection.swift; sourceTree = ""; }; 4E49DECC2CE54C7200352DCD /* PermissionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionSection.swift; sourceTree = ""; }; 4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxBitratePolicy.swift; sourceTree = ""; }; @@ -1173,7 +1198,13 @@ 4E49DEDD2CE55F7F00352DCD /* SquareImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquareImageCropView.swift; sourceTree = ""; }; 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPlayUserAccessType.swift; sourceTree = ""; }; 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImagePicker.swift; sourceTree = ""; }; + 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioEditorViewModel.swift; sourceTree = ""; }; + 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleEditorViewModel.swift; sourceTree = ""; }; + 4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagEditorViewModel.swift; sourceTree = ""; }; + 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenreEditorViewModel.swift; sourceTree = ""; }; + 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddItemElementView.swift; sourceTree = ""; }; 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; + 4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = ""; }; 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; @@ -1265,6 +1296,8 @@ 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.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 = ""; }; + 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonKind.swift; sourceTree = ""; }; + 4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemArrayElements.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 = ""; }; @@ -2090,6 +2123,32 @@ path = MediaComponents; sourceTree = ""; }; + 4E31EF972CFFB9B70053DFE7 /* Components */ = { + isa = PBXGroup; + children = ( + 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */, + 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E31EFA22CFFFB410053DFE7 /* EditItemElementView */ = { + isa = PBXGroup; + children = ( + 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */, + 4E31EFA32CFFFB480053DFE7 /* Components */, + ); + path = EditItemElementView; + sourceTree = ""; + }; + 4E31EFA32CFFFB480053DFE7 /* Components */ = { + isa = PBXGroup; + children = ( + 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */, + ); + path = Components; + sourceTree = ""; + }; 4E35CE592CBED3F300DBD886 /* Components */ = { isa = PBXGroup; children = ( @@ -2146,6 +2205,27 @@ path = UserProfileImagePicker; sourceTree = ""; }; + 4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */ = { + isa = PBXGroup; + children = ( + 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */, + 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */, + 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */, + 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */, + 4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */, + ); + path = ItemEditorViewModel; + sourceTree = ""; + }; + 4E5071E22CFCEFC3003FA2AD /* AddItemElementView */ = { + isa = PBXGroup; + children = ( + 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */, + 4E31EF972CFFB9B70053DFE7 /* Components */, + ); + path = AddItemElementView; + sourceTree = ""; + }; 4E5334A02CD1A27C00D59FA8 /* ActionButtons */ = { isa = PBXGroup; children = ( @@ -2287,7 +2367,9 @@ 4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = { isa = PBXGroup; children = ( + 4E5071E22CFCEFC3003FA2AD /* AddItemElementView */, 4E8F74A62CE03D4C00CC8969 /* Components */, + 4E31EFA22CFFFB410053DFE7 /* EditItemElementView */, 4E6619FF2CEFE39000025C99 /* EditMetadataView */, 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */, ); @@ -2302,14 +2384,14 @@ path = Components; sourceTree = ""; }; - 4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */ = { + 4E8F74A92CE03DBE00CC8969 /* ItemAdministration */ = { isa = PBXGroup; children = ( 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */, - 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */, + 4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */, 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */, ); - path = ItemEditorViewModel; + path = ItemAdministration; sourceTree = ""; }; 4E90F75E2CC72B1F00417C31 /* Sections */ = { @@ -2561,7 +2643,7 @@ E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, - 4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */, + 4E8F74A92CE03DBE00CC8969 /* ItemAdministration */, E107BB9127880A4000354E07 /* ItemViewModel */, E1EDA8D52B924CA500F9A57E /* LibraryViewModel */, C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */, @@ -2679,6 +2761,7 @@ E1579EA62B97DC1500A31CA1 /* Eventful.swift */, E1092F4B29106F9F00163F57 /* GestureAction.swift */, E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */, + 4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */, E14EDECA2B8FB66F000F00A4 /* ItemFilter */, E1C925F328875037002A7A66 /* ItemViewType.swift */, E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */, @@ -2707,7 +2790,9 @@ E1E306CC28EF6E8000537998 /* TimerProxy.swift */, E129428F28F0BDC300796AC6 /* TimeStampType.swift */, E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */, + 4E01446B2D0292E000193038 /* Trie.swift */, E1EA09682BED78BB004CDE76 /* UserAccessPolicy.swift */, + 4E556AAF2D036F5E00733377 /* UserPermissions.swift */, DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */, E1D8429229340B8300D1041A /* Utilities.swift */, E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */, @@ -4097,6 +4182,7 @@ E122A9122788EAAD0060FA63 /* MediaStream.swift */, 4E661A2D2CEFE77700025C99 /* MetadataField.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, + 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, @@ -4921,6 +5007,7 @@ E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */, E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */, + 4E01446C2D0292E200193038 /* Trie.swift in Sources */, E14EA16A2BF7333B00DE757A /* UserProfileImageViewModel.swift in Sources */, 4EBE06542C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */, E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */, @@ -4996,6 +5083,7 @@ E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E17DC74B2BE740D900B42379 /* StoredValues+Server.swift in Sources */, 4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */, + 4E5071D82CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */, E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, @@ -5022,6 +5110,7 @@ E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, + 4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */, 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, @@ -5056,6 +5145,7 @@ E11042762B8013DF00821020 /* Stateful.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, E1575E66293E77B5001665B1 /* Poster.swift in Sources */, + 4E4E9C682CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */, E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */, E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */, 4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */, @@ -5082,6 +5172,7 @@ E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */, 4E661A1F2CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */, E145EB462BE0AD4E003BF6F3 /* Set.swift in Sources */, + 4EFE0C7D2D0156A900D4834D /* PersonKind.swift in Sources */, E1575E7D293E77B5001665B1 /* PosterDisplayType.swift in Sources */, E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */, @@ -5093,10 +5184,12 @@ E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */, E1CB75722C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, + 4E4E9C6B2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */, E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */, E133328929538D8D00EE76AB /* Files.swift in Sources */, E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */, C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */, + 4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */, E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */, E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */, E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */, @@ -5218,6 +5311,7 @@ C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */, 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, + 4E5071D72CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */, E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, E146A9D82BE6E9830034DA1E /* StoredValue.swift in Sources */, 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, @@ -5282,6 +5376,7 @@ 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, E1B4E4382CA7795200DC49DE /* OrderedDictionary.swift in Sources */, E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */, + 4E5071E42CFCEFD3003FA2AD /* AddItemElementView.swift in Sources */, E16AA60828A364A6009A983C /* PosterButton.swift in Sources */, E1E1644128BB301900323B0A /* Array.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserRow.swift in Sources */, @@ -5294,6 +5389,7 @@ E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */, 4EBE06532C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, + 4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */, E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, @@ -5446,6 +5542,7 @@ 4E90F76A2CC72B1F00417C31 /* DetailsSection.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, + 4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, @@ -5465,6 +5562,7 @@ 4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */, E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */, 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */, + 4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */, E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */, E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, @@ -5481,6 +5579,7 @@ E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */, + 4E01446D2D0292E200193038 /* Trie.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */, E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift in Sources */, @@ -5561,6 +5660,7 @@ E101ECD52CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */, E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, 4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */, + 4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */, 4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */, 4E49DED32CE54D6D00352DCD /* ActiveSessionsPolicy.swift in Sources */, 4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */, @@ -5573,6 +5673,7 @@ 4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */, E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, + 4E5071DA2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */, E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */, E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */, 4E661A202CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */, @@ -5691,6 +5792,7 @@ E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, 4EBE06462C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */, + 4EFE0C802D02055900D4834D /* ItemArrayElements.swift in Sources */, C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, @@ -5699,6 +5801,7 @@ E1D27EE72BBC955F00152D16 /* UnmaskSecureField.swift in Sources */, E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */, E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */, + 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */, E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, 4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */, @@ -5750,6 +5853,7 @@ 4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */, E148128528C15472003B8787 /* SortOrder+ItemSortOrder.swift in Sources */, E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */, + 4E4E9C6A2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */, E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, @@ -5763,6 +5867,8 @@ DFB7C3DF2C7AA43A00CE7CDC /* UserSignInState.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */, 4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */, + 4E31EFA52CFFFB690053DFE7 /* EditItemElementView.swift in Sources */, + 4E4E9C672CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */, E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, 4EB538C82CE3E8A600EB72D5 /* RemoteControlSection.swift in Sources */, diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift b/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift new file mode 100644 index 000000000..825dcfde2 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift @@ -0,0 +1,140 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct AddItemElementView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + @ObservedObject + var viewModel: ItemEditorViewModel + + let type: ItemArrayElements + + @State + private var id: String? + @State + private var name: String = "" + @State + private var personKind: PersonKind = .unknown + @State + private var personRole: String = "" + + @State + private var loaded: Bool = false + + @State + private var isPresentingError: Bool = false + @State + private var error: Error? + + // MARK: - Name is Valid + + private var isValid: Bool { + name.isNotEmpty + } + + private var itemAlreadyExists: Bool { + viewModel.trie.contains(key: name.localizedLowercase) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case let .error(error): + ErrorView(error: error) + case .updating: + DelayedProgressView() + case .initial, .content: + contentView + } + } + .navigationTitle(type.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.loading) { + ProgressView() + } + + Button(L10n.save) { + viewModel.send(.add([type.createElement( + name: name, + id: id, + personRole: personRole.isEmpty ? (personKind == .unknown ? nil : personKind.rawValue) : personRole, + personKind: personKind.rawValue + )])) + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + .onFirstAppear { + viewModel.send(.load) + } + .onChange(of: name) { _ in + if !viewModel.backgroundStates.contains(.loading) { + viewModel.send(.search(name)) + } + } + .onReceive(viewModel.events) { event in + switch event { + case .updated: + UIDevice.feedback(.success) + router.dismissCoordinator() + case .loaded: + loaded = true + viewModel.send(.search(name)) + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + isPresentingError = true + } + } + .alert( + L10n.error, + isPresented: $isPresentingError, + presenting: error + ) { error in + Text(error.localizedDescription) + } + } + + // MARK: - Content View + + private var contentView: some View { + List { + NameInput( + name: $name, + type: type, + personKind: $personKind, + personRole: $personRole, + itemAlreadyExists: itemAlreadyExists + ) + + SearchResultsSection( + id: $id, + name: $name, + type: type, + population: viewModel.matches, + isSearching: viewModel.backgroundStates.contains(.searching) + ) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift new file mode 100644 index 000000000..7c6a1586e --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift @@ -0,0 +1,86 @@ +// +// 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 AddItemElementView { + + struct NameInput: View { + + @Binding + var name: String + var type: ItemArrayElements + + @Binding + var personKind: PersonKind + @Binding + var personRole: String + + let itemAlreadyExists: Bool + + // MARK: - Body + + var body: some View { + nameView + + if type == .people { + personView + } + } + + // MARK: - Name Input Field + + private var nameView: some View { + Section { + TextField(L10n.name, text: $name) + .autocorrectionDisabled() + } header: { + Text(L10n.name) + } footer: { + if name.isEmpty || name == "" { + Label( + L10n.required, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } else { + if itemAlreadyExists { + Label( + L10n.existsOnServer, + systemImage: "checkmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .green)) + } else { + Label( + L10n.willBeCreatedOnServer, + systemImage: "checkmark.seal.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .blue)) + } + } + } + } + + // MARK: - Person Input Fields + + var personView: some View { + Section { + Picker(L10n.type, selection: $personKind) { + ForEach(PersonKind.allCases, id: \.self) { kind in + Text(kind.displayTitle).tag(kind) + } + } + if personKind == PersonKind.actor { + TextField(L10n.role, text: $personRole) + .autocorrectionDisabled() + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift new file mode 100644 index 000000000..53f950def --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift @@ -0,0 +1,106 @@ +// +// 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 AddItemElementView { + + struct SearchResultsSection: View { + + @Binding + var id: String? + @Binding + var name: String + + let type: ItemArrayElements + let population: [Element] + let isSearching: Bool + + // MARK: - Body + + var body: some View { + if name.isNotEmpty { + Section { + if population.isNotEmpty { + resultsView + .animation(.easeInOut, value: population.count) + } else if !isSearching { + noResultsView + .transition(.opacity) + .animation(.easeInOut, value: population.count) + } + } header: { + HStack { + Text(L10n.existingItems) + if isSearching { + DelayedProgressView() + } else { + Text("-") + Text(population.count.description) + } + } + .animation(.easeInOut, value: isSearching) + } + } + } + + // MARK: - Empty Matches Results + + private var noResultsView: some View { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + + // MARK: - Formatted Matches Results + + private var resultsView: some View { + ForEach(population, id: \.self) { result in + Button { + name = type.getName(for: result) + id = type.getId(for: result) + } label: { + labelView(result) + } + .foregroundStyle(.primary) + .disabled(name == type.getName(for: result)) + .transition(.opacity.combined(with: .move(edge: .top))) + .animation(.easeInOut, value: population.count) + } + } + + // MARK: - Element Matches Button Label by Type + + @ViewBuilder + private func labelView(_ match: Element) -> some View { + switch type { + case .people: + let person = match as! BaseItemPerson + HStack { + ZStack { + Color.clear + ImageView(person.portraitImageSources(maxWidth: 30)) + .failure { + SystemImageContentView(systemName: "person.fill") + } + } + .posterStyle(.portrait) + .frame(width: 30, height: 90) + .padding(.horizontal) + + Text(type.getName(for: match)) + .frame(maxWidth: .infinity, alignment: .leading) + } + default: + Text(type.getName(for: match)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift b/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift new file mode 100644 index 000000000..ec624f268 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift @@ -0,0 +1,106 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditItemElementView { + + struct EditItemElementRow: View { + + @Environment(\.isEditing) + var isEditing + @Environment(\.isSelected) + var isSelected + + let item: Element + let type: ItemArrayElements + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Body + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + if type == .people { + personImage + } + } content: { + rowContent + } + .isSeparatorVisible(false) + .onSelect(perform: onSelect) + .swipeActions { + Button(L10n.delete, systemImage: "trash", action: onDelete) + .tint(.red) + } + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + Text(type.getName(for: item)) + .foregroundStyle( + isEditing ? (isSelected ? .primary : .secondary) : .primary + ) + .font(.headline) + .lineLimit(1) + + if type == .people { + let person = (item as! BaseItemPerson) + + TextPairView( + leading: person.type ?? .emptyDash, + trailing: person.role ?? .emptyDash + ) + .foregroundStyle( + isEditing ? (isSelected ? .primary : .secondary) : .primary, + .secondary + ) + .font(.subheadline) + .lineLimit(1) + } + } + + if isEditing { + Spacer() + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + } + } + } + + // MARK: - Person Image + + @ViewBuilder + private var personImage: some View { + let person = (item as! BaseItemPerson) + + ZStack { + Color.clear + + ImageView(person.portraitImageSources(maxWidth: 30)) + .failure { + SystemImageContentView(systemName: "person.fill") + } + } + .posterStyle(.portrait) + .posterShadow() + .frame(width: 30, height: 90) + .padding(.trailing) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift b/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift new file mode 100644 index 000000000..21faec635 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift @@ -0,0 +1,229 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct EditItemElementView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: ItemEditorCoordinator.Router + + @ObservedObject + var viewModel: ItemEditorViewModel + + @State + private var elements: [Element] + + private let type: ItemArrayElements + private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void + + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingDeleteSelectionConfirmation = false + @State + private var selectedElements: Set = [] + @State + private var isEditing: Bool = false + @State + private var isReordering: Bool = false + + // MARK: - Initializer + + init( + viewModel: ItemEditorViewModel, + type: ItemArrayElements, + route: @escaping (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void + ) { + self.viewModel = viewModel + self.type = type + self.route = route + self.elements = type.getElement(for: viewModel.item) + } + + // 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) + } + } + } + .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 + } + + 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) + } + } + + // MARK: - Navigation Bar Select/Remove All Content + + @ViewBuilder + private var navigationBarSelectView: some View { + let isAllSelected = selectedElements.count == (elements.count) + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + selectedElements = isAllSelected ? [] : Set(elements) + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + .foregroundStyle(accentColor) + } + + // MARK: - Content View + + private var contentView: some View { + List { + InsetGroupedListHeader(type.displayTitle, description: type.description) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + if elements.isNotEmpty { + ForEach(elements, id: \.self) { element in + EditItemElementRow( + item: element, + type: type, + onSelect: { + if isEditing { + selectedElements.toggle(value: element) + } + }, + onDelete: { + selectedElements.toggle(value: element) + isPresentingDeleteConfirmation = true + } + ) + .environment(\.isEditing, isEditing) + .environment(\.isSelected, selectedElements.contains(element)) + } + .onMove { source, destination in + guard isReordering else { return } + elements.move(fromOffsets: source, toOffset: destination) + } + } else { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } + } + .listStyle(.plain) + .environment(\.editMode, isReordering ? .constant(.active) : .constant(.inactive)) + } + + // MARK: - Delete Selected Confirmation Actions + + @ViewBuilder + private var deleteSelectedConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + let elementsToRemove = elements.filter { selectedElements.contains($0) } + viewModel.send(.remove(elementsToRemove)) + selectedElements.removeAll() + isEditing = false + } + } + + // MARK: - Delete Single Confirmation Actions + + @ViewBuilder + private var deleteConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let elementToRemove = selectedElements.first, selectedElements.count == 1 { + viewModel.send(.remove([elementToRemove])) + selectedElements.removeAll() + isEditing = false + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift index 591148a2e..7a3920041 100644 --- a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift @@ -51,7 +51,7 @@ struct ItemEditorView: View { private var refreshButtonView: some View { Section { RefreshMetadataButton(item: viewModel.item) - .environment(\.isEnabled, userSession?.user.isAdministrator ?? false) + .environment(\.isEnabled, userSession?.user.permissions.isAdministrator ?? false) } footer: { LearnMoreButton(L10n.metadata) { TextPair( @@ -82,5 +82,24 @@ struct ItemEditorView: View { router.route(to: \.editMetadata, viewModel.item) } } + + Section { + ChevronButton(L10n.genres) + .onSelect { + router.route(to: \.editGenres, viewModel.item) + } + ChevronButton(L10n.people) + .onSelect { + router.route(to: \.editPeople, viewModel.item) + } + ChevronButton(L10n.tags) + .onSelect { + router.route(to: \.editTags, viewModel.item) + } + ChevronButton(L10n.studios) + .onSelect { + router.route(to: \.editStudios, viewModel.item) + } + } } } diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 2bd959d4b..78aaf8b4f 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -32,21 +32,31 @@ struct ItemView: View { @StoredValue(.User.enableItemDeletion) private var enableItemDeletion: Bool - @StoredValue(.User.enableItemEditor) - private var enableItemEditor: Bool + @StoredValue(.User.enableItemEditing) + private var enableItemEditing: Bool + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement: Bool private var canDelete: Bool { - enableItemDeletion && viewModel.item.canDelete ?? false + if viewModel.item.type == .boxSet { + return enableCollectionManagement && viewModel.item.canDelete ?? false + } else { + return enableItemDeletion && viewModel.item.canDelete ?? false + } } - private var canDownload: Bool { - viewModel.item.canDownload ?? false + private var canEdit: Bool { + if viewModel.item.type == .boxSet { + return enableCollectionManagement + } else { + return enableItemEditing + } } // Use to hide the menu button when not needed. // Add more checks as needed. For example, canDownload. private var enableMenu: Bool { - canDelete || enableItemEditor + canDelete || canEdit } private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { @@ -132,7 +142,7 @@ struct ItemView: View { isLoading: viewModel.backgroundStates.contains(.refresh), isHidden: !enableMenu ) { - if enableItemEditor { + if canEdit { Button(L10n.edit, systemImage: "pencil") { router.route(to: \.itemEditor, viewModel) } diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift index 998c3f968..d3ca3efd2 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift @@ -64,9 +64,9 @@ extension PagingLibraryView { viewType = .grid } label: { if viewType == .grid { - Label("Grid", systemImage: "checkmark") + Label(L10n.grid, systemImage: "checkmark") } else { - Label("Grid", systemImage: "square.grid.2x2.fill") + Label(L10n.grid, systemImage: "square.grid.2x2.fill") } } @@ -74,9 +74,9 @@ extension PagingLibraryView { viewType = .list } label: { if viewType == .list { - Label("List", systemImage: "checkmark") + Label(L10n.list, systemImage: "checkmark") } else { - Label("List", systemImage: "square.fill.text.grid.1x2") + Label(L10n.list, systemImage: "square.fill.text.grid.1x2") } } } diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift index ee9eeb319..11180c0b3 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -17,21 +17,39 @@ extension CustomizeViewsSettings { @Injected(\.currentUserSession) private var userSession - @StoredValue(.User.enableItemEditor) - private var enableItemEditor + @StoredValue(.User.enableItemEditing) + private var enableItemEditing @StoredValue(.User.enableItemDeletion) private var enableItemDeletion + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement var body: some View { Section(L10n.items) { - - if userSession?.user.isAdministrator ?? false { - Toggle(L10n.allowItemEditing, isOn: $enableItemEditor) + /// Enable Editing Items from All Visible LIbraries + if userSession?.user.permissions.items.canEditMetadata ?? false { + Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) } - - if userSession?.user.hasDeletionPermissions ?? 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 { + Toggle(L10n.allowLyricsManagement isOn: $enableLyricsManagement) + } */ + /// 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) } } } diff --git a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift index c807c0b40..1d95a2429 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -43,7 +43,7 @@ struct SettingsView: View { router.route(to: \.serverConnection, viewModel.userSession.server) } - if viewModel.userSession.user.isAdministrator { + if viewModel.userSession.user.permissions.isAdministrator { ChevronButton(L10n.dashboard) .onSelect { router.route(to: \.adminDashboard) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index cd4dbef0e..ee9cc9e5b 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -14,7 +14,6 @@ "connectToJellyfin" = "Connect to Jellyfin"; "connectToServer" = "Connect to Server"; "continueWatching" = "Continue Watching"; -"director" = "DIRECTOR"; "discoveredServers" = "Discovered Servers"; "displayOrder" = "Display order"; "emptyNextUp" = "Empty Next Up"; @@ -1454,6 +1453,10 @@ // Toggle option for enabling media item editing "allowItemEditing" = "Allow media item editing"; +// Allow Collection Management - Toggle +// Toggle option for enabling collection editing / deletion +"allowCollectionManagement" = "Allow collection management"; + // Allow Media Item Deletion - Toggle // Toggle option for enabling media item deletion "allowItemDeletion" = "Allow media item deletion"; @@ -1882,6 +1885,50 @@ // Explanation of custom connections policy "customConnectionsDescription" = "Manually set the maximum number of connections a user can have to the server."; +// Required - Validation +// Indicates a field is required +"required" = "Required"; + +// Reorder - Menu Option +// Menu option to allow for reorder items in a set or array +"reorder" = "Reorder"; + +// Exists on Server - Validation +// Indicates a specific item exists on your Jellyfin Server +"existsOnServer" = "This item exists on your Jellyfin Server."; + +// Will Be Created on Server - Notification +// Indicates a specific item will be created as new on your Jellyfin Server +"willBeCreatedOnServer" = "This will be created as a new item on your Jellyfin Server."; + +// Genres - Description +// A brief explanation of genres in the context of media items +"genresDescription" = "Categories that describe the themes or styles of media."; + +// Tags - Description +// A brief explanation of tags in the context of media items +"tagsDescription" = "Labels used to organize or highlight specific attributes of media."; + +// Studios - Description +// A brief explanation of studios in the context of media items +"studiosDescription" = "Studio(s) involved in the creation of media."; + +// People - Description +// A brief explanation of tags in the people of media items +"peopleDescription" = "People who helped create or perform specific media."; + +// Delete Item - Confirmation +// Asks the user to confirm the deletion of a single item +"deleteItemConfirmation" = "Are you sure you want to delete this item?"; + +// Delete Selected Items - Confirmation +// Asks the user to confirm the deletion of selected item +"deleteSelectedConfirmation" = "Are you sure you want to delete the selected items?"; + +// Existing items - Section Title +// Section for Items that already exist on the Jellyfin Server +"existingItems" = "Existing items"; + // Enable All Libraries - Toggle // Toggle to enable a setting for all Libraries "enableAllLibraries" = "Enable all libraries"; @@ -1897,3 +1944,99 @@ // Access - Section Description // Section Title for Media Access "access" = "Access"; + +// Actor - Enum +// Represents an actor +"actor" = "Actor"; + +// Composer - Enum +// Represents a composer +"composer" = "Composer"; + +// Director - Enum +// Represents a director +"director" = "Director"; + +// Writer - Enum +// Represents a writer +"writer" = "Writer"; + +// Guest Star - Enum +// Represents a guest star +"guestStar" = "Guest Star"; + +// Producer - Enum +// Represents a producer +"producer" = "Producer"; + +// Conductor - Enum +// Represents a conductor +"conductor" = "Conductor"; + +// Lyricist - Enum +// Represents a lyricist +"lyricist" = "Lyricist"; + +// Arranger - Enum +// Represents an arranger +"arranger" = "Arranger"; + +// Engineer - Enum +// Represents an engineer +"engineer" = "Engineer"; + +// Mixer - Enum +// Represents a mixer +"mixer" = "Mixer"; + +// Remixer - Enum +// Represents a remixer +"remixer" = "Remixer"; + +// Creator - Enum +// Represents a creator +"creator" = "Creator"; + +// Artist - Enum +// Represents an artist +"artist" = "Artist"; + +// Album Artist - Enum +// Represents an album artist +"albumArtist" = "Album Artist"; + +// Author - Enum +// Represents an author +"author" = "Author"; + +// Illustrator - Enum +// Represents an illustrator +"illustrator" = "Illustrator"; + +// Penciller - Enum +// Represents a penciller +"penciller" = "Penciller"; + +// Inker - Enum +// Represents an inker +"inker" = "Inker"; + +// Colorist - Enum +// Represents a colorist +"colorist" = "Colorist"; + +// Letterer - Enum +// Represents a letterer +"letterer" = "Letterer"; + +// Cover Artist - Enum +// Represents a cover artist +"coverArtist" = "Cover Artist"; + +// Editor - Enum +// Represents an editor +"editor" = "Editor"; + +// Translator - Enum +// Represents a translator +"translator" = "Translator";