diff --git a/Shared/Coordinators/ItemEditorCoordinator.swift b/Shared/Coordinators/ItemEditorCoordinator.swift index d97201df3..e649148d6 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 imageEditor = makeImageEditor + // MARK: - Route to Genres @Route(.push) @@ -73,6 +78,12 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable { } } + // MARK: - Item Images + + func makeImageEditor(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..d63e54558 --- /dev/null +++ b/Shared/Coordinators/ItemImagePickerCoordinator.swift @@ -0,0 +1,53 @@ +// +// 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 + + @ObservedObject + var 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..5adad0b97 --- /dev/null +++ b/Shared/Coordinators/ItemImagesCoordinator.swift @@ -0,0 +1,62 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 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 + + // Okay for now since `ObservedObject` is on `MainActor` + @ObservedObject + private var 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 = ObservedObject(wrappedValue: 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..17bbd2c9c --- /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 { + "circle" + } + + public var id: Int { + hashValue + } +} diff --git a/Shared/Extensions/JellyfinAPI/Request.swift b/Shared/Extensions/JellyfinAPI/Request.swift new file mode 100644 index 000000000..4f8585eac --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/Request.swift @@ -0,0 +1,66 @@ +// +// 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 Get + +public extension Request { + /// Size of the request HTTP method in bytes + var methodSize: Int { + method.rawValue.count + } + + /// Size of the request URL in bytes + var urlSize: Int { + url?.absoluteString.count ?? 0 + } + + /// Size of the request query parameters in bytes + var querySize: Int { + guard let query = query else { return 0 } + return query.reduce(0) { $0 + $1.0.count + ($1.1?.count ?? 0) + 2 } + } + + /// Size of the request headers in bytes + var headersSize: Int { + guard let headers = headers else { return 0 } + return headers.reduce(0) { $0 + $1.key.count + $1.value.count + 4 } + } + + /// Size of the request body in bytes + var bodySize: Int { + var size = 0 + if let body = body { + do { + let bodyData = try JSONEncoder().encode(AnyEncodable(body)) + size += bodyData.count + } catch { + size += 0 + } + } + return size + } + + /// Total size of the total request in bytes + var requestSize: Int { + methodSize + urlSize + querySize + headersSize + bodySize + } +} + +/// A type-erased `Encodable` to encode any value conforming to `Encodable` +private struct AnyEncodable: Encodable { + private let _encode: (Encoder) throws -> Void + + init(_ value: T) { + _encode = value.encode + } + + func encode(to encoder: Encoder) throws { + try _encode(encoder) + } +} diff --git a/Shared/Extensions/Hashable.swift b/Shared/Extensions/RatingType.swift similarity index 53% rename from Shared/Extensions/Hashable.swift rename to Shared/Extensions/RatingType.swift index 49bbb6a6d..d3cd23eae 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: @retroactive 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..de7008477 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -120,6 +120,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 +158,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 +228,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 +252,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 +410,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 +478,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 +494,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 +642,12 @@ 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 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 +720,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 +744,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 +786,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 +956,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 +1098,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 +1286,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 +1358,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 +1416,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..129f7f8dc --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/ItemImagesViewModel.swift @@ -0,0 +1,417 @@ +// +// 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 IdentifiedCollections +import JellyfinAPI +import OrderedCollections +import SwiftUI + +class ItemImagesViewModel: ViewModel, Stateful, Eventful { + + enum Event: Equatable { + case updated + case deleted + case error(JellyfinAPIError) + } + + enum Action: Equatable { + case cancel + case refresh + case setImage(RemoteImageInfo) + case uploadPhoto(image: UIImage, type: ImageType) + case uploadImage(file: URL, type: ImageType) + case deleteImage(ImageInfo) + } + + enum BackgroundState: Hashable { + case refreshing + } + + enum State: Hashable { + case initial + case content + case updating + case deleting + case error(JellyfinAPIError) + } + + // MARK: - Published Variables + + @Published + var item: BaseItemDto + @Published + var images: IdentifiedArray = [] + + // 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() + self.state = .initial + + return state + + case .refresh: + task?.cancel() + + task = Task { [weak self] in + guard let self else { return } + do { + await MainActor.run { + self.state = .initial + _ = self.backgroundStates.append(.refreshing) + self.images.removeAll() + } + + try await self.getAllImages() + + await MainActor.run { + self.state = .content + _ = self.backgroundStates.remove(.refreshing) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .error(apiError) + self.eventSubject.send(.error(apiError)) + self.backgroundStates.remove(.refreshing) + } + } + }.asAnyCancellable() + + return state + + case let .setImage(remoteImageInfo): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + _ = self.state = .updating + } + + try await self.setImage(remoteImageInfo) + try await self.getAllImages() + + await MainActor.run { + self.state = .updating + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .content + self.eventSubject.send(.error(apiError)) + } + } + }.asAnyCancellable() + + return state + + case let .uploadPhoto(image, type): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + self.state = .updating + } + + try await self.uploadPhoto(image, type: type) + try await self.getAllImages() + + await MainActor.run { + self.state = .content + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .content + self.eventSubject.send(.error(apiError)) + } + } + }.asAnyCancellable() + + return state + + case let .uploadImage(url, type): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + self.state = .updating + } + + try await self.uploadImage(url, type: type) + try await self.getAllImages() + + await MainActor.run { + self.state = .content + self.eventSubject.send(.updated) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .content + self.eventSubject.send(.error(apiError)) + } + } + }.asAnyCancellable() + + return state + + case let .deleteImage(imageInfo): + task?.cancel() + + task = Task { [weak self] in + guard let self = self else { return } + do { + await MainActor.run { + self.state = .deleting + } + + try await deleteImage(imageInfo) + try await refreshItem() + + await MainActor.run { + self.state = .deleting + self.eventSubject.send(.deleted) + } + } catch { + let apiError = JellyfinAPIError(error.localizedDescription) + await MainActor.run { + self.state = .content + self.eventSubject.send(.error(apiError)) + } + } + }.asAnyCancellable() + + return state + } + } + + // 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) + + await MainActor.run { + let updatedImages = response.value.sorted { + if $0.imageType == $1.imageType { + return $0.imageIndex ?? 0 < $1.imageIndex ?? 0 + } + return $0.imageType?.rawValue ?? "" < $1.imageType?.rawValue ?? "" + } + + self.images = IdentifiedArray(uniqueElements: updatedImages) + } + } + + // 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 uploadImage(_ url: URL, type: ImageType) async throws { + guard url.startAccessingSecurityScopedResource() else { + throw JellyfinAPIError("Unable to access file at \(url)") + } + 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 { + throw JellyfinAPIError("Unable to load image from file") + } + + if let pngData = image.pngData() { + contentType = "image/png" + imageData = pngData + } else if let jpgData = image.jpegData(compressionQuality: 1) { + contentType = "image/jpeg" + imageData = jpgData + } else { + throw JellyfinAPIError("Failed to convert image to png/jpg") + } + } + + 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?.rawValue else { return } + + if let imageIndex = imageInfo.imageIndex { + let request = Paths.deleteItemImageByIndex( + itemID: itemID, + imageType: imageType, + imageIndex: imageIndex + ) + + try await userSession.client.send(request) + } else { + let request = Paths.deleteItemImage( + itemID: itemID, + imageType: imageType + ) + + try await userSession.client.send(request) + } + + await MainActor.run { + /// Remove the ImageInfo from local storage + self.images.removeAll { $0 == imageInfo } + + /// Ensure that the remaining items have the correct new indexes + if imageInfo.imageIndex != nil { + self.images = IdentifiedArray( + uniqueElements: self.images.map { image in + var updatedImage = image + if updatedImage.imageType == imageInfo.imageType, + let imageIndex = updatedImage.imageIndex, + let targetIndex = imageInfo.imageIndex, + imageIndex > targetIndex + { + updatedImage.imageIndex = imageIndex - 1 + } + return updatedImage + } + ) + } + } + } + + // MARK: - Refresh Item + + private func refreshItem() async throws { + guard let itemID = item.id else { return } + + await MainActor.run { + _ = backgroundStates.append(.refreshing) + } + + let request = Paths.getItem( + userID: userSession.user.id, + itemID: itemID + ) + + let response = try await userSession.client.send(request) + + await MainActor.run { + self.item = response.value + _ = backgroundStates.remove(.refreshing) + Notifications[.itemMetadataDidChange].post(item) + } + } +} diff --git a/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift b/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift new file mode 100644 index 000000000..a119b24f9 --- /dev/null +++ b/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift @@ -0,0 +1,63 @@ +// +// 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 IdentifiedCollections +import JellyfinAPI +import OrderedCollections +import UIKit + +private let DefaultPageSize = 50 + +class RemoteImageInfoViewModel: PagingLibraryViewModel { + + @Published + var item: BaseItemDto + @Published + var imageType: ImageType + + let providerName: String? + let includeAllLanguages: Bool + + init( + item: BaseItemDto, + imageType: ImageType, + providerName: String? = nil, + includeAllLanguages: Bool = false, + pageSize: Int = DefaultPageSize + ) { + self.item = item + self.imageType = imageType + self.providerName = providerName + self.includeAllLanguages = includeAllLanguages + super.init(parent: nil, pageSize: pageSize) + } + + override func get(page: Int) async throws -> [RemoteImageInfo] { + guard let itemID = item.id else { return [] } + + let startIndex = page * pageSize + var parameters = Paths.GetRemoteImagesParameters( + type: imageType, + startIndex: startIndex, + limit: pageSize, + providerName: providerName, + isIncludeAllLanguages: includeAllLanguages + ) + + if let providerName = providerName { + parameters.providerName = providerName + } + + let request = Paths.getRemoteImages(itemID: itemID, parameters: parameters) + let response = try await userSession.client.send(request) + + 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..d0e087273 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,15 @@ 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 */; }; + 4EA78B0F2D29B0880093BFCE /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B0E2D29B0820093BFCE /* Request.swift */; }; + 4EA78B102D29B0880093BFCE /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B0E2D29B0820093BFCE /* Request.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 +229,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 +539,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 +826,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 +1219,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 +1227,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 +1254,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 +1339,13 @@ 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 = ""; }; + 4EA78B0E2D29B0820093BFCE /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.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 +1387,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 +1785,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 +2307,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 +2340,7 @@ 4E49DEDE2CE55F7F00352DCD /* Components */ = { isa = PBXGroup; children = ( - 4E49DEDC2CE55F7F00352DCD /* PhotoPicker.swift */, - 4E49DEDD2CE55F7F00352DCD /* SquareImageCropView.swift */, + 4EECA4EC2D2C89D20080A863 /* UserProfileImageCropView.swift */, ); path = Components; sourceTree = ""; @@ -2307,7 +2349,7 @@ isa = PBXGroup; children = ( 4E49DEDE2CE55F7F00352DCD /* Components */, - 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */, + 4E49DEE52CE5616800352DCD /* UserProfileImagePickerView.swift */, ); path = UserProfileImagePicker; sourceTree = ""; @@ -2531,10 +2573,10 @@ isa = PBXGroup; children = ( 4E8F74A62CE03D4C00CC8969 /* Components */, - 4E6619FF2CEFE39000025C99 /* EditMetadataView */, + 4E37F6182D17EB220022AADD /* ItemImages */, + 4E37F6192D17EB3C0022AADD /* ItemMetadata */, 4EE766F32D131F6E009658F0 /* IdentifyItemView */, 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */, - 4E3766192D2144BA00C5D7A5 /* ItemElements */, ); path = ItemEditorView; sourceTree = ""; @@ -2552,8 +2594,10 @@ children = ( 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */, 4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */, + 4E45939D2D04E1E600E277E1 /* ItemImagesViewModel.swift */, 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */, 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */, + 4E37F6152D17C1710022AADD /* RemoteImageInfoViewModel.swift */, ); path = ItemAdministration; sourceTree = ""; @@ -2648,6 +2692,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 +2908,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 +3494,6 @@ E133328729538D8D00EE76AB /* Files.swift */, E11CEB8C28999B4A003E74C7 /* Font.swift */, E10432F52BE4426F006FF9DD /* FormatStyle.swift */, - E1803EA02BFBD6CF0039F90E /* Hashable.swift */, E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, E139CC1E28EC83E400688DE2 /* Int.swift */, E1AD105226D96D5F003E4A08 /* JellyfinAPI */, @@ -3406,6 +3502,7 @@ E1A505692D0B733F007EE305 /* Optional.swift */, E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, + 4EECA4F42D2CAA350080A863 /* RatingType.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */, E145EB442BE0AD4E003BF6F3 /* Set.swift */, 621338922660107500A81A2A /* String.swift */, @@ -3450,6 +3547,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 +4052,7 @@ E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, E103DF922BCF2F23000229B2 /* MediaView */, E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, + 4EECA4E12D2C7D450080A863 /* PhotoPickerView */, E10231342BCF8A3C009D71FC /* ProgramsView */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, 4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */, @@ -4100,8 +4200,8 @@ children = ( E1763A282BF3046A004DF6AB /* AddUserButton.swift */, E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, - E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */, + E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, ); path = Components; sourceTree = ""; @@ -4490,6 +4590,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,7 +4606,9 @@ 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, + 4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */, 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */, + 4EA78B0E2D29B0820093BFCE /* Request.swift */, 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */, @@ -5312,11 +5416,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 +5465,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 +5482,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 +5510,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 +5545,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 +5568,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 +5595,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 +5631,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 +5672,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 */, @@ -5602,6 +5700,7 @@ E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */, E154967C296CBB1A00C4EF88 /* FontPickerView.swift in Sources */, E15D63F02BD6DFC200AA665D /* SystemImageable.swift in Sources */, + 4EA78B102D29B0880093BFCE /* Request.swift in Sources */, E1EA096A2BED78F5004CDE76 /* UserAccessPolicy.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */, @@ -5643,6 +5742,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 +5770,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 +5856,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 +5936,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 +5948,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 */, @@ -5859,6 +5962,7 @@ E113132B28BDB4B500930F75 /* NavigationBarDrawerView.swift in Sources */, E164A7F62BE4814700A54B18 /* SelectUserServerSelection.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerConnectionViewModel.swift in Sources */, + 4EA78B0F2D29B0880093BFCE /* Request.swift in Sources */, E1EA09882BEE9CF3004CDE76 /* UserLocalSecurityView.swift in Sources */, E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */, @@ -5878,6 +5982,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 +5994,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 +6021,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 +6029,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 +6048,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 +6082,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 +6092,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 +6147,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 +6231,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 +6240,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 +6287,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 +6340,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 +6360,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 +6381,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 +6410,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/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/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..f72b8e9c8 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: \.imageEditor, 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..eca61aaa6 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift @@ -0,0 +1,179 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +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( + item: viewModel.item, + imageType: imageType + )) + } + + // 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) + } + } + } + .navigationTitle(remoteImageInfoViewModel.imageType.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(viewModel.state == .updating) + .topBarTrailing { + if viewModel.backgroundStates.contains(.refreshing) { + ProgressView() + } + } + .sheet(item: $selectedImage) { + selectedImage = nil + } content: { remoteImageInfo in + ItemImageDetailsView( + viewModel: viewModel, + imageSource: ImageSource(url: URL(string: remoteImageInfo.url)), + width: remoteImageInfo.width, + height: remoteImageInfo.height, + language: remoteImageInfo.language, + provider: remoteImageInfo.providerName, + rating: remoteImageInfo.communityRating, + ratingType: remoteImageInfo.ratingType, + ratingVotes: remoteImageInfo.voteCount, + onClose: { + selectedImage = nil + }, + onSave: { + viewModel.send(.setImage(remoteImageInfo)) + selectedImage = nil + } + ) + .navigationTitle(remoteImageInfo.type?.displayTitle ?? "") + .environment(\.isEditing, true) + } + .onFirstAppear { + remoteImageInfoViewModel.send(.refresh) + } + .onReceive(viewModel.events) { event in + switch event { + case .deleted: + break + 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 { + if let image { + 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(URL(string: posterImageInfo?.url ?? "")) + .placeholder { source in + if let blurHash = source.blurHash { + BlurHashView(blurHash: blurHash, size: .Square(length: 8)) + .scaledToFit() + } else { + Image(systemName: "circle") + } + } + .failure { + Image(systemName: "questionmark") + } + .foregroundColor(.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..ef769b0df --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift @@ -0,0 +1,58 @@ +// +// 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 ItemImageDetailsView { + + struct DeleteButton: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Delete Action + + let onDelete: () -> Void + + // MARK: - Dialog State + + @State + private var isPresentingConfirmation: Bool = false + + // MARK: - Body + + @ViewBuilder + var body: some View { + ListRowButton(L10n.delete) { + isPresentingConfirmation = true + } + .foregroundStyle(.red, .red.opacity(0.2)) + .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..5dffe280d --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift @@ -0,0 +1,110 @@ +// +// 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 + + let index: Int? + let language: String? + let width: Int? + let height: Int? + let provider: String? + + // MARK: - Image Ratings Variables + + let rating: Double? + let ratingType: RatingType? + let ratingVotes: Int? + + // MARK: - Image Source Variable + + 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.ratingType = ratingType + self.ratingVotes = ratingVotes + } + + // MARK: - Body + + @ViewBuilder + 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 ratingType { + TextPairView(leading: L10n.type, trailing: ratingType.displayTitle) + } + + if let ratingVotes { + TextPairView(leading: L10n.votes, trailing: ratingVotes.description) + } + } + } + + 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..73b92db4b --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.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 Defaults +import JellyfinAPI +import SwiftUI + +extension ItemImageDetailsView { + + struct HeaderSection: View { + + // MARK: - Image Info + + let imageSource: ImageSource + let posterType: PosterDisplayType + + // MARK: - Body + + @ViewBuilder + var body: some View { + Section { + ImageView(imageSource) + .placeholder { _ in + Image(systemName: "circle") + } + .failure { + Image(systemName: "circle") + } + } + .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..d9d65d001 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift @@ -0,0 +1,135 @@ +// +// 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 Combine +import Defaults +import Factory +import JellyfinAPI +import Nuke +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 ratingType: RatingType? + 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, + ratingType: RatingType? = nil, + ratingVotes: Int? = nil, + onClose: @escaping () -> Void, + onSave: (() -> Void)? = nil, + onDelete: (() -> Void)? = nil + ) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + self.imageSource = imageSource + self.index = index + self.width = width + self.height = height + self.language = language + self.provider = provider + self.rating = rating + self.ratingType = ratingType + self.ratingVotes = ratingVotes + self.onClose = onClose + self.onSave = onSave + self.onDelete = onDelete + } + + // MARK: - Body + + var body: some View { + NavigationView { + contentView + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + onClose() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.refreshing) { + 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, + ratingType: ratingType, + 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..0818f441a --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift @@ -0,0 +1,232 @@ +// +// 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 Combine +import Defaults +import Factory +import JellyfinAPI +import SwiftUI + +struct ItemImagesView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - User Session + + @Injected(\.currentUserSession) + private var userSession + + // 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, .deleting, .updating: + 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 + } + ) + .navigationTitle(imageInfo.imageType?.displayTitle ?? "") + .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(.uploadImage(file: file, type: type)) + selectedType = nil + } + case let .failure(fileError): + error = fileError + selectedType = nil + } + } + .onReceive(viewModel.events) { event in + switch event { + case .updated, .deleted: () + 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) + + Divider() + .padding(.vertical, 16) + } header: { + sectionHeader(for: imageType) + } + } + } + } + + // MARK: - Image Scroll View + + @ViewBuilder + private func imageScrollView(for imageType: ImageType) -> some View { + let imageArray = viewModel.images.filter { $0.imageType == imageType } + + if imageArray.isNotEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(imageArray, id: \.self) { imageInfo in + imageButton(imageInfo: imageInfo) { + selectedImage = imageInfo + } + } + } + } + } + } + + // MARK: - Section Header + + @ViewBuilder + private func sectionHeader(for imageType: ImageType) -> some View { + HStack(alignment: .center, spacing: 16) { + Text(imageType.rawValue.localizedCapitalized) + .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) + } + .padding(.horizontal, 30) + } + + // MARK: - Image Button + + @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: userSession!.client + ) + ) + .placeholder { _ in + Image(systemName: "circle") + } + .failure { + Image(systemName: "questionmark") + } + .scaledToFit() + .frame(maxWidth: .infinity) + } + .scaledToFit() + .posterStyle(imageInfo.height ?? 0 > imageInfo.width ?? 0 ? .portrait : .landscape) + .frame(maxHeight: 150) + .shadow(radius: 4) + .padding(16) + } + } +} 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..e7c4e05d6 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift @@ -0,0 +1,62 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +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.state == .updating, + image: image, + cropShape: .rect, + presetRatio: .canUseMultiplePresetFixedRatio() + ) { + viewModel.send(.uploadPhoto(image: $0, type: type)) + } onCancel: { + router.dismissCoordinator() + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .interactiveDismissDisabled(viewModel.state == .updating) + .navigationBarBackButtonHidden(viewModel.state == .updating) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + case .deleted: + break + 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..aa1ac5fa1 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift @@ -0,0 +1,28 @@ +// +// 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 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..bcbff1ae0 --- /dev/null +++ b/Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift @@ -0,0 +1,162 @@ +// +// 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 + +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..63d469db7 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -157,6 +157,9 @@ /// Arranger "arranger" = "Arranger"; +/// Art +"art" = "Art"; + /// Artist "artist" = "Artist"; @@ -211,6 +214,12 @@ /// Back "back" = "Back"; +/// Backdrop +"backdrop" = "Backdrop"; + +/// Banner +"banner" = "Banner"; + /// Bar Buttons "barButtons" = "Bar Buttons"; @@ -307,6 +316,12 @@ /// Books "books" = "Books"; +/// Box +"box" = "Box"; + +/// BoxRear +"boxRear" = "BoxRear"; + /// Bugs and Features "bugsAndFeatures" = "Bugs and Features"; @@ -337,6 +352,9 @@ /// Channels "channels" = "Channels"; +/// Chapter +"chapter" = "Chapter"; + /// Chapters "chapters" = "Chapters"; @@ -562,6 +580,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 +673,9 @@ /// Digital "digital" = "Digital"; +/// Dimensions +"dimensions" = "Dimensions"; + /// Direct Play "direct" = "Direct Play"; @@ -673,6 +697,9 @@ /// Disabled "disabled" = "Disabled"; +/// Disc +"disc" = "Disc"; + /// Disclaimer "disclaimer" = "Disclaimer"; @@ -880,6 +907,15 @@ /// Illustrator "illustrator" = "Illustrator"; +/// Images +"images" = "Images"; + +/// Image source +"imageSource" = "Image source"; + +/// Index +"index" = "Index"; + /// Indicators "indicators" = "Indicators"; @@ -979,6 +1015,9 @@ /// Liked Items "likedItems" = "Liked Items"; +/// Likes +"likes" = "Likes"; + /// List "list" = "List"; @@ -1012,6 +1051,9 @@ /// Locked users "lockedUsers" = "Locked users"; +/// Logo +"logo" = "Logo"; + /// Logs "logs" = "Logs"; @@ -1072,6 +1114,9 @@ /// Mbps "megabitsPerSecond" = "Mbps"; +/// Menu +"menu" = "Menu"; + /// Menu Buttons "menuButtons" = "Menu Buttons"; @@ -1321,6 +1366,9 @@ /// Production Year "productionYear" = "Production Year"; +/// Profile +"profile" = "Profile"; + /// Profile Image "profileImage" = "Profile Image"; @@ -1525,6 +1573,12 @@ /// Schedule already exists "scheduleAlreadyExists" = "Schedule already exists"; +/// Score +"score" = "Score"; + +/// Screenshot +"screenshot" = "Screenshot"; + /// Scrub Current Time "scrubCurrentTime" = "Scrub Current Time"; @@ -1789,6 +1843,9 @@ /// Test Size "testSize" = "Test Size"; +/// Thumb +"thumb" = "Thumb"; + /// Time "time" = "Time"; @@ -1891,6 +1948,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 +2032,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";