From 553441d83e99a9dfa292c45c6b040eee9e1acaad Mon Sep 17 00:00:00 2001 From: Joe Kribs Date: Mon, 20 Jan 2025 14:17:35 -0700 Subject: [PATCH] [iOS] Media Item Menu - Edit Item Images (#1345) * Good start but some missing items: - Upload image isn't working - Only a single image is shown per section. Need to make this the HCollection of all images for the group * Upload still failing but now update and set are 2 different processes because I think that's better. Spacing on the add screen is still all wrong but we're getting closer * ~70% Complete TODO: - Spacing for remote portrait images is wrong & cramped - Upload image from file browser never works & produces 400 error - Show all images for an item.imageType opposed to just the first - Setting image works but produces a 400 error - Error alert looks bad * Merge with Main * URL Changes * Updating logic and confirmation screen * Lots of changes: Selecting a Remote image is now working without error and works consistently! Upload a local file is still broken Item types with multiple images is working as intended now! Overriding an image on index doesn't seem to work but it doesn't work for Web either so........ UI is way more jank but the hard parts are getting solved! * Breaking this even more with the hopes of a better tomorrow. * Getting better? * Refreshing is working but I might need to make this work mroe effiently... * 90% There! * Ability to cancel the update * Still no luck uploading images? * Stop reordering on deletion/addition * 2025 disclaimers * Uploading finally works! * Functional but messy. TODO: - Figure out better resizing if too big? - Upload from Photos - Move upload logic to imageViewModel and make RemtoeImageViewModel PagingLibraryViewModel conformant - Create a ImageInfoView for Selection & Deletion. * Now conforms to PagingLIbraryViewModel but everything else is a mess * Close! * First no all appears * Fix double pop/routerdismiss * Uploading from Photos is (Finally) Ready! * wip * Reuse PhotoPicker and Crop code. * 4/6 of the codefactor changes * Pass around the URL NOT the UIImage * Clean up ItemImageDetails types. * Make sure the ImageView mirrors the real shape of the image. Posters should be uniform but this is the selection for the image so the dimensions are important to demonstrate. * Rating Type label. * Delete confirmation dialog. * Remove double sizing. Remove Unused ViewModel. Change PhotoPicker to a checkmark instead a 1. Since there is only ever one picture selected, no need to count the images. * Get the image URL as needed. No more Truples. Localize ImageTypes. * Remove attempt at ImageInfo Poster Comformance. * Even more cleanup * Delete vs Save flip * Hide delete button * Even more cleanup * Fix tvOS build issues. * Reduce delay & remove unused comment. Should finally be ready again. * wip * Update ItemImagesView.swift * Event Only on upload failures. * Remove unnecessary ViewModel's from tvOS. * Add dismiss action to RemoteSearchResultView. While I am doing this here, fix it there. * Move From Coordinator -> .Sheet. This fixes the popping issue / delay requirement! * wip * wip * wip * wip --------- Co-authored-by: Ethan Pippin --- .../Coordinators/ItemEditorCoordinator.swift | 11 + .../ItemImagePickerCoordinator.swift | 52 +++ .../Coordinators/ItemImagesCoordinator.swift | 60 +++ .../UserProfileImageCoordinator.swift | 14 +- Shared/Extensions/JellyfinAPI/ImageInfo.swift | 36 ++ Shared/Extensions/JellyfinAPI/ImageType.swift | 44 ++ .../JellyfinAPI/RemoteImageInfo.swift | 34 ++ Shared/Extensions/Nuke/ImagePipeline.swift | 3 + .../{Hashable.swift => RatingType.swift} | 13 +- Shared/Strings/Strings.swift | 50 +++ .../ItemImagesViewModel.swift | 399 ++++++++++++++++++ .../RemoteImageInfoViewModel.swift | 64 +++ .../UserProfileImageViewModel.swift | 6 + Swiftfin.xcodeproj/project.pbxproj | 186 ++++++-- Swiftfin/Components/ListRowButton.swift | 37 +- Swiftfin/Components/ListTitleSection.swift | 1 + Swiftfin/Extensions/ButtonStyle-iOS.swift | 1 + .../IdentifyItemView/IdentifyItemView.swift | 20 +- .../Views/ItemEditorView/ItemEditorView.swift | 4 + .../ItemImages/AddItemImageView.swift | 214 ++++++++++ .../ItemImageDetailsDeleteButton.swift | 50 +++ .../ItemImageDetailsDetailsSection.swift | 103 +++++ .../ItemImageDetailsHeaderSection.swift | 43 ++ .../ItemImageDetailsView.swift | 126 ++++++ .../ItemImages/ItemImagesView.swift | 222 ++++++++++ .../Components/ItemPhotoCropView.swift | 59 +++ .../ItemPhotoPickerView.swift | 27 ++ .../AddItemElementView.swift | 141 +++++++ .../Components/NameInput.swift | 87 ++++ .../Components/SearchResultsSection.swift | 112 +++++ .../Components/EditItemElementRow.swift | 113 +++++ .../EditItemElementView.swift | 274 ++++++++++++ .../Components/Sections/DateSection.swift | 0 .../Sections/DisplayOrderSection.swift | 0 .../Components/Sections/EpisodeSection.swift | 0 .../Sections/LocalizationSection.swift | 0 .../Sections/LockMetadataSection.swift | 0 .../Sections/MediaFormatSection.swift | 0 .../Components/Sections/OverviewSection.swift | 0 .../Sections/ParentialRatingsSection.swift | 0 .../Components/Sections/ReviewsSection.swift | 0 .../Components/Sections/SeriesSection.swift | 0 .../Components/Sections/TitleSection.swift | 0 .../EditMetadataView/EditMetadataView.swift | 0 .../Components/PhotoCropView.swift | 161 +++++++ .../PhotoPickerView/PhotoPickerView.swift | 86 ++++ .../Components/PhotoPicker.swift | 90 ---- .../Components/SquareImageCropView.swift | 198 --------- .../Components/UserProfileImageCropView.swift | 61 +++ ...swift => UserProfileImagePickerView.swift} | 11 +- Translations/en.lproj/Localizable.strings | 75 ++++ 51 files changed, 2923 insertions(+), 365 deletions(-) create mode 100644 Shared/Coordinators/ItemImagePickerCoordinator.swift create mode 100644 Shared/Coordinators/ItemImagesCoordinator.swift create mode 100644 Shared/Extensions/JellyfinAPI/ImageInfo.swift create mode 100644 Shared/Extensions/JellyfinAPI/ImageType.swift create mode 100644 Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift rename Shared/Extensions/{Hashable.swift => RatingType.swift} (54%) create mode 100644 Shared/ViewModels/ItemAdministration/ItemImagesViewModel.swift create mode 100644 Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift create mode 100644 Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/DateSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/DisplayOrderSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/EpisodeSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/LocalizationSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/LockMetadataSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/MediaFormatSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/OverviewSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/ParentialRatingsSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/ReviewsSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/SeriesSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/Components/Sections/TitleSection.swift (100%) rename Swiftfin/Views/ItemEditorView/{ => ItemMetadata}/EditMetadataView/EditMetadataView.swift (100%) create mode 100644 Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift create mode 100644 Swiftfin/Views/PhotoPickerView/PhotoPickerView.swift delete mode 100644 Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift delete mode 100644 Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift create mode 100644 Swiftfin/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift rename Swiftfin/Views/UserProfileImagePicker/{UserProfileImagePicker.swift => UserProfileImagePickerView.swift} (70%) diff --git a/Shared/Coordinators/ItemEditorCoordinator.swift b/Shared/Coordinators/ItemEditorCoordinator.swift index d97201df3..29fec728f 100644 --- a/Shared/Coordinators/ItemEditorCoordinator.swift +++ b/Shared/Coordinators/ItemEditorCoordinator.swift @@ -26,6 +26,11 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable { @Route(.modal) var editMetadata = makeEditMetadata + // MARK: - Route to Images + + @Route(.modal) + var editImages = makeEditImages + // MARK: - Route to Genres @Route(.push) @@ -73,6 +78,12 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable { } } + // MARK: - Item Images + + func makeEditImages(viewModel: ItemImagesViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemImagesCoordinator(viewModel: viewModel)) + } + // MARK: - Item Genres @ViewBuilder diff --git a/Shared/Coordinators/ItemImagePickerCoordinator.swift b/Shared/Coordinators/ItemImagePickerCoordinator.swift new file mode 100644 index 000000000..35353e8e3 --- /dev/null +++ b/Shared/Coordinators/ItemImagePickerCoordinator.swift @@ -0,0 +1,52 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import Stinsen +import SwiftUI + +final class ItemImagePickerCoordinator: NavigationCoordinatable { + + // MARK: - Navigation Stack + + let stack = Stinsen.NavigationStack(initial: \ItemImagePickerCoordinator.start) + + @Root + var start = makeStart + + // MARK: - Routes + + @Route(.push) + var cropImage = makeCropImage + + // MARK: - Observed Object + + private let viewModel: ItemImagesViewModel + + // MARK: - Image Variable + + let type: ImageType + + // MARK: - Initializer + + init(viewModel: ItemImagesViewModel, type: ImageType) { + self.viewModel = viewModel + self.type = type + } + + // MARK: - Crop Image View + + func makeCropImage(image: UIImage) -> some View { + ItemPhotoCropView(viewModel: viewModel, image: image, type: type) + } + + @ViewBuilder + func makeStart() -> some View { + ItemImagePicker() + } +} diff --git a/Shared/Coordinators/ItemImagesCoordinator.swift b/Shared/Coordinators/ItemImagesCoordinator.swift new file mode 100644 index 000000000..24175a7ab --- /dev/null +++ b/Shared/Coordinators/ItemImagesCoordinator.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) 2025 Jellyfin & Jellyfin Contributors +// + +import Factory +import JellyfinAPI +import Stinsen +import SwiftUI + +final class ItemImagesCoordinator: ObservableObject, NavigationCoordinatable { + + // MARK: - Navigation Stack + + let stack = NavigationStack(initial: \ItemImagesCoordinator.start) + + @Root + var start = makeStart + + private let viewModel: ItemImagesViewModel + + // MARK: - Route to Add Remote Image + + @Route(.push) + var addImage = makeAddImage + + // MARK: - Route to Photo Picker + + @Route(.modal) + var photoPicker = makePhotoPicker + + // MARK: - Initializer + + init(viewModel: ItemImagesViewModel) { + self.viewModel = viewModel + } + + // MARK: - Add Remote Images View + + @ViewBuilder + func makeAddImage(imageType: ImageType) -> some View { + AddItemImageView(viewModel: viewModel, imageType: imageType) + } + + // MARK: - Photo Picker View + + func makePhotoPicker(type: ImageType) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemImagePickerCoordinator(viewModel: self.viewModel, type: type)) + } + + // MARK: - Start + + @ViewBuilder + func makeStart() -> some View { + ItemImagesView(viewModel: self.viewModel) + } +} diff --git a/Shared/Coordinators/UserProfileImageCoordinator.swift b/Shared/Coordinators/UserProfileImageCoordinator.swift index d9b217839..e5188896e 100644 --- a/Shared/Coordinators/UserProfileImageCoordinator.swift +++ b/Shared/Coordinators/UserProfileImageCoordinator.swift @@ -11,7 +11,7 @@ import SwiftUI final class UserProfileImageCoordinator: NavigationCoordinatable { - // MARK: - Navigation Components + // MARK: - Navigation Stack let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start) @@ -37,19 +37,11 @@ final class UserProfileImageCoordinator: NavigationCoordinatable { // MARK: - Views func makeCropImage(image: UIImage) -> some View { - #if os(iOS) - UserProfileImagePicker.SquareImageCropView(viewModel: viewModel, image: image) - #else - AssertionFailureView("not implemented") - #endif + UserProfileImageCropView(viewModel: viewModel, image: image) } @ViewBuilder func makeStart() -> some View { - #if os(iOS) - UserProfileImagePicker(viewModel: viewModel) - #else - AssertionFailureView("not implemented") - #endif + UserProfileImagePickerView() } } diff --git a/Shared/Extensions/JellyfinAPI/ImageInfo.swift b/Shared/Extensions/JellyfinAPI/ImageInfo.swift new file mode 100644 index 000000000..f8cfd6c10 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/ImageInfo.swift @@ -0,0 +1,36 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension ImageInfo: @retroactive Identifiable { + + public var id: Int { + hashValue + } +} + +extension ImageInfo { + + func itemImageSource(itemID: String, client: JellyfinClient) -> ImageSource { + let parameters = Paths.GetItemImageParameters( + tag: imageTag, + imageIndex: imageIndex + ) + let request = Paths.getItemImage( + itemID: itemID, + imageType: imageType?.rawValue ?? "", + parameters: parameters + ) + + let itemImageURL = client.fullURL(with: request) + + return ImageSource(url: itemImageURL) + } +} diff --git a/Shared/Extensions/JellyfinAPI/ImageType.swift b/Shared/Extensions/JellyfinAPI/ImageType.swift new file mode 100644 index 000000000..d90ae1d2f --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/ImageType.swift @@ -0,0 +1,44 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension ImageType: Displayable { + + var displayTitle: String { + switch self { + case .primary: + return L10n.primary + case .art: + return L10n.art + case .backdrop: + return L10n.backdrop + case .banner: + return L10n.banner + case .logo: + return L10n.logo + case .thumb: + return L10n.thumb + case .disc: + return L10n.disc + case .box: + return L10n.box + case .screenshot: + return L10n.screenshot + case .menu: + return L10n.menu + case .chapter: + return L10n.chapter + case .boxRear: + return L10n.boxRear + case .profile: + return L10n.profile + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift b/Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift new file mode 100644 index 000000000..8be12d1be --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift @@ -0,0 +1,34 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI + +extension RemoteImageInfo: @retroactive Identifiable, Poster { + + var displayTitle: String { + providerName ?? L10n.unknown + } + + var unwrappedIDHashOrZero: Int { + id + } + + var subtitle: String? { + language + } + + var systemImage: String { + "photo" + } + + public var id: Int { + hashValue + } +} diff --git a/Shared/Extensions/Nuke/ImagePipeline.swift b/Shared/Extensions/Nuke/ImagePipeline.swift index 68a544687..28c55b64b 100644 --- a/Shared/Extensions/Nuke/ImagePipeline.swift +++ b/Shared/Extensions/Nuke/ImagePipeline.swift @@ -55,6 +55,9 @@ extension ImagePipeline.Swiftfin { static let local: ImagePipeline = ImagePipeline(delegate: SwiftfinImagePipelineDelegate()) { $0.dataCache = DataCache.Swiftfin.local } + + /// An `ImagePipeline` for images to prevent more important images from losing their cache. + static let other: ImagePipeline = ImagePipeline(configuration: .withURLCache) } final class SwiftfinImagePipelineDelegate: ImagePipelineDelegate { diff --git a/Shared/Extensions/Hashable.swift b/Shared/Extensions/RatingType.swift similarity index 54% rename from Shared/Extensions/Hashable.swift rename to Shared/Extensions/RatingType.swift index 49bbb6a6d..aecd287da 100644 --- a/Shared/Extensions/Hashable.swift +++ b/Shared/Extensions/RatingType.swift @@ -6,11 +6,16 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // -import Foundation +import JellyfinAPI -extension Hashable { +extension RatingType: Displayable { - var hashString: String { - "\(hashValue)" + var displayTitle: String { + switch self { + case .score: + return L10n.score + case .likes: + return L10n.likes + } } } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 8face6c3e..fe6e0071e 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -78,10 +78,14 @@ internal enum L10n { } /// Album Artist internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "Album Artist") + /// All + internal static let all = L10n.tr("Localizable", "all", fallback: "All") /// All Audiences internal static let allAudiences = L10n.tr("Localizable", "allAudiences", fallback: "All Audiences") /// 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 languages + internal static let allLanguages = L10n.tr("Localizable", "allLanguages", fallback: "All languages") /// All Media internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") /// Allow collection management @@ -120,6 +124,8 @@ internal enum L10n { internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "Application Name") /// Arranger internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger") + /// Art + internal static let art = L10n.tr("Localizable", "art", fallback: "Art") /// Artist internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist") /// Aspect Fill @@ -156,6 +162,10 @@ internal enum L10n { internal static let autoPlay = L10n.tr("Localizable", "autoPlay", fallback: "Auto Play") /// Back internal static let back = L10n.tr("Localizable", "back", fallback: "Back") + /// Backdrop + internal static let backdrop = L10n.tr("Localizable", "backdrop", fallback: "Backdrop") + /// Banner + internal static let banner = L10n.tr("Localizable", "banner", fallback: "Banner") /// Bar Buttons internal static let barButtons = L10n.tr("Localizable", "barButtons", fallback: "Bar Buttons") /// Behavior @@ -222,6 +232,10 @@ internal enum L10n { internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue") /// Books internal static let books = L10n.tr("Localizable", "books", fallback: "Books") + /// Box + internal static let box = L10n.tr("Localizable", "box", fallback: "Box") + /// BoxRear + internal static let boxRear = L10n.tr("Localizable", "boxRear", fallback: "BoxRear") /// Bugs and Features internal static let bugsAndFeatures = L10n.tr("Localizable", "bugsAndFeatures", fallback: "Bugs and Features") /// Buttons @@ -242,6 +256,8 @@ internal enum L10n { internal static let changePin = L10n.tr("Localizable", "changePin", fallback: "Change Pin") /// Channels internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels") + /// Chapter + internal static let chapter = L10n.tr("Localizable", "chapter", fallback: "Chapter") /// Chapters internal static let chapters = L10n.tr("Localizable", "chapters", fallback: "Chapters") /// Chapter Slider @@ -398,6 +414,8 @@ internal enum L10n { } /// Are you sure you wish to delete this device? This session will be logged out. internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.") + /// Delete image + internal static let deleteImage = L10n.tr("Localizable", "deleteImage", fallback: "Delete image") /// 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. @@ -464,6 +482,8 @@ internal enum L10n { internal static let devices = L10n.tr("Localizable", "devices", fallback: "Devices") /// Digital internal static let digital = L10n.tr("Localizable", "digital", fallback: "Digital") + /// Dimensions + internal static let dimensions = L10n.tr("Localizable", "dimensions", fallback: "Dimensions") /// Direct Play 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. @@ -478,6 +498,8 @@ internal enum L10n { internal static let directStream = L10n.tr("Localizable", "directStream", fallback: "Direct Stream") /// Disabled internal static let disabled = L10n.tr("Localizable", "disabled", fallback: "Disabled") + /// Disc + internal static let disc = L10n.tr("Localizable", "disc", fallback: "Disc") /// Disclaimer internal static let disclaimer = L10n.tr("Localizable", "disclaimer", fallback: "Disclaimer") /// Dismiss @@ -624,6 +646,14 @@ internal enum L10n { internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle") /// Illustrator internal static let illustrator = L10n.tr("Localizable", "illustrator", fallback: "Illustrator") + /// Images + internal static let image = L10n.tr("Localizable", "image", fallback: "Images") + /// Images + internal static let images = L10n.tr("Localizable", "images", fallback: "Images") + /// Image source + internal static let imageSource = L10n.tr("Localizable", "imageSource", fallback: "Image source") + /// Index + internal static let index = L10n.tr("Localizable", "index", fallback: "Index") /// Indicators internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators") /// Inker @@ -696,6 +726,8 @@ internal enum L10n { internal static let light = L10n.tr("Localizable", "light", fallback: "Light") /// Liked Items internal static let likedItems = L10n.tr("Localizable", "likedItems", fallback: "Liked Items") + /// Likes + internal static let likes = L10n.tr("Localizable", "likes", fallback: "Likes") /// List internal static let list = L10n.tr("Localizable", "list", fallback: "List") /// Live TV @@ -718,6 +750,8 @@ internal enum L10n { internal static let lockedFields = L10n.tr("Localizable", "lockedFields", fallback: "Locked Fields") /// Locked users internal static let lockedUsers = L10n.tr("Localizable", "lockedUsers", fallback: "Locked users") + /// Logo + internal static let logo = L10n.tr("Localizable", "logo", fallback: "Logo") /// Logs internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs") /// Access the Jellyfin server logs for troubleshooting and monitoring purposes. @@ -758,6 +792,8 @@ internal enum L10n { internal static let mediaPlayback = L10n.tr("Localizable", "mediaPlayback", fallback: "Media playback") /// Mbps internal static let megabitsPerSecond = L10n.tr("Localizable", "megabitsPerSecond", fallback: "Mbps") + /// Menu + internal static let menu = L10n.tr("Localizable", "menu", fallback: "Menu") /// Menu Buttons internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons") /// Metadata @@ -926,6 +962,8 @@ internal enum L10n { internal static let productionLocations = L10n.tr("Localizable", "productionLocations", fallback: "Production Locations") /// Production Year internal static let productionYear = L10n.tr("Localizable", "productionYear", fallback: "Production Year") + /// Profile + internal static let profile = L10n.tr("Localizable", "profile", fallback: "Profile") /// Profile Image internal static let profileImage = L10n.tr("Localizable", "profileImage", fallback: "Profile Image") /// Profiles @@ -1066,6 +1104,10 @@ internal enum L10n { internal static let saveUserWithoutAuthDescription = L10n.tr("Localizable", "saveUserWithoutAuthDescription", fallback: "Save the user to this device without any local authentication.") /// Schedule already exists internal static let scheduleAlreadyExists = L10n.tr("Localizable", "scheduleAlreadyExists", fallback: "Schedule already exists") + /// Score + internal static let score = L10n.tr("Localizable", "score", fallback: "Score") + /// Screenshot + internal static let screenshot = L10n.tr("Localizable", "screenshot", fallback: "Screenshot") /// Scrub Current Time internal static let scrubCurrentTime = L10n.tr("Localizable", "scrubCurrentTime", fallback: "Scrub Current Time") /// Search @@ -1250,6 +1292,8 @@ internal enum L10n { internal static let terabitsPerSecond = L10n.tr("Localizable", "terabitsPerSecond", fallback: "Tbps") /// Test Size internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size") + /// Thumb + internal static let thumb = L10n.tr("Localizable", "thumb", fallback: "Thumb") /// Time internal static let time = L10n.tr("Localizable", "time", fallback: "Time") /// Time Limit @@ -1320,6 +1364,10 @@ internal enum L10n { internal static let unreleased = L10n.tr("Localizable", "unreleased", fallback: "Unreleased") /// You have unsaved changes. Are you sure you want to discard them? internal static let unsavedChangesMessage = L10n.tr("Localizable", "unsavedChangesMessage", fallback: "You have unsaved changes. Are you sure you want to discard them?") + /// Upload file + internal static let uploadFile = L10n.tr("Localizable", "uploadFile", fallback: "Upload file") + /// Upload photo + internal static let uploadPhoto = L10n.tr("Localizable", "uploadPhoto", fallback: "Upload photo") /// URL internal static let url = L10n.tr("Localizable", "url", fallback: "URL") /// Use as Transcoding Profile @@ -1374,6 +1422,8 @@ internal enum L10n { internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "Video transcoding") /// Some views may need an app restart to update. internal static let viewsMayRequireRestart = L10n.tr("Localizable", "viewsMayRequireRestart", fallback: "Some views may need an app restart to update.") + /// Votes + internal static let votes = L10n.tr("Localizable", "votes", fallback: "Votes") /// Weekday internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday") /// Weekend diff --git a/Shared/ViewModels/ItemAdministration/ItemImagesViewModel.swift b/Shared/ViewModels/ItemAdministration/ItemImagesViewModel.swift new file mode 100644 index 000000000..c544a12af --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/ItemImagesViewModel.swift @@ -0,0 +1,399 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI +import OrderedCollections +import SwiftUI + +class ItemImagesViewModel: ViewModel, Stateful, Eventful { + + enum Event: Equatable { + case updated + case error(JellyfinAPIError) + } + + enum Action: Equatable { + case cancel + case refresh + case setImage(RemoteImageInfo) + case uploadImage(image: UIImage, type: ImageType) + case uploadFile(file: URL, type: ImageType) + case deleteImage(ImageInfo) + } + + enum BackgroundState: Hashable { + case updating + } + + enum State: Hashable { + case initial + case content + case error(JellyfinAPIError) + } + + // MARK: - Published Variables + + @Published + var item: BaseItemDto + @Published + var images: [ImageType: [ImageInfo]] = [:] + + // MARK: - State Management + + @Published + var state: State = .initial + @Published + var backgroundStates: OrderedSet = [] + + private var task: AnyCancellable? + private let eventSubject = PassthroughSubject() + + // MARK: - Eventful + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + // MARK: - Init + + init(item: BaseItemDto) { + self.item = item + } + + // MARK: - Respond to Actions + + func respond(to action: Action) -> State { + switch action { + + case .cancel: + task?.cancel() + return .initial + + case .refresh: + task?.cancel() + + task = Task { [weak self] in + guard let self else { return } + do { + await MainActor.run { + _ = self.backgroundStates.append(.updating) + self.images.removeAll() + } + + try await self.getAllImages() + + await MainActor.run { + self.state = .content + _ = self.backgroundStates.remove(.updating) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .error(apiError) + self.eventSubject.send(.error(apiError)) + self.backgroundStates.remove(.updating) + } + } + }.asAnyCancellable() + + return .initial + + case let .setImage(remoteImageInfo): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + _ = self.backgroundStates.append(.updating) + } + + try await self.setImage(remoteImageInfo) + try await self.getAllImages() + + await MainActor.run { + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.eventSubject.send(.error(apiError)) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.updating) + } + }.asAnyCancellable() + + return .content + + case let .uploadImage(image, type): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + _ = self.backgroundStates.append(.updating) + } + + try await self.uploadPhoto(image, type: type) + try await self.getAllImages() + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.eventSubject.send(.error(apiError)) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.updating) + } + }.asAnyCancellable() + + return .content + + case let .uploadFile(url, type): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + _ = self.backgroundStates.append(.updating) + } + + try await self.uploadFile(url, type: type) + try await self.getAllImages() + + await MainActor.run { + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.eventSubject.send(.error(apiError)) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.updating) + } + }.asAnyCancellable() + + return .content + + case let .deleteImage(imageInfo): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + _ = self.backgroundStates.append(.updating) + } + + try await deleteImage(imageInfo) + try await refreshItem() + + await MainActor.run { + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.eventSubject.send(.error(apiError)) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.updating) + } + }.asAnyCancellable() + + return .content + } + } + + // MARK: - Get All Item Images + + private func getAllImages() async throws { + guard let itemID = item.id else { return } + + let request = Paths.getItemImageInfos(itemID: itemID) + let response = try await self.userSession.client.send(request) + + let newImages: [ImageType: [ImageInfo]] = response.value.grouped(by: \.imageType) + .mapValues { $0.sorted(using: \.imageIndex) } + .reduce(into: [:]) { partialResult, kv in + guard let k = kv.key else { return } + partialResult[k] = kv.value + } + + await MainActor.run { + self.images = newImages + } + } + + // MARK: - Set Image From URL + + private func setImage(_ remoteImageInfo: RemoteImageInfo) async throws { + guard let itemID = item.id, + let type = remoteImageInfo.type, + let imageURL = remoteImageInfo.url else { return } + + let parameters = Paths.DownloadRemoteImageParameters(type: type, imageURL: imageURL) + let imageRequest = Paths.downloadRemoteImage(itemID: itemID, parameters: parameters) + try await userSession.client.send(imageRequest) + } + + // MARK: - Upload Image/File + + private func upload(imageData: Data, imageType: ImageType, contentType: String) async throws { + guard let itemID = item.id else { return } + + let uploadLimit: Int = 30_000_000 + + guard imageData.count <= uploadLimit else { + throw JellyfinAPIError( + "This image (\(imageData.count.formatted(.byteCount(style: .file)))) exceeds the maximum allowed size for upload (\(uploadLimit.formatted(.byteCount(style: .file)))." + ) + } + + var request = Paths.setItemImage( + itemID: itemID, + imageType: imageType.rawValue, + imageData.base64EncodedData() + ) + request.headers = ["Content-Type": contentType] + + _ = try await userSession.client.send(request) + } + + // MARK: - Prepare Photo for Upload + + private func uploadPhoto(_ image: UIImage, type: ImageType) async throws { + let contentType: String + let imageData: Data + + if let pngData = image.pngData() { + contentType = "image/png" + imageData = pngData + } else if let jpgData = image.jpegData(compressionQuality: 1) { + contentType = "image/jpeg" + imageData = jpgData + } else { + logger.error("Unable to convert given profile image to png/jpg") + throw JellyfinAPIError("An internal error occurred") + } + + try await upload( + imageData: imageData, + imageType: type, + contentType: contentType + ) + } + + // MARK: - Prepare Image for Upload + + private func uploadFile(_ url: URL, type: ImageType) async throws { + guard url.startAccessingSecurityScopedResource() else { + logger.error("Unable to access file at \(url)") + throw JellyfinAPIError("An internal error occurred.") + } + defer { url.stopAccessingSecurityScopedResource() } + + let contentType: String + let imageData: Data + + switch url.pathExtension.lowercased() { + case "png": + contentType = "image/png" + imageData = try Data(contentsOf: url) + case "jpeg", "jpg": + contentType = "image/jpeg" + imageData = try Data(contentsOf: url) + default: + guard let image = try UIImage(data: Data(contentsOf: url)) else { + logger.error("Unable to load image from file") + throw JellyfinAPIError("An internal error occurred.") + } + + if let pngData = image.pngData() { + contentType = "image/png" + imageData = pngData + } else if let jpgData = image.jpegData(compressionQuality: 1) { + contentType = "image/jpeg" + imageData = jpgData + } else { + logger.error("Failed to convert image to png/jpg") + throw JellyfinAPIError("An internal error occurred.") + } + } + + try await upload( + imageData: imageData, + imageType: type, + contentType: contentType + ) + } + + // MARK: - Delete Image + + private func deleteImage(_ imageInfo: ImageInfo) async throws { + guard let itemID = item.id, + let imageType = imageInfo.imageType else { return } + + if let imageIndex = imageInfo.imageIndex { + let request = Paths.deleteItemImageByIndex( + itemID: itemID, + imageType: imageType.rawValue, + imageIndex: imageIndex + ) + + try await userSession.client.send(request) + } else { + let request = Paths.deleteItemImage( + itemID: itemID, + imageType: imageType.rawValue + ) + + try await userSession.client.send(request) + } + + try await getAllImages() + } + + // MARK: - Refresh Item + + private func refreshItem() async throws { + guard let itemID = item.id else { return } + + await MainActor.run { + _ = backgroundStates.append(.updating) + } + + 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(.updating) + Notifications[.itemMetadataDidChange].post(item) + } + } +} diff --git a/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift b/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift new file mode 100644 index 000000000..22e380a58 --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift @@ -0,0 +1,64 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +class RemoteImageInfoViewModel: PagingLibraryViewModel { + + // Image providers come from the paging call + @Published + private(set) var providers: [String] = [] + + @Published + var includeAllLanguages: Bool = false { + didSet { + DispatchQueue.main.async { + self.send(.refresh) + } + } + } + + @Published + var provider: String? = nil { + didSet { + DispatchQueue.main.async { + self.send(.refresh) + } + } + } + + let imageType: ImageType + + init(imageType: ImageType, parent: BaseItemDto) { + + self.imageType = imageType + + super.init(parent: parent) + } + + override func get(page: Int) async throws -> [RemoteImageInfo] { + guard let itemID = parent?.id else { return [] } + + var parameters = Paths.GetRemoteImagesParameters() + parameters.isIncludeAllLanguages = includeAllLanguages + parameters.limit = pageSize + parameters.providerName = provider + parameters.startIndex = page * pageSize + parameters.type = imageType + + let request = Paths.getRemoteImages(itemID: itemID, parameters: parameters) + let response = try await userSession.client.send(request) + + await MainActor.run { + providers = response.value.providers ?? [] + } + + return response.value.images ?? [] + } +} diff --git a/Shared/ViewModels/UserProfileImageViewModel.swift b/Shared/ViewModels/UserProfileImageViewModel.swift index 2fbfe7626..bcdff63e1 100644 --- a/Shared/ViewModels/UserProfileImageViewModel.swift +++ b/Shared/ViewModels/UserProfileImageViewModel.swift @@ -151,6 +151,12 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { ) request.headers = ["Content-Type": contentType] + guard imageData.count <= 30_000_000 else { + throw JellyfinAPIError( + "This profile image is too large (\(imageData.count.formatted(.byteCount(style: .file)))). The upload limit for images is 30 MB." + ) + } + let _ = try await userSession.client.send(request) sweepProfileImageCache() diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index de6413a78..a526fe677 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 4E10C81D2CC046610012CC9F /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C81C2CC0465F0012CC9F /* UserSection.swift */; }; 4E11805F2CBF52380077A588 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; 4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; + 4E13FAD82D18D5AF007785F6 /* ImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */; }; + 4E13FAD92D18D5AF007785F6 /* ImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */; }; 4E14DC032CD43DD2001B621B /* AdminDashboardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E14DC022CD43DCB001B621B /* AdminDashboardCoordinator.swift */; }; 4E16FD512C0183DB00110147 /* LetterPickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */; }; 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD522C01840C00110147 /* LetterPickerBar.swift */; }; @@ -31,6 +33,8 @@ 4E17498F2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */; }; 4E182C9C2C94993200FBEFD5 /* ServerTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */; }; 4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */; }; + 4E1AA0042D0640AA00524970 /* RemoteImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */; }; + 4E1AA0052D0640AA00524970 /* RemoteImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */; }; 4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; }; 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; @@ -67,8 +71,12 @@ 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 */; }; + 4E37F6162D17C1860022AADD /* RemoteImageInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E37F6152D17C1710022AADD /* RemoteImageInfoViewModel.swift */; }; 4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; }; 4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; }; + 4E45939E2D04E20000E277E1 /* ItemImagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45939D2D04E1E600E277E1 /* ItemImagesViewModel.swift */; }; + 4E4593A32D04E2B500E277E1 /* ItemImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4593A22D04E2AF00E277E1 /* ItemImagesView.swift */; }; + 4E4593A62D04E4E300E277E1 /* AddItemImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4593A52D04E4DE00E277E1 /* AddItemImageView.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 */; }; @@ -78,22 +86,16 @@ 4E49DED52CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */; }; 4E49DED62CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */; }; 4E49DED82CE5509300352DCD /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED72CE5509000352DCD /* StatusSection.swift */; }; - 4E49DEE02CE55F7F00352DCD /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEDC2CE55F7F00352DCD /* PhotoPicker.swift */; }; - 4E49DEE12CE55F7F00352DCD /* SquareImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEDD2CE55F7F00352DCD /* SquareImageCropView.swift */; }; 4E49DEE32CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; }; 4E49DEE42CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; }; - 4E49DEE62CE5616800352DCD /* UserProfileImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */; }; + 4E49DEE62CE5616800352DCD /* UserProfileImagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE52CE5616800352DCD /* UserProfileImagePickerView.swift */; }; 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; }; 4E4DAC372D11EE5E00E13FF9 /* SplitLoginWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4DAC362D11EE4F00E13FF9 /* SplitLoginWindowView.swift */; }; 4E4DAC3D2D11F94400E13FF9 /* LocalServerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4DAC3C2D11F94000E13FF9 /* LocalServerButton.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 */; }; 4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; }; @@ -106,7 +108,6 @@ 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; 4E656C302D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; }; 4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; }; - 4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; }; 4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; }; 4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A002CEFE39900025C99 /* EditMetadataView.swift */; }; 4E661A0F2CEFE46300025C99 /* SeriesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0D2CEFE46300025C99 /* SeriesSection.swift */; }; @@ -175,6 +176,13 @@ 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; }; 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; }; 4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; }; + 4EA78B132D29F62E0093BFCE /* ItemImagesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B112D29F6240093BFCE /* ItemImagesCoordinator.swift */; }; + 4EA78B162D2A0C4A0093BFCE /* ItemImageDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B142D2A0C4A0093BFCE /* ItemImageDetailsView.swift */; }; + 4EA78B202D2B5AA30093BFCE /* ItemPhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */; }; + 4EA78B232D2B5CFC0093BFCE /* ItemPhotoCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */; }; + 4EA78B252D2B5DBD0093BFCE /* ItemImagePickerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */; }; + 4EB132EF2D2CF6D600B5A8E5 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */; }; + 4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; }; @@ -219,11 +227,18 @@ 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; 4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */; }; 4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; }; - 4EE766F82D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; }; 4EE766FA2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; }; 4EE766FB2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; }; 4EE767082D13403F009658F0 /* RemoteSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */; }; 4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */; }; + 4EECA4E32D2C7D530080A863 /* PhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4E22D2C7D530080A863 /* PhotoPickerView.swift */; }; + 4EECA4E62D2C7D650080A863 /* PhotoCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4E52D2C7D650080A863 /* PhotoCropView.swift */; }; + 4EECA4ED2D2C89D70080A863 /* UserProfileImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4EC2D2C89D20080A863 /* UserProfileImageCropView.swift */; }; + 4EECA4EF2D2C9B310080A863 /* ItemImageDetailsHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4EE2D2C9B260080A863 /* ItemImageDetailsHeaderSection.swift */; }; + 4EECA4F12D2C9E860080A863 /* ItemImageDetailsDetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F02D2C9E7B0080A863 /* ItemImageDetailsDetailsSection.swift */; }; + 4EECA4F32D2CA5A10080A863 /* ItemImageDetailsDeleteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F22D2CA59B0080A863 /* ItemImageDetailsDeleteButton.swift */; }; + 4EECA4F52D2CAA380080A863 /* RatingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F42D2CAA350080A863 /* RatingType.swift */; }; + 4EECA4F62D2CAA380080A863 /* RatingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F42D2CAA350080A863 /* RatingType.swift */; }; 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; }; 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; }; 4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; }; @@ -522,7 +537,6 @@ E11BDF972B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; E11BDF982B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */; }; - E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */; }; E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; E11CEB8B28998552003E74C7 /* View-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* View-iOS.swift */; }; E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; }; @@ -810,8 +824,6 @@ E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; }; E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55828C125E900311DFE /* StudiosHStack.swift */; }; E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55A28C1266400311DFE /* GenresHStack.swift */; }; - E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1803EA02BFBD6CF0039F90E /* Hashable.swift */; }; - E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1803EA02BFBD6CF0039F90E /* Hashable.swift */; }; E18121062CBE428000682985 /* ChevronButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528728FD229500600579 /* ChevronButton.swift */; }; E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; }; E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E18443CA2A037773002DDDC8 /* UDPBroadcast */; }; @@ -1205,6 +1217,7 @@ 4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceNameSection.swift; sourceTree = ""; }; 4E10C81C2CC0465F0012CC9F /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = ""; }; 4E12F9152CBE9615006C217E /* DeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceType.swift; sourceTree = ""; }; + 4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageInfo.swift; sourceTree = ""; }; 4E14DC022CD43DCB001B621B /* AdminDashboardCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDashboardCoordinator.swift; sourceTree = ""; }; 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerButton.swift; sourceTree = ""; }; 4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = ""; }; @@ -1212,6 +1225,7 @@ 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTasksView.swift; sourceTree = ""; }; 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskRow.swift; sourceTree = ""; }; + 4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageInfo.swift; sourceTree = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; 4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = ""; }; 4E2470062D078DD7009139D8 /* ServerUserParentalRatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserParentalRatingView.swift; sourceTree = ""; }; @@ -1238,18 +1252,20 @@ 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 = ""; }; + 4E37F6152D17C1710022AADD /* RemoteImageInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageInfoViewModel.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 = ""; }; + 4E45939D2D04E1E600E277E1 /* ItemImagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagesViewModel.swift; sourceTree = ""; }; + 4E4593A22D04E2AF00E277E1 /* ItemImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagesView.swift; sourceTree = ""; }; + 4E4593A52D04E4DE00E277E1 /* AddItemImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddItemImageView.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 = ""; }; 4E49DED12CE54D6900352DCD /* ActiveSessionsPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsPolicy.swift; sourceTree = ""; }; 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFailurePolicy.swift; sourceTree = ""; }; 4E49DED72CE5509000352DCD /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; - 4E49DEDC2CE55F7F00352DCD /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; - 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 = ""; }; + 4E49DEE52CE5616800352DCD /* UserProfileImagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImagePickerView.swift; sourceTree = ""; }; 4E4DAC362D11EE4F00E13FF9 /* SplitLoginWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitLoginWindowView.swift; sourceTree = ""; }; 4E4DAC3C2D11F94000E13FF9 /* LocalServerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalServerButton.swift; sourceTree = ""; }; 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioEditorViewModel.swift; sourceTree = ""; }; @@ -1321,6 +1337,12 @@ 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = ""; }; 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = ""; }; 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserViewModel.swift; sourceTree = ""; }; + 4EA78B112D29F6240093BFCE /* ItemImagesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagesCoordinator.swift; sourceTree = ""; }; + 4EA78B142D2A0C4A0093BFCE /* ItemImageDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsView.swift; sourceTree = ""; }; + 4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPhotoPickerView.swift; sourceTree = ""; }; + 4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPhotoCropView.swift; sourceTree = ""; }; + 4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagePickerCoordinator.swift; sourceTree = ""; }; + 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = ""; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = ""; }; @@ -1362,6 +1384,13 @@ 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResult.swift; sourceTree = ""; }; 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultRow.swift; sourceTree = ""; }; 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultView.swift; sourceTree = ""; }; + 4EECA4E22D2C7D530080A863 /* PhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerView.swift; sourceTree = ""; }; + 4EECA4E52D2C7D650080A863 /* PhotoCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCropView.swift; sourceTree = ""; }; + 4EECA4EC2D2C89D20080A863 /* UserProfileImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageCropView.swift; sourceTree = ""; }; + 4EECA4EE2D2C9B260080A863 /* ItemImageDetailsHeaderSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsHeaderSection.swift; sourceTree = ""; }; + 4EECA4F02D2C9E7B0080A863 /* ItemImageDetailsDetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsDetailsSection.swift; sourceTree = ""; }; + 4EECA4F22D2CA59B0080A863 /* ItemImageDetailsDeleteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsDeleteButton.swift; sourceTree = ""; }; + 4EECA4F42D2CAA350080A863 /* RatingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingType.swift; sourceTree = ""; }; 4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = ""; }; 4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = ""; }; 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = ""; }; @@ -1753,7 +1782,6 @@ E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = ""; }; E17FB55828C125E900311DFE /* StudiosHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudiosHStack.swift; sourceTree = ""; }; E17FB55A28C1266400311DFE /* GenresHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenresHStack.swift; sourceTree = ""; }; - E1803EA02BFBD6CF0039F90E /* Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hashable.swift; sourceTree = ""; }; E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = ""; }; E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = ""; }; E185920928CEF23A00326F80 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = ""; }; @@ -2276,13 +2304,25 @@ path = ServerLogsView; sourceTree = ""; }; - 4E3766192D2144BA00C5D7A5 /* ItemElements */ = { + 4E37F6182D17EB220022AADD /* ItemImages */ = { + isa = PBXGroup; + children = ( + 4E4593A52D04E4DE00E277E1 /* AddItemImageView.swift */, + 4EA78B152D2A0C4A0093BFCE /* ItemImageDetailsView */, + 4E4593A22D04E2AF00E277E1 /* ItemImagesView.swift */, + 4EA78B1E2D2B5A960093BFCE /* ItemPhotoPickerView */, + ); + path = ItemImages; + sourceTree = ""; + }; + 4E37F6192D17EB3C0022AADD /* ItemMetadata */ = { isa = PBXGroup; children = ( 4E5071E22CFCEFC3003FA2AD /* AddItemElementView */, 4E31EFA22CFFFB410053DFE7 /* EditItemElementView */, + 4E6619FF2CEFE39000025C99 /* EditMetadataView */, ); - path = ItemElements; + path = ItemMetadata; sourceTree = ""; }; 4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = { @@ -2297,8 +2337,7 @@ 4E49DEDE2CE55F7F00352DCD /* Components */ = { isa = PBXGroup; children = ( - 4E49DEDC2CE55F7F00352DCD /* PhotoPicker.swift */, - 4E49DEDD2CE55F7F00352DCD /* SquareImageCropView.swift */, + 4EECA4EC2D2C89D20080A863 /* UserProfileImageCropView.swift */, ); path = Components; sourceTree = ""; @@ -2307,7 +2346,7 @@ isa = PBXGroup; children = ( 4E49DEDE2CE55F7F00352DCD /* Components */, - 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */, + 4E49DEE52CE5616800352DCD /* UserProfileImagePickerView.swift */, ); path = UserProfileImagePicker; sourceTree = ""; @@ -2531,10 +2570,10 @@ isa = PBXGroup; children = ( 4E8F74A62CE03D4C00CC8969 /* Components */, - 4E6619FF2CEFE39000025C99 /* EditMetadataView */, + 4E37F6182D17EB220022AADD /* ItemImages */, + 4E37F6192D17EB3C0022AADD /* ItemMetadata */, 4EE766F32D131F6E009658F0 /* IdentifyItemView */, 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */, - 4E3766192D2144BA00C5D7A5 /* ItemElements */, ); path = ItemEditorView; sourceTree = ""; @@ -2552,8 +2591,10 @@ children = ( 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */, 4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */, + 4E45939D2D04E1E600E277E1 /* ItemImagesViewModel.swift */, 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */, 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */, + 4E37F6152D17C1710022AADD /* RemoteImageInfoViewModel.swift */, ); path = ItemAdministration; sourceTree = ""; @@ -2648,6 +2689,42 @@ path = Components; sourceTree = ""; }; + 4EA78B152D2A0C4A0093BFCE /* ItemImageDetailsView */ = { + isa = PBXGroup; + children = ( + 4EA78B1B2D2A266A0093BFCE /* Components */, + 4EA78B142D2A0C4A0093BFCE /* ItemImageDetailsView.swift */, + ); + path = ItemImageDetailsView; + sourceTree = ""; + }; + 4EA78B1B2D2A266A0093BFCE /* Components */ = { + isa = PBXGroup; + children = ( + 4EECA4F22D2CA59B0080A863 /* ItemImageDetailsDeleteButton.swift */, + 4EECA4F02D2C9E7B0080A863 /* ItemImageDetailsDetailsSection.swift */, + 4EECA4EE2D2C9B260080A863 /* ItemImageDetailsHeaderSection.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EA78B1E2D2B5A960093BFCE /* ItemPhotoPickerView */ = { + isa = PBXGroup; + children = ( + 4EA78B212D2B5CDD0093BFCE /* Components */, + 4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */, + ); + path = ItemPhotoPickerView; + sourceTree = ""; + }; + 4EA78B212D2B5CDD0093BFCE /* Components */ = { + isa = PBXGroup; + children = ( + 4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */, + ); + path = Components; + sourceTree = ""; + }; 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = { isa = PBXGroup; children = ( @@ -2828,6 +2905,23 @@ path = Components; sourceTree = ""; }; + 4EECA4E12D2C7D450080A863 /* PhotoPickerView */ = { + isa = PBXGroup; + children = ( + 4EECA4E42D2C7D570080A863 /* Components */, + 4EECA4E22D2C7D530080A863 /* PhotoPickerView.swift */, + ); + path = PhotoPickerView; + sourceTree = ""; + }; + 4EECA4E42D2C7D570080A863 /* Components */ = { + isa = PBXGroup; + children = ( + 4EECA4E52D2C7D650080A863 /* PhotoCropView.swift */, + ); + path = Components; + sourceTree = ""; + }; 4EED87472CBF824B002354D2 /* Components */ = { isa = PBXGroup; children = ( @@ -3397,7 +3491,6 @@ E133328729538D8D00EE76AB /* Files.swift */, E11CEB8C28999B4A003E74C7 /* Font.swift */, E10432F52BE4426F006FF9DD /* FormatStyle.swift */, - E1803EA02BFBD6CF0039F90E /* Hashable.swift */, E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, E139CC1E28EC83E400688DE2 /* Int.swift */, E1AD105226D96D5F003E4A08 /* JellyfinAPI */, @@ -3406,6 +3499,7 @@ E1A505692D0B733F007EE305 /* Optional.swift */, E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, + 4EECA4F42D2CAA350080A863 /* RatingType.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */, E145EB442BE0AD4E003BF6F3 /* Set.swift */, 621338922660107500A81A2A /* String.swift */, @@ -3450,6 +3544,8 @@ 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */, 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */, + 4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */, + 4EA78B112D29F6240093BFCE /* ItemImagesCoordinator.swift */, 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */, E102312B2BCF8A08009D71FC /* LiveTVCoordinator */, C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */, @@ -3953,6 +4049,7 @@ E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, E103DF922BCF2F23000229B2 /* MediaView */, E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, + 4EECA4E12D2C7D450080A863 /* PhotoPickerView */, E10231342BCF8A3C009D71FC /* ProgramsView */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, 4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */, @@ -4100,8 +4197,8 @@ children = ( E1763A282BF3046A004DF6AB /* AddUserButton.swift */, E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, - E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */, + E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, ); path = Components; sourceTree = ""; @@ -4490,6 +4587,8 @@ E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */, 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, + 4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */, + 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */, E1D842902933F87500D1041A /* ItemFields.swift */, E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */, E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, @@ -4504,6 +4603,7 @@ 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, + 4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */, 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */, 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, @@ -5312,11 +5412,11 @@ E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E146A9D92BE6E9830034DA1E /* StoredValue.swift in Sources */, + 4E13FAD82D18D5AF007785F6 /* ImageInfo.swift in Sources */, E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */, E13D98EE2D0664C1005FE96D /* NotificationSet.swift in Sources */, E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */, - 4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */, E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */, E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, @@ -5361,7 +5461,6 @@ E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */, 4E49DED02CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */, - E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, 4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */, 4E24ECFC2D076F6200A473A9 /* ListRowCheckbox.swift in Sources */, @@ -5379,7 +5478,6 @@ E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, - 4EE766F82D132054009658F0 /* IdentifyItemViewModel.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, @@ -5408,7 +5506,6 @@ E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, E1CB75782C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */, E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */, - E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */, 4E49DEE32CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */, E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, E1575E93293E7B1E001665B1 /* Double.swift in Sources */, @@ -5444,6 +5541,7 @@ E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */, + 4EECA4F62D2CAA380080A863 /* RatingType.swift in Sources */, E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */, E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */, 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */, @@ -5466,7 +5564,6 @@ 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 */, @@ -5494,7 +5591,6 @@ 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 */, 4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, @@ -5531,7 +5627,6 @@ 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 */, @@ -5573,7 +5668,6 @@ 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 */, @@ -5643,6 +5737,7 @@ E18E021C2887492B0022598C /* BlurView.swift in Sources */, E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */, + 4E1AA0052D0640AA00524970 /* RemoteImageInfo.swift in Sources */, E1A5056B2D0B733F007EE305 /* Optional.swift in Sources */, E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, E1CB75832C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */, @@ -5670,6 +5765,7 @@ E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, + 4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */, E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E1CB75702C80E66700217C76 /* CommaStringBuilder.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, @@ -5755,7 +5851,7 @@ 4EB7C8D52CCED6E7000CC011 /* AddServerUserView.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, - E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */, + 4E4593A32D04E2B500E277E1 /* ItemImagesView.swift in Sources */, 4E699BB92CB33FC2007CBD5D /* HomeSection.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, @@ -5835,6 +5931,7 @@ C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, + 4E37F6162D17C1860022AADD /* RemoteImageInfoViewModel.swift in Sources */, 4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */, 4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, @@ -5846,6 +5943,7 @@ 4E661A2E2CEFE77700025C99 /* MetadataField.swift in Sources */, 4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, + 4E1AA0042D0640AA00524970 /* RemoteImageInfo.swift in Sources */, 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, @@ -5878,6 +5976,7 @@ E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, 4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */, E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */, + 4E4593A62D04E4E300E277E1 /* AddItemImageView.swift in Sources */, E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, 4E2AC4C52C6C492700DD600D /* MediaContainer.swift in Sources */, @@ -5889,6 +5988,7 @@ E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */, E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */, E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, + 4EECA4F52D2CAA380080A863 /* RatingType.swift in Sources */, 4E49DECF2CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */, E18ACA922A15A32F00BB4F35 /* (null) in Sources */, E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */, @@ -5915,6 +6015,7 @@ E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, E1D90D762C051D44000EA787 /* BackPort+ScrollIndicatorVisibility.swift in Sources */, + 4EECA4E62D2C7D650080A863 /* PhotoCropView.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, E145EB252BE055AD003BF6F3 /* ServerResponse.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, @@ -5922,6 +6023,7 @@ 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, E10E67B72CF515130095365B /* Binding.swift in Sources */, E119696A2CC99EA9001A58BE /* ServerTaskProgressSection.swift in Sources */, + 4EA78B132D29F62E0093BFCE /* ItemImagesCoordinator.swift in Sources */, E1BAFE102BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift in Sources */, E1ED7FDE2CAA641F00ACB6E3 /* ListTitleSection.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, @@ -5940,8 +6042,6 @@ E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, - 4E49DEE02CE55F7F00352DCD /* PhotoPicker.swift in Sources */, - 4E49DEE12CE55F7F00352DCD /* SquareImageCropView.swift in Sources */, 4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */, 4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */, 4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */, @@ -5976,6 +6076,7 @@ E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */, E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, + 4EECA4F32D2CA5A10080A863 /* ItemImageDetailsDeleteButton.swift in Sources */, E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, E19070492C84F2BB0004600E /* ButtonStyle-iOS.swift in Sources */, E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */, @@ -5985,12 +6086,14 @@ E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, 4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */, + 4E45939E2D04E20000E277E1 /* ItemImagesViewModel.swift in Sources */, C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */, 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 */, + 4EA78B162D2A0C4A0093BFCE /* ItemImageDetailsView.swift in Sources */, 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */, E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift in Sources */, E1CB757C2C80F00D00217C76 /* TranscodingProfile.swift in Sources */, @@ -6038,6 +6141,7 @@ C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, 4E17498E2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */, 4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */, + 4E13FAD92D18D5AF007785F6 /* ImageInfo.swift in Sources */, 4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, @@ -6121,6 +6225,7 @@ E1A1528D28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, 4E35CE642CBED69600DBD886 /* TaskTriggerType.swift in Sources */, E18E01EE288747230022598C /* AboutView.swift in Sources */, + 4EB132EF2D2CF6D600B5A8E5 /* ImageType.swift in Sources */, 62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */, E1549662296CA2EF00C4EF88 /* UserSession.swift in Sources */, @@ -6129,13 +6234,15 @@ E10B1ECD2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */, E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */, E1A1529028FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, + 4EA78B252D2B5DBD0093BFCE /* ItemImagePickerCoordinator.swift in Sources */, E11042752B8013DF00821020 /* Stateful.swift in Sources */, E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E12376AE2A33D680001F5B44 /* AboutView+Card.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */, - 4E49DEE62CE5616800352DCD /* UserProfileImagePicker.swift in Sources */, + 4E49DEE62CE5616800352DCD /* UserProfileImagePickerView.swift in Sources */, 4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */, E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, + 4EA78B232D2B5CFC0093BFCE /* ItemPhotoCropView.swift in Sources */, E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */, E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */, BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */, @@ -6174,6 +6281,7 @@ 4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */, + 4EECA4E32D2C7D530080A863 /* PhotoPickerView.swift in Sources */, E1ED7FDC2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */, E149CCAD2BE6ECC8008B9331 /* Storable.swift in Sources */, E1CB75792C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */, @@ -6226,6 +6334,7 @@ E1F5CF092CB0A04500607465 /* Text.swift in Sources */, 4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */, E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, + 4EA78B202D2B5AA30093BFCE /* ItemPhotoPickerView.swift in Sources */, E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */, E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */, E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */, @@ -6245,6 +6354,7 @@ C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, + 4EECA4F12D2C9E860080A863 /* ItemImageDetailsDetailsSection.swift in Sources */, E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */, BD3957752C112A330078CEF8 /* ButtonSection.swift in Sources */, E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */, @@ -6265,6 +6375,7 @@ E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */, + 4EECA4ED2D2C89D70080A863 /* UserProfileImageCropView.swift in Sources */, E18E01EA288747230022598C /* MovieItemView.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */, @@ -6293,6 +6404,7 @@ E13DD4022717EE79009D4DAF /* SelectUserCoordinator.swift in Sources */, E11245B128D919CD00D8A977 /* Overlay.swift in Sources */, E145EB4D2BE1688E003BF6F3 /* SwiftinStore+UserState.swift in Sources */, + 4EECA4EF2D2C9B310080A863 /* ItemImageDetailsHeaderSection.swift in Sources */, 53EE24E6265060780068F029 /* SearchView.swift in Sources */, E164A8152BE58C2F00A54B18 /* V2AnyData.swift in Sources */, E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */, diff --git a/Swiftfin/Components/ListRowButton.swift b/Swiftfin/Components/ListRowButton.swift index 5763d25d0..dc17bb2d8 100644 --- a/Swiftfin/Components/ListRowButton.swift +++ b/Swiftfin/Components/ListRowButton.swift @@ -13,38 +13,53 @@ import SwiftUI // Meant to be used within `List` or `Form` struct ListRowButton: View { - let title: String - let action: () -> Void + private let title: String + private let role: ButtonRole? + private let action: () -> Void - init(_ title: String, action: @escaping () -> Void) { + init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) { self.title = title + self.role = role self.action = action } var body: some View { - Button(title, action: action) - .font(.body.weight(.bold)) + Button(title, role: role, action: action) .buttonStyle(ListRowButtonStyle()) - .listRowInsets(.init(.zero)) + .listRowInsets(.zero) } } -// TODO: implement `role` private struct ListRowButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled + private func primaryStyle(configuration: Configuration) -> some ShapeStyle { + if configuration.role == .destructive || configuration.role == .cancel { + return AnyShapeStyle(Color.red) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.primary) + } + } + + private func secondaryStyle(configuration: Configuration) -> some ShapeStyle { + if configuration.role == .destructive { + return AnyShapeStyle(Color.red.opacity(0.2)) + } else { + return isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray) + } + } + func makeBody(configuration: Configuration) -> some View { ZStack { Rectangle() - .foregroundStyle(isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray)) + .fill(secondaryStyle(configuration: configuration)) configuration.label - .foregroundStyle(.primary) + .foregroundStyle(primaryStyle(configuration: configuration)) } .opacity(configuration.isPressed ? 0.75 : 1) - .frame(maxWidth: .infinity) - .listRowInsets(.zero) + .font(.body.weight(.bold)) } } diff --git a/Swiftfin/Components/ListTitleSection.swift b/Swiftfin/Components/ListTitleSection.swift index b330dfff5..c9d22a467 100644 --- a/Swiftfin/Components/ListTitleSection.swift +++ b/Swiftfin/Components/ListTitleSection.swift @@ -25,6 +25,7 @@ struct ListTitleSection: View { Text(title) .font(.title3) .fontWeight(.semibold) + .multilineTextAlignment(.center) if let description { Text(description) diff --git a/Swiftfin/Extensions/ButtonStyle-iOS.swift b/Swiftfin/Extensions/ButtonStyle-iOS.swift index 565dbfcbb..5e3da819b 100644 --- a/Swiftfin/Extensions/ButtonStyle-iOS.swift +++ b/Swiftfin/Extensions/ButtonStyle-iOS.swift @@ -20,6 +20,7 @@ extension ButtonStyle where Self == ToolbarPillButtonStyle { } } +// TODO: don't take `Color`, take generic `ShapeStyle` struct ToolbarPillButtonStyle: ButtonStyle { @Environment(\.isEnabled) diff --git a/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift b/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift index a9b42100e..ef42169dc 100644 --- a/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift +++ b/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift @@ -74,13 +74,19 @@ struct IdentifyItemView: View { .navigationTitle(L10n.identify) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(viewModel.state == .updating) - .sheet(item: $selectedResult) { result in - RemoteSearchResultView(result: result) { - selectedResult = nil - viewModel.send(.update(result)) - } onClose: { - selectedResult = nil - } + .sheet(item: $selectedResult) { + selectedResult = nil + } content: { result in + RemoteSearchResultView( + result: result, + onSave: { + selectedResult = nil + viewModel.send(.update(result)) + }, + onClose: { + selectedResult = nil + } + ) } .onReceive(viewModel.events) { events in switch events { diff --git a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift index 2075f917d..b5e4d5f7f 100644 --- a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift @@ -104,6 +104,10 @@ struct ItemEditorView: View { router.route(to: \.identifyItem, viewModel.item) } } + ChevronButton(L10n.images) + .onSelect { + router.route(to: \.editImages, ItemImagesViewModel(item: viewModel.item)) + } ChevronButton(L10n.metadata) .onSelect { router.route(to: \.editMetadata, viewModel.item) diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift new file mode 100644 index 000000000..52ed0fb39 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift @@ -0,0 +1,214 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import BlurHashKit +import CollectionVGrid +import JellyfinAPI +import SwiftUI + +// TODO: different layouts per image type +// - also based on iOS vs iPadOS + +struct AddItemImageView: View { + + // MARK: - Observed, & Environment Objects + + @EnvironmentObject + private var router: ItemImagesCoordinator.Router + + @ObservedObject + private var viewModel: ItemImagesViewModel + + @StateObject + private var remoteImageInfoViewModel: RemoteImageInfoViewModel + + // MARK: - Dialog State + + @State + private var selectedImage: RemoteImageInfo? + @State + private var error: Error? + + // MARK: - Collection Layout + + @State + private var layout: CollectionVGridLayout = .minWidth(150) + + // MARK: - Initializer + + init(viewModel: ItemImagesViewModel, imageType: ImageType) { + self.viewModel = viewModel + self._remoteImageInfoViewModel = StateObject( + wrappedValue: RemoteImageInfoViewModel( + imageType: imageType, + parent: viewModel.item + ) + ) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch remoteImageInfoViewModel.state { + case .initial, .refreshing: + DelayedProgressView() + case .content: + gridView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + } + .animation(.linear(duration: 0.1), value: remoteImageInfoViewModel.state) + .navigationTitle(remoteImageInfoViewModel.imageType.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating)) + .navigationBarMenuButton(isLoading: viewModel.backgroundStates.contains(.updating)) { + Button { + remoteImageInfoViewModel.includeAllLanguages.toggle() + } label: { + if remoteImageInfoViewModel.includeAllLanguages { + Label(L10n.allLanguages, systemImage: "checkmark") + } else { + Text(L10n.allLanguages) + } + } + + if remoteImageInfoViewModel.providers.isNotEmpty { + Menu { + Button { + remoteImageInfoViewModel.provider = nil + } label: { + if remoteImageInfoViewModel.provider == nil { + Label(L10n.all, systemImage: "checkmark") + } else { + Text(L10n.all) + } + } + + ForEach(remoteImageInfoViewModel.providers, id: \.self) { provider in + Button { + remoteImageInfoViewModel.provider = provider + } label: { + if remoteImageInfoViewModel.provider == provider { + Label(provider, systemImage: "checkmark") + } else { + Text(provider) + } + } + } + } label: { + Text(L10n.provider) + + Text(remoteImageInfoViewModel.provider ?? L10n.all) + } + } + } + .sheet(item: $selectedImage) { + selectedImage = nil + } content: { remoteImageInfo in + ItemImageDetailsView( + viewModel: viewModel, + imageSource: ImageSource(url: remoteImageInfo.url?.url), + width: remoteImageInfo.width, + height: remoteImageInfo.height, + language: remoteImageInfo.language, + provider: remoteImageInfo.providerName, + rating: remoteImageInfo.communityRating, + ratingVotes: remoteImageInfo.voteCount, + onClose: { + selectedImage = nil + }, + onSave: { + viewModel.send(.setImage(remoteImageInfo)) + selectedImage = nil + } + ) + } + .onFirstAppear { + remoteImageInfoViewModel.send(.refresh) + } + .onReceive(viewModel.events) { event in + switch event { + case .updated: + UIDevice.feedback(.success) + router.pop() + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Content Grid View + + @ViewBuilder + private var gridView: some View { + if remoteImageInfoViewModel.elements.isEmpty { + Text(L10n.none) + } else { + CollectionVGrid( + uniqueElements: remoteImageInfoViewModel.elements, + layout: layout + ) { image in + imageButton(image) + } + .onReachedBottomEdge(offset: .offset(300)) { + remoteImageInfoViewModel.send(.getNextPage) + } + } + } + + // MARK: - Poster Image Button + + @ViewBuilder + private func imageButton(_ image: RemoteImageInfo) -> some View { + Button { + selectedImage = image + } label: { + posterImage( + image, + posterStyle: (image.height ?? 0) > (image.width ?? 0) ? .portrait : .landscape + ) + } + } + + // MARK: - Poster Image + + @ViewBuilder + private func posterImage( + _ posterImageInfo: RemoteImageInfo?, + posterStyle: PosterDisplayType + ) -> some View { + ZStack { + Color.secondarySystemFill + .frame(maxWidth: .infinity, maxHeight: .infinity) + + ImageView(posterImageInfo?.url?.url) + .placeholder { source in + if let blurHash = source.blurHash { + BlurHashView(blurHash: blurHash) + .scaledToFit() + } else { + Image(systemName: "photo") + } + } + .failure { + Image(systemName: "photo") + } + .pipeline(.Swiftfin.other) + .foregroundStyle(.secondary) + .font(.headline) + } + .posterStyle(posterStyle) + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift new file mode 100644 index 000000000..f762d4e20 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift @@ -0,0 +1,50 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemImageDetailsView { + + struct DeleteButton: View { + + // MARK: - Delete Action + + let onDelete: () -> Void + + // MARK: - Dialog State + + @State + private var isPresentingConfirmation: Bool = false + + // MARK: - Body + + var body: some View { + ListRowButton(L10n.delete, role: .destructive) { + isPresentingConfirmation = true + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingConfirmation, + titleVisibility: .visible + ) { + Button( + L10n.delete, + role: .destructive, + action: onDelete + ) + + Button(L10n.cancel, role: .cancel) { + isPresentingConfirmation = false + } + } message: { + Text(L10n.deleteItemConfirmationMessage) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift new file mode 100644 index 000000000..7ea72a524 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift @@ -0,0 +1,103 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemImageDetailsView { + + struct DetailsSection: View { + + // MARK: - Image Details Variables + + private let index: Int? + private let language: String? + private let width: Int? + private let height: Int? + private let provider: String? + + // MARK: - Image Ratings Variables + + private let rating: Double? + private let ratingVotes: Int? + + // MARK: - Image Source Variable + + private let url: URL? + + // MARK: - Initializer + + init( + url: URL? = nil, + index: Int? = nil, + language: String? = nil, + width: Int? = nil, + height: Int? = nil, + provider: String? = nil, + rating: Double? = nil, + ratingType: RatingType? = nil, + ratingVotes: Int? = nil + ) { + self.url = url + self.index = index + self.language = language + self.width = width + self.height = height + self.provider = provider + self.rating = rating + self.ratingVotes = ratingVotes + } + + // MARK: - Body + + var body: some View { + Section(L10n.details) { + if let provider { + TextPairView(leading: L10n.provider, trailing: provider) + } + + if let language { + TextPairView(leading: L10n.language, trailing: language) + } + + if let width, let height { + TextPairView( + leading: L10n.dimensions, + trailing: "\(width) x \(height)" + ) + } + + if let index { + TextPairView(leading: L10n.index, trailing: index.description) + } + } + + if let rating { + Section(L10n.ratings) { + TextPairView(leading: L10n.rating, trailing: rating.formatted(.number.precision(.fractionLength(2)))) + + if let ratingVotes { + TextPairView(L10n.votes, value: Text(ratingVotes, format: .number)) + } + } + } + + if let url { + Section { + ChevronButton( + L10n.imageSource, + external: true + ) + .onSelect { + UIApplication.shared.open(url) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift new file mode 100644 index 000000000..6fc1c89fc --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift @@ -0,0 +1,43 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemImageDetailsView { + + struct HeaderSection: View { + + // MARK: - Image Info + + let imageSource: ImageSource + let posterType: PosterDisplayType + + // MARK: - Body + + var body: some View { + Section { + ImageView(imageSource) + .placeholder { _ in + Image(systemName: "photo") + } + .failure { + Image(systemName: "photo") + } + .pipeline(.Swiftfin.other) + } + .scaledToFit() + .frame(maxHeight: 300) + .posterStyle(posterType) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .listRowCornerRadius(0) + .listRowInsets(.zero) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift new file mode 100644 index 000000000..1cabcaf83 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift @@ -0,0 +1,126 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct ItemImageDetailsView: View { + + @Environment(\.isEditing) + private var isEditing + + // MARK: - State, Observed, & Environment Objects + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + @ObservedObject + private var viewModel: ItemImagesViewModel + + // MARK: - Image Variable + + private let imageSource: ImageSource + + // MARK: - Description Variables + + private let index: Int? + private let width: Int? + private let height: Int? + private let language: String? + private let provider: String? + private let rating: Double? + private let ratingVotes: Int? + + // MARK: - Image Actions + + private let onClose: () -> Void + private let onSave: (() -> Void)? + private let onDelete: (() -> Void)? + + // MARK: - Initializer + + init( + viewModel: ItemImagesViewModel, + imageSource: ImageSource, + index: Int? = nil, + width: Int? = nil, + height: Int? = nil, + language: String? = nil, + provider: String? = nil, + rating: Double? = nil, + ratingVotes: Int? = nil, + onClose: @escaping () -> Void, + onSave: (() -> Void)? = nil, + onDelete: (() -> Void)? = nil + ) { + self.viewModel = viewModel + self.imageSource = imageSource + self.index = index + self.width = width + self.height = height + self.language = language + self.provider = provider + self.rating = rating + self.ratingVotes = ratingVotes + self.onClose = onClose + self.onSave = onSave + self.onDelete = onDelete + } + + // MARK: - Body + + var body: some View { + NavigationView { + contentView + .navigationTitle(L10n.image) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + onClose() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + + if let onSave { + Button(L10n.save, action: onSave) + .buttonStyle(.toolbarPill) + } + } + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + List { + HeaderSection( + imageSource: imageSource, + posterType: height ?? 0 > width ?? 0 ? .portrait : .landscape + ) + + DetailsSection( + url: imageSource.url, + index: index, + language: language, + width: width, + height: height, + provider: provider, + rating: rating, + ratingVotes: ratingVotes + ) + + if isEditing, let onDelete { + DeleteButton { + onDelete() + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift new file mode 100644 index 000000000..bbe63e6d0 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift @@ -0,0 +1,222 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct ItemImagesView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Observed & Environment Objects + + @EnvironmentObject + private var router: ItemImagesCoordinator.Router + + @StateObject + var viewModel: ItemImagesViewModel + + // MARK: - Dialog State + + @State + private var selectedImage: ImageInfo? + @State + private var selectedType: ImageType? + @State + private var isFilePickerPresented = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + imageView + case .initial: + DelayedProgressView() + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + } + .navigationTitle(L10n.images) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + viewModel.send(.refresh) + } + .navigationBarCloseButton { + router.dismissCoordinator() + } + .sheet(item: $selectedImage) { + selectedImage = nil + } content: { imageInfo in + ItemImageDetailsView( + viewModel: viewModel, + imageSource: imageInfo.itemImageSource( + itemID: viewModel.item.id!, + client: viewModel.userSession.client + ), + index: imageInfo.imageIndex, + width: imageInfo.width, + height: imageInfo.height, + onClose: { + selectedImage = nil + }, + onDelete: { + viewModel.send(.deleteImage(imageInfo)) + selectedImage = nil + } + ) + .environment(\.isEditing, true) + } + .fileImporter( + isPresented: $isFilePickerPresented, + allowedContentTypes: [.png, .jpeg, .heic], + allowsMultipleSelection: false + ) { + switch $0 { + case let .success(urls): + if let file = urls.first, let type = selectedType { + viewModel.send(.uploadFile(file: file, type: type)) + selectedType = nil + } + case let .failure(fileError): + error = fileError + selectedType = nil + } + } + .onReceive(viewModel.events) { event in + switch event { + case .updated: () + case let .error(eventError): + self.error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Image View + + @ViewBuilder + private var imageView: some View { + ScrollView { + ForEach(ImageType.allCases.sorted(using: \.rawValue), id: \.self) { imageType in + Section { + imageScrollView(for: imageType) + + RowDivider() + .padding(.vertical, 16) + } header: { + sectionHeader(for: imageType) + } + } + } + } + + // MARK: - Image Scroll View + + @ViewBuilder + private func imageScrollView(for imageType: ImageType) -> some View { + let images = viewModel.images[imageType] ?? [] + + if images.isNotEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(images, id: \.self) { imageInfo in + imageButton(imageInfo: imageInfo) { + selectedImage = imageInfo + } + } + } + .edgePadding(.horizontal) + } + } + } + + // MARK: - Section Header + + @ViewBuilder + private func sectionHeader(for imageType: ImageType) -> some View { + HStack { + Text(imageType.displayTitle) + .font(.headline) + + Spacer() + + Menu(L10n.options, systemImage: "plus") { + Button(L10n.search, systemImage: "magnifyingglass") { + router.route( + to: \.addImage, + imageType + ) + } + + Divider() + + Button(L10n.uploadFile, systemImage: "document.badge.plus") { + selectedType = imageType + isFilePickerPresented = true + } + + Button(L10n.uploadPhoto, systemImage: "photo.badge.plus") { + router.route(to: \.photoPicker, imageType) + } + } + .font(.body) + .labelStyle(.iconOnly) + .backport + .fontWeight(.semibold) + .foregroundStyle(accentColor) + } + .edgePadding(.horizontal) + } + + // MARK: - Image Button + + // TODO: instead of using `posterStyle`, should be sized based on + // the image type and just ignore and poster styling + @ViewBuilder + private func imageButton( + imageInfo: ImageInfo, + onSelect: @escaping () -> Void + ) -> some View { + Button(action: onSelect) { + ZStack { + Color.secondarySystemFill + + ImageView( + imageInfo.itemImageSource( + itemID: viewModel.item.id!, + client: viewModel.userSession.client + ) + ) + .placeholder { _ in + Image(systemName: "photo") + } + .failure { + Image(systemName: "photo") + } + .pipeline(.Swiftfin.other) + } + .posterStyle(imageInfo.height ?? 0 > imageInfo.width ?? 0 ? .portrait : .landscape) + .frame(maxHeight: 150) + .posterShadow() + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift new file mode 100644 index 000000000..72716c35b --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift @@ -0,0 +1,59 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import Mantis +import SwiftUI + +struct ItemPhotoCropView: View { + + // MARK: - State, Observed, & Environment Objects + + @EnvironmentObject + private var router: ItemImagePickerCoordinator.Router + + @ObservedObject + var viewModel: ItemImagesViewModel + + // MARK: - Image Variable + + let image: UIImage + let type: ImageType + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + PhotoCropView( + isSaving: viewModel.backgroundStates.contains(.updating), + image: image, + cropShape: .rect, + presetRatio: .canUseMultiplePresetFixedRatio() + ) { + viewModel.send(.uploadImage(image: $0, type: type)) + } onCancel: { + router.dismissCoordinator() + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .interactiveDismissDisabled(viewModel.backgroundStates.contains(.updating)) + .navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating)) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + case .updated: + router.dismissCoordinator() + } + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift new file mode 100644 index 000000000..5fa611fa7 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift @@ -0,0 +1,27 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ItemImagePicker: View { + + // MARK: - Observed, & Environment Objects + + @EnvironmentObject + private var router: ItemImagePickerCoordinator.Router + + // MARK: - Body + + var body: some View { + PhotoPickerView { + router.route(to: \.cropImage, $0) + } onCancel: { + router.dismissCoordinator() + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift new file mode 100644 index 000000000..77cf97067 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift @@ -0,0 +1,141 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct AddItemElementView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Environment & Observed Objects + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + @ObservedObject + var viewModel: ItemEditorViewModel + + // MARK: - Elements Variables + + let type: ItemArrayElements + + @State + private var id: String? + @State + private var name: String = "" + @State + private var personKind: PersonKind = .unknown + @State + private var personRole: String = "" + + // MARK: - Trie Data Loaded + + @State + private var loaded: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Name is Valid + + private var isValid: Bool { + name.isNotEmpty + } + + // MARK: - Name Already Exists + + private var itemAlreadyExists: Bool { + viewModel.trie.contains(key: name.localizedLowercase) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content, .updating: + contentView + case let .error(error): + ErrorView(error: error) + } + } + .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 + } + } + .errorMessage($error) + } + + // MARK: - Content View + + private var contentView: some View { + List { + NameInput( + name: $name, + personKind: $personKind, + personRole: $personRole, + type: type, + itemAlreadyExists: itemAlreadyExists + ) + + SearchResultsSection( + name: $name, + id: $id, + type: type, + population: viewModel.matches, + isSearching: viewModel.backgroundStates.contains(.searching) + ) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift new file mode 100644 index 000000000..b4ab8fc28 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift @@ -0,0 +1,87 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddItemElementView { + + struct NameInput: View { + + // MARK: - Element Variables + + @Binding + var name: String + @Binding + var personKind: PersonKind + @Binding + var personRole: String + + let type: ItemArrayElements + let itemAlreadyExists: Bool + + // MARK: - Body + + var body: some View { + nameView + + if type == .people { + personView + } + } + + // MARK: - Name View + + 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 View + + 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/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift new file mode 100644 index 000000000..267359be0 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.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) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddItemElementView { + + struct SearchResultsSection: View { + + // MARK: - Element Variables + + @Binding + var name: String + @Binding + var id: String? + + // MARK: - Element Search Variables + + let type: ItemArrayElements + let population: [Element] + + // TODO: Why doesn't environment(\.isSearching) work? + let isSearching: Bool + + // MARK: - Body + + 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: - No Results View + + private var noResultsView: some View { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + + // MARK: - Results View + + 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: - Label View + + @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/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift new file mode 100644 index 000000000..f1d5df9b8 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift @@ -0,0 +1,113 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension EditItemElementView { + + struct EditItemElementRow: View { + + // MARK: - Enviroment Variables + + @Environment(\.isEditing) + var isEditing + @Environment(\.isSelected) + var isSelected + + // MARK: - Metadata Variables + + let item: Element + let type: ItemArrayElements + + // MARK: - Row Actions + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Body + + var body: some View { + ListRow { + if type == .people { + personImage + } + } content: { + rowContent + } + .onSelect(perform: onSelect) + .isSeparatorVisible(false) + .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(.horizontal) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift new file mode 100644 index 000000000..ff93e5a0c --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift @@ -0,0 +1,274 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct EditItemElementView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Observed & Environment Objects + + @EnvironmentObject + private var router: ItemEditorCoordinator.Router + + @ObservedObject + var viewModel: ItemEditorViewModel + + // MARK: - Elements + + @State + private var elements: [Element] + + // MARK: - Type & Route + + private let type: ItemArrayElements + private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void + + // MARK: - Dialog States + + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingDeleteSelectionConfirmation = false + + // MARK: - Editing States + + @State + private var selectedElements: Set = [] + @State + private var isEditing: Bool = false + @State + private var isReordering: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // 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 { + ZStack { + switch viewModel.state { + case .initial, .content, .updating: + contentView + case let .error(error): + errorView(with: error) + } + } + .navigationBarTitle(type.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing || isReordering) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + 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 + } + } + } + .onReceive(viewModel.events) { events in + switch events { + case let .error(eventError): + error = eventError + default: + break + } + } + .errorMessage($error) + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedConfirmationActions + } message: { + Text(L10n.deleteSelectedConfirmation) + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteConfirmationActions + } message: { + Text(L10n.deleteItemConfirmation) + } + .onNotification(.itemMetadataDidChange) { _ in + self.elements = type.getElement(for: self.viewModel.item) + } + } + + // MARK: - Select/Remove All Button + + @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: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.load) + } + } + + // MARK: - Content View + + private var contentView: some View { + 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)) + .listRowInsets(.edgeInsets) + } + .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/EditMetadataView/Components/Sections/DateSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/DateSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/DisplayOrderSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/DisplayOrderSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/EpisodeSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/EpisodeSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/LocalizationSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/LocalizationSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/LockMetadataSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/LockMetadataSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/MediaFormatSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/MediaFormatSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/OverviewSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/OverviewSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ParentialRatingsSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ParentialRatingsSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ReviewsSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ReviewsSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/SeriesSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/SeriesSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/TitleSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/TitleSection.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/EditMetadataView.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift similarity index 100% rename from Swiftfin/Views/ItemEditorView/EditMetadataView/EditMetadataView.swift rename to Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift diff --git a/Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift b/Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift new file mode 100644 index 000000000..611245c30 --- /dev/null +++ b/Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift @@ -0,0 +1,161 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Mantis +import SwiftUI + +struct PhotoCropView: View { + + // MARK: - State, Observed, & Environment Objects + + @StateObject + private var proxy: _PhotoCropView.Proxy = .init() + + // MARK: - Image Variable + + let isSaving: Bool + let image: UIImage + let cropShape: Mantis.CropShapeType + let presetRatio: Mantis.PresetFixedRatioType + let onSave: (UIImage) -> Void + let onCancel: () -> Void + + // MARK: - Body + + var body: some View { + _PhotoCropView( + initialImage: image, + cropShape: cropShape, + presetRatio: presetRatio, + proxy: proxy, + onImageCropped: onSave + ) + .topBarTrailing { + + Button(L10n.rotate, systemImage: "rotate.right") { + proxy.rotate() + } + + if isSaving { + Button(L10n.cancel, action: onCancel) + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + proxy.crop() + } + .buttonStyle(.toolbarPill) + } + } + .toolbar { + ToolbarItem(placement: .principal) { + if isSaving { + ProgressView() + } else { + Button(L10n.reset) { + proxy.reset() + } + .foregroundStyle(.yellow) + .disabled(isSaving) + } + } + } + .ignoresSafeArea() + .background { + Color.black + } + } +} + +// MARK: - Photo Crop View + +private struct _PhotoCropView: UIViewControllerRepresentable { + + class Proxy: ObservableObject { + + weak var cropViewController: CropViewController? + + func crop() { + cropViewController?.crop() + } + + func reset() { + cropViewController?.didSelectReset() + } + + func rotate() { + cropViewController?.didSelectClockwiseRotate() + } + } + + let initialImage: UIImage + let cropShape: Mantis.CropShapeType + let presetRatio: Mantis.PresetFixedRatioType + let proxy: Proxy + let onImageCropped: (UIImage) -> Void + + func makeUIViewController(context: Context) -> some UIViewController { + var config = Mantis.Config() + + config.cropViewConfig.backgroundColor = .black.withAlphaComponent(0.9) + config.cropViewConfig.cropShapeType = cropShape + config.presetFixedRatioType = presetRatio + config.showAttachedCropToolbar = false + + let cropViewController = Mantis.cropViewController( + image: initialImage, + config: config + ) + + cropViewController.delegate = context.coordinator + context.coordinator.onImageCropped = onImageCropped + + proxy.cropViewController = cropViewController + + return cropViewController + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: CropViewControllerDelegate { + + var onImageCropped: ((UIImage) -> Void)? + + func cropViewControllerDidCrop( + _ cropViewController: CropViewController, + cropped: UIImage, + transformation: Transformation, + cropInfo: CropInfo + ) { + onImageCropped?(cropped) + } + + func cropViewControllerDidCancel( + _ cropViewController: CropViewController, + original: UIImage + ) {} + + func cropViewControllerDidFailToCrop( + _ cropViewController: CropViewController, + original: UIImage + ) {} + + func cropViewControllerDidBeginResize( + _ cropViewController: CropViewController + ) {} + + func cropViewControllerDidEndResize( + _ cropViewController: Mantis.CropViewController, + original: UIImage, + cropInfo: Mantis.CropInfo + ) {} + } +} diff --git a/Swiftfin/Views/PhotoPickerView/PhotoPickerView.swift b/Swiftfin/Views/PhotoPickerView/PhotoPickerView.swift new file mode 100644 index 000000000..a95e01081 --- /dev/null +++ b/Swiftfin/Views/PhotoPickerView/PhotoPickerView.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) 2025 Jellyfin & Jellyfin Contributors +// + +import PhotosUI +import SwiftUI + +// TODO: polish: find way to deselect image on appear +// - from popping from cropping +// TODO: polish: when image is picked, instead of loading it here +// which takes ~1-2s, show some kind of loading indicator +// on this view or push to another view that will go to crop + +struct PhotoPickerView: UIViewControllerRepresentable { + + // MARK: - Photo Picker Actions + + var onSelect: (UIImage) -> Void + var onCancel: () -> Void + + // MARK: - Initializer + + init(onSelect: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) { + self.onSelect = onSelect + self.onCancel = onCancel + } + + // MARK: - UIView Controller + + func makeUIViewController(context: Context) -> PHPickerViewController { + + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + + configuration.filter = .all(of: [.images, .not(.livePhotos)]) + configuration.preferredAssetRepresentationMode = .current + configuration.selection = .default + configuration.selectionLimit = 1 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + + context.coordinator.onSelect = onSelect + context.coordinator.onCancel = onCancel + + return picker + } + + // MARK: - Update UIView Controller + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + // MARK: - Make Coordinator + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + // MARK: - Coordinator + + class Coordinator: PHPickerViewControllerDelegate { + + var onSelect: ((UIImage) -> Void)? + var onCancel: (() -> Void)? + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + + guard let image = results.first else { + onCancel?() + return + } + + let itemProvider = image.itemProvider + + guard itemProvider.canLoadObject(ofClass: UIImage.self) else { return } + + itemProvider.loadObject(ofClass: UIImage.self) { image, _ in + guard let image = image as? UIImage else { return } + self.onSelect?(image) + } + } + } +} diff --git a/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift b/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift deleted file mode 100644 index 358d162db..000000000 --- a/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift +++ /dev/null @@ -1,90 +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) 2025 Jellyfin & Jellyfin Contributors -// - -import PhotosUI -import SwiftUI - -// TODO: polish: find way to deselect image on appear -// - from popping from cropping -// TODO: polish: when image is picked, instead of loading it here -// which takes ~1-2s, show some kind of loading indicator -// on this view or push to another view that will go to crop - -extension UserProfileImagePicker { - - struct PhotoPicker: UIViewControllerRepresentable { - - // MARK: - Photo Picker Actions - - var onCancel: () -> Void - var onSelectedImage: (UIImage) -> Void - - // MARK: - Initializer - - init(onCancel: @escaping () -> Void, onSelectedImage: @escaping (UIImage) -> Void) { - self.onCancel = onCancel - self.onSelectedImage = onSelectedImage - } - - // MARK: - UIView Controller - - func makeUIViewController(context: Context) -> PHPickerViewController { - - var configuration = PHPickerConfiguration(photoLibrary: .shared()) - - configuration.filter = .all(of: [.images, .not(.livePhotos)]) - configuration.preferredAssetRepresentationMode = .current - configuration.selection = .ordered - configuration.selectionLimit = 1 - - let picker = PHPickerViewController(configuration: configuration) - picker.delegate = context.coordinator - - context.coordinator.onCancel = onCancel - context.coordinator.onSelectedImage = onSelectedImage - - return picker - } - - // MARK: - Update UIView Controller - - func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} - - // MARK: - Make Coordinator - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - // MARK: - Coordinator - - class Coordinator: PHPickerViewControllerDelegate { - - var onCancel: (() -> Void)? - var onSelectedImage: ((UIImage) -> Void)? - - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - - guard let image = results.first else { - onCancel?() - return - } - - let itemProvider = image.itemProvider - - if itemProvider.canLoadObject(ofClass: UIImage.self) { - itemProvider.loadObject(ofClass: UIImage.self) { image, _ in - if let image = image as? UIImage { - self.onSelectedImage?(image) - } - } - } - } - } - } -} diff --git a/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift b/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift deleted file mode 100644 index 3b649b174..000000000 --- a/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift +++ /dev/null @@ -1,198 +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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Mantis -import SwiftUI - -extension UserProfileImagePicker { - - struct SquareImageCropView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - State, Observed, & Environment Objects - - @EnvironmentObject - private var router: UserProfileImageCoordinator.Router - - @StateObject - private var proxy: _SquareImageCropView.Proxy = .init() - - @ObservedObject - var viewModel: UserProfileImageViewModel - - // MARK: - Image Variable - - let image: UIImage - - // MARK: - Error State - - @State - private var error: Error? = nil - - // MARK: - Body - - var body: some View { - _SquareImageCropView(initialImage: image, proxy: proxy) { - viewModel.send(.upload($0)) - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .interactiveDismissDisabled(viewModel.state == .uploading) - .navigationBarBackButtonHidden(viewModel.state == .uploading) - .topBarTrailing { - - if viewModel.state == .initial { - Button(L10n.rotate, systemImage: "rotate.right") { - proxy.rotate() - } - .foregroundStyle(.gray) - } - - if viewModel.state == .uploading { - Button(L10n.cancel) { - viewModel.send(.cancel) - } - .foregroundStyle(.red) - } else { - Button { - proxy.crop() - } label: { - Text(L10n.save) - .foregroundStyle(accentColor.overlayColor) - .font(.headline) - .padding(.vertical, 5) - .padding(.horizontal, 10) - .background { - accentColor - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } - } - .toolbar { - ToolbarItem(placement: .principal) { - if viewModel.state == .uploading { - ProgressView() - } else { - Button(L10n.reset) { - proxy.reset() - } - .foregroundStyle(.yellow) - .disabled(viewModel.state == .uploading) - } - } - } - .ignoresSafeArea() - .background { - Color.black - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - error = eventError - case .deleted: - break - case .uploaded: - router.dismissCoordinator() - } - } - .errorMessage($error) - } - } - - // MARK: - Square Image Crop View - - struct _SquareImageCropView: UIViewControllerRepresentable { - - class Proxy: ObservableObject { - - weak var cropViewController: CropViewController? - - func crop() { - cropViewController?.crop() - } - - func reset() { - cropViewController?.didSelectReset() - } - - func rotate() { - cropViewController?.didSelectClockwiseRotate() - } - } - - let initialImage: UIImage - let proxy: Proxy - let onImageCropped: (UIImage) -> Void - - func makeUIViewController(context: Context) -> some UIViewController { - var config = Mantis.Config() - - config.cropViewConfig.backgroundColor = .black.withAlphaComponent(0.9) - config.cropViewConfig.cropShapeType = .square - config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 1) - config.showAttachedCropToolbar = false - - let cropViewController = Mantis.cropViewController( - image: initialImage, - config: config - ) - - cropViewController.delegate = context.coordinator - context.coordinator.onImageCropped = onImageCropped - - proxy.cropViewController = cropViewController - - return cropViewController - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - class Coordinator: CropViewControllerDelegate { - - var onImageCropped: ((UIImage) -> Void)? - - func cropViewControllerDidCrop( - _ cropViewController: CropViewController, - cropped: UIImage, - transformation: Transformation, - cropInfo: CropInfo - ) { - onImageCropped?(cropped) - } - - func cropViewControllerDidCancel( - _ cropViewController: CropViewController, - original: UIImage - ) {} - - func cropViewControllerDidFailToCrop( - _ cropViewController: CropViewController, - original: UIImage - ) {} - - func cropViewControllerDidBeginResize( - _ cropViewController: CropViewController - ) {} - - func cropViewControllerDidEndResize( - _ cropViewController: Mantis.CropViewController, - original: UIImage, - cropInfo: Mantis.CropInfo - ) {} - } - } -} diff --git a/Swiftfin/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift b/Swiftfin/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift new file mode 100644 index 000000000..6deca814b --- /dev/null +++ b/Swiftfin/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift @@ -0,0 +1,61 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import Mantis +import SwiftUI + +struct UserProfileImageCropView: View { + + // MARK: - State, Observed, & Environment Objects + + @EnvironmentObject + private var router: UserProfileImageCoordinator.Router + + @ObservedObject + var viewModel: UserProfileImageViewModel + + // MARK: - Image Variable + + let image: UIImage + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + PhotoCropView( + isSaving: viewModel.state == .uploading, + image: image, + cropShape: .square, + presetRatio: .alwaysUsingOnePresetFixedRatio(ratio: 1) + ) { + viewModel.send(.upload($0)) + } onCancel: { + router.dismissCoordinator() + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .interactiveDismissDisabled(viewModel.state == .uploading) + .navigationBarBackButtonHidden(viewModel.state == .uploading) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + case .deleted: + break + case .uploaded: + router.dismissCoordinator() + } + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePickerView.swift similarity index 70% rename from Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift rename to Swiftfin/Views/UserProfileImagePicker/UserProfileImagePickerView.swift index 6ebc6875e..4d4860204 100644 --- a/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift +++ b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePickerView.swift @@ -8,23 +8,20 @@ import SwiftUI -struct UserProfileImagePicker: View { +struct UserProfileImagePickerView: View { // MARK: - Observed, & Environment Objects @EnvironmentObject private var router: UserProfileImageCoordinator.Router - @ObservedObject - var viewModel: UserProfileImageViewModel - // MARK: - Body var body: some View { - PhotoPicker { + PhotoPickerView { + router.route(to: \.cropImage, $0) + } onCancel: { router.dismissCoordinator() - } onSelectedImage: { image in - router.route(to: \.cropImage, image) } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 3433af752..e84c92557 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -94,12 +94,18 @@ /// Album Artist "albumArtist" = "Album Artist"; +/// All +"all" = "All"; + /// All Audiences "allAudiences" = "All Audiences"; /// View all past and present devices that have connected. "allDevicesDescription" = "View all past and present devices that have connected."; +/// All languages +"allLanguages" = "All languages"; + /// All Media "allMedia" = "All Media"; @@ -157,6 +163,9 @@ /// Arranger "arranger" = "Arranger"; +/// Art +"art" = "Art"; + /// Artist "artist" = "Artist"; @@ -211,6 +220,12 @@ /// Back "back" = "Back"; +/// Backdrop +"backdrop" = "Backdrop"; + +/// Banner +"banner" = "Banner"; + /// Bar Buttons "barButtons" = "Bar Buttons"; @@ -307,6 +322,12 @@ /// Books "books" = "Books"; +/// Box +"box" = "Box"; + +/// BoxRear +"boxRear" = "BoxRear"; + /// Bugs and Features "bugsAndFeatures" = "Bugs and Features"; @@ -337,6 +358,9 @@ /// Channels "channels" = "Channels"; +/// Chapter +"chapter" = "Chapter"; + /// Chapters "chapters" = "Chapters"; @@ -562,6 +586,9 @@ /// Are you sure you wish to delete this device? This session will be logged out. "deleteDeviceWarning" = "Are you sure you wish to delete this device? This session will be logged out."; +/// Delete image +"deleteImage" = "Delete image"; + /// Are you sure you want to delete this item? "deleteItemConfirmation" = "Are you sure you want to delete this item?"; @@ -652,6 +679,9 @@ /// Digital "digital" = "Digital"; +/// Dimensions +"dimensions" = "Dimensions"; + /// Direct Play "direct" = "Direct Play"; @@ -673,6 +703,9 @@ /// Disabled "disabled" = "Disabled"; +/// Disc +"disc" = "Disc"; + /// Disclaimer "disclaimer" = "Disclaimer"; @@ -880,6 +913,18 @@ /// Illustrator "illustrator" = "Illustrator"; +/// Images +"image" = "Images"; + +/// Images +"images" = "Images"; + +/// Image source +"imageSource" = "Image source"; + +/// Index +"index" = "Index"; + /// Indicators "indicators" = "Indicators"; @@ -979,6 +1024,9 @@ /// Liked Items "likedItems" = "Liked Items"; +/// Likes +"likes" = "Likes"; + /// List "list" = "List"; @@ -1012,6 +1060,9 @@ /// Locked users "lockedUsers" = "Locked users"; +/// Logo +"logo" = "Logo"; + /// Logs "logs" = "Logs"; @@ -1072,6 +1123,9 @@ /// Mbps "megabitsPerSecond" = "Mbps"; +/// Menu +"menu" = "Menu"; + /// Menu Buttons "menuButtons" = "Menu Buttons"; @@ -1321,6 +1375,9 @@ /// Production Year "productionYear" = "Production Year"; +/// Profile +"profile" = "Profile"; + /// Profile Image "profileImage" = "Profile Image"; @@ -1525,6 +1582,12 @@ /// Schedule already exists "scheduleAlreadyExists" = "Schedule already exists"; +/// Score +"score" = "Score"; + +/// Screenshot +"screenshot" = "Screenshot"; + /// Scrub Current Time "scrubCurrentTime" = "Scrub Current Time"; @@ -1789,6 +1852,9 @@ /// Test Size "testSize" = "Test Size"; +/// Thumb +"thumb" = "Thumb"; + /// Time "time" = "Time"; @@ -1891,6 +1957,12 @@ /// You have unsaved changes. Are you sure you want to discard them? "unsavedChangesMessage" = "You have unsaved changes. Are you sure you want to discard them?"; +/// Upload file +"uploadFile" = "Upload file"; + +/// Upload photo +"uploadPhoto" = "Upload photo"; + /// URL "url" = "URL"; @@ -1969,6 +2041,9 @@ /// Some views may need an app restart to update. "viewsMayRequireRestart" = "Some views may need an app restart to update."; +/// Votes +"votes" = "Votes"; + /// Weekday "weekday" = "Weekday";