From b2a31dbc3a9e36183735351f3b784d36ce1be0cf Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 21 May 2024 22:45:48 -0600 Subject: [PATCH] User Profile Image Selection (#1061) --- RedrawOnNotificationView.swift | 36 ++++ Shared/Components/ImageView.swift | 43 +--- .../MainCoordinator/iOSMainCoordinator.swift | 2 - .../MainCoordinator/tvOSMainCoordinator.swift | 4 - Shared/Coordinators/SettingsCoordinator.swift | 6 + .../UserProfileImageCoordinator.swift | 40 ++++ Shared/Extensions/Hashable.swift | 16 ++ Shared/Extensions/JellyfinAPI/UserDto.swift | 6 +- Shared/Extensions/Nuke/DataCache.swift | 71 +++++++ Shared/Extensions/Nuke/ImagePipeline.swift | 29 +++ Shared/Extensions/UIApplication.swift | 6 +- Shared/Extensions/URL.swift | 5 + Shared/Services/LogManager.swift | 38 ++-- Shared/Services/SwiftfinDefaults.swift | 2 +- Shared/Services/SwiftfinNotifications.swift | 2 + Shared/Services/UserSession.swift | 4 +- .../SwiftinStore+UserState.swift | 10 +- .../ViewModels/ConnectToServerViewModel.swift | 10 +- Shared/ViewModels/SettingsViewModel.swift | 24 +++ .../UserProfileImageViewModel.swift | 109 ++++++++++ Swiftfin tvOS/App/SwiftfinApp.swift | 23 ++- Swiftfin.xcodeproj/project.pbxproj | 141 ++++++++++--- .../xcshareddata/swiftpm/Package.resolved | 25 ++- Swiftfin/App/SwiftfinApp.swift | 23 ++- Swiftfin/Components/SettingsBarButton.swift | 36 ++-- Swiftfin/Views/ConnectToServerView.swift | 2 +- .../Components/AddUserButton.swift | 2 +- .../Components/AddUserRow.swift | 2 +- .../Components/UserGridButton.swift | 3 +- .../SelectUserView/Components/UserRow.swift | 3 +- .../Views/SelectUserView/SelectUserView.swift | 1 + .../Components/UserProfileRow.swift | 21 +- .../Components/PhotoPicker.swift | 78 +++++++ .../Components/SquareImageCropView.swift | 193 ++++++++++++++++++ .../UserProfileImagePicker.swift | 23 +++ .../UserProfileSettingsView.swift | 62 ++++-- .../Components/PublicUserRow.swift | 1 + 37 files changed, 948 insertions(+), 154 deletions(-) create mode 100644 RedrawOnNotificationView.swift create mode 100644 Shared/Coordinators/UserProfileImageCoordinator.swift create mode 100644 Shared/Extensions/Hashable.swift create mode 100644 Shared/Extensions/Nuke/DataCache.swift create mode 100644 Shared/Extensions/Nuke/ImagePipeline.swift create mode 100644 Shared/ViewModels/UserProfileImageViewModel.swift create mode 100644 Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift create mode 100644 Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift create mode 100644 Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift diff --git a/RedrawOnNotificationView.swift b/RedrawOnNotificationView.swift new file mode 100644 index 00000000..0bcd8814 --- /dev/null +++ b/RedrawOnNotificationView.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) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct RedrawOnNotificationView: View { + + @State + private var id = 0 + + private let name: NSNotification.Name + private let content: () -> Content + + init(name: NSNotification.Name, @ViewBuilder content: @escaping () -> Content) { + self.name = name + self.content = content + } + + init(_ swiftfinNotification: Notifications.Key, @ViewBuilder content: @escaping () -> Content) { + self.name = swiftfinNotification.underlyingNotification.name + self.content = content + } + + var body: some View { + content() + .id(id) + .onNotification(name) { _ in + id += 1 + } + } +} diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index 67d9c600..cdf7665d 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -11,18 +11,6 @@ import Nuke import NukeUI import SwiftUI -private let imagePipeline = { - - ImageDecoderRegistry.shared.register { context in - guard let mimeType = context.urlResponse?.mimeType else { return nil } - return mimeType.contains("svg") ? ImageDecoders.Empty() : nil - } - - return ImagePipeline(configuration: .withDataCache) -}() - -// TODO: Binding inits? -// - instead of removing first source on failure, just safe index into sources // TODO: currently SVGs are only supported for logos, which are only used in a few places. // make it so when displaying an SVG there is a unified `image` caller modifier // TODO: `LazyImage` uses a transaction for view swapping, which will fade out old views @@ -37,6 +25,7 @@ struct ImageView: View { private var sources: [ImageSource] private var image: (Image) -> any View + private var pipeline: ImagePipeline private var placeholder: ((ImageSource) -> any View)? private var failure: () -> any View @@ -70,7 +59,7 @@ struct ImageView: View { } } } - .pipeline(imagePipeline) + .pipeline(pipeline) } else { failure() .eraseToAnyView() @@ -81,43 +70,29 @@ struct ImageView: View { extension ImageView { init(_ source: ImageSource) { - self.init( - sources: [source].compacted(using: \.url), - image: { $0 }, - placeholder: nil, - failure: { EmptyView() } - ) + self.init([source].compacted(using: \.url)) } init(_ sources: [ImageSource]) { self.init( sources: sources.compacted(using: \.url), image: { $0 }, + pipeline: .shared, placeholder: nil, failure: { EmptyView() } ) } init(_ source: URL?) { - self.init( - sources: [ImageSource(url: source)], - image: { $0 }, - placeholder: nil, - failure: { EmptyView() } - ) + self.init([ImageSource(url: source)]) } init(_ sources: [URL?]) { let imageSources = sources - .compactMap { $0 } + .compacted() .map { ImageSource(url: $0) } - self.init( - sources: imageSources, - image: { $0 }, - placeholder: nil, - failure: { EmptyView() } - ) + self.init(imageSources) } } @@ -129,6 +104,10 @@ extension ImageView { copy(modifying: \.image, with: content) } + func pipeline(_ pipeline: ImagePipeline) -> Self { + copy(modifying: \.pipeline, with: pipeline) + } + func placeholder(@ViewBuilder _ content: @escaping (ImageSource) -> any View) -> Self { copy(modifying: \.placeholder, with: content) } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index f968fb19..e5b1a2da 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -74,8 +74,6 @@ final class MainCoordinator: NavigationCoordinatable { // TODO: move these to the App instead? - ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB - // Notification setup for state Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 63c29115..46595fef 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -60,10 +60,6 @@ final class MainCoordinator: NavigationCoordinatable { } } - ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory - - UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] - // Notification setup for state Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 0168dd9b..fd0d85f0 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -28,6 +28,8 @@ final class SettingsCoordinator: NavigationCoordinatable { var resetUserPassword = makeResetUserPassword @Route(.push) var localSecurity = makeLocalSecurity + @Route(.modal) + var photoPicker = makePhotoPicker @Route(.push) var userProfile = makeUserProfileSettings @@ -86,6 +88,10 @@ final class SettingsCoordinator: NavigationCoordinatable { UserLocalSecurityView() } + func makePhotoPicker(viewModel: SettingsViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(UserProfileImageCoordinator()) + } + @ViewBuilder func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View { UserProfileSettingsView(viewModel: viewModel) diff --git a/Shared/Coordinators/UserProfileImageCoordinator.swift b/Shared/Coordinators/UserProfileImageCoordinator.swift new file mode 100644 index 00000000..5542d587 --- /dev/null +++ b/Shared/Coordinators/UserProfileImageCoordinator.swift @@ -0,0 +1,40 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Stinsen +import SwiftUI + +final class UserProfileImageCoordinator: NavigationCoordinatable { + + let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start) + + @Root + var start = makeStart + + @Route(.push) + var cropImage = makeCropImage + + func makeCropImage(image: UIImage) -> some View { + #if os(iOS) + UserProfileImagePicker.SquareImageCropView( + image: image + ) + #else + AssertionFailureView("not implemented") + #endif + } + + @ViewBuilder + func makeStart() -> some View { + #if os(iOS) + UserProfileImagePicker() + #else + AssertionFailureView("not implemented") + #endif + } +} diff --git a/Shared/Extensions/Hashable.swift b/Shared/Extensions/Hashable.swift new file mode 100644 index 00000000..13970de4 --- /dev/null +++ b/Shared/Extensions/Hashable.swift @@ -0,0 +1,16 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +extension Hashable { + + var hashString: String { + "\(hashValue)" + } +} diff --git a/Shared/Extensions/JellyfinAPI/UserDto.swift b/Shared/Extensions/JellyfinAPI/UserDto.swift index 3e8ba6dc..59de3902 100644 --- a/Shared/Extensions/JellyfinAPI/UserDto.swift +++ b/Shared/Extensions/JellyfinAPI/UserDto.swift @@ -13,8 +13,7 @@ extension UserDto { func profileImageSource( client: JellyfinClient, - maxWidth: CGFloat? = nil, - maxHeight: CGFloat? = nil + maxWidth: CGFloat? = nil ) -> ImageSource { UserState( id: id ?? "", @@ -23,8 +22,7 @@ extension UserDto { ) .profileImageSource( client: client, - maxWidth: maxWidth, - maxHeight: maxHeight + maxWidth: maxWidth ) } } diff --git a/Shared/Extensions/Nuke/DataCache.swift b/Shared/Extensions/Nuke/DataCache.swift new file mode 100644 index 00000000..143b2282 --- /dev/null +++ b/Shared/Extensions/Nuke/DataCache.swift @@ -0,0 +1,71 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CoreStore +import Foundation +import Nuke + +// TODO: when `Storage` is implemented, could allow limits on sizes + +// Note: For better support with multi-url servers, ignore the +// host and only use path + query which has ids and tags + +extension DataCache { + enum Swiftfin {} +} + +extension DataCache.Swiftfin { + + static let `default`: DataCache? = { + let dataCache = try? DataCache(name: "org.jellyfin.swiftfin") { name in + URL(string: name)?.pathAndQuery() ?? name + } + + dataCache?.sizeLimit = 1024 * 1024 * 500 // 500 MB + + return dataCache + }() + + /// The `DataCache` used for images that should have longer lifetimes, usable without a + /// connection, and not affected by other caching size limits. + /// + /// Current 150 MB is more than necessary. + static let branding: DataCache? = { + guard let root = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + + let path = root.appendingPathComponent("Cache/org.jellyfin.swiftfin.branding", isDirectory: true) + + let dataCache = try? DataCache(path: path) { name in + + // this adds some latency, but fine since + // this DataCache is special + if name.range(of: "Splashscreen") != nil { + + // TODO: potential issue where url ends with `/`, if + // not found, retry with `/` appended + let prefix = name.trimmingSuffix("/Branding/Splashscreen?") + + // can assume that we are only requesting a server with + // the key same as the current url + guard let prefixURL = URL(string: prefix) else { return name } + guard let server = try? SwiftfinStore.dataStack.fetchOne( + From() + .where(\.$currentURL == prefixURL) + ) else { return name } + + return "\(server.id)-splashscreen" + } else { + return URL(string: name)?.pathAndQuery() ?? name + } + } + + return dataCache + }() +} diff --git a/Shared/Extensions/Nuke/ImagePipeline.swift b/Shared/Extensions/Nuke/ImagePipeline.swift new file mode 100644 index 00000000..83606804 --- /dev/null +++ b/Shared/Extensions/Nuke/ImagePipeline.swift @@ -0,0 +1,29 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import Nuke + +extension ImagePipeline { + enum Swiftfin {} +} + +extension ImagePipeline.Swiftfin { + + /// The default `ImagePipeline` to use for images that should be used + /// during normal usage with an active connection. + static let `default`: ImagePipeline = ImagePipeline { + $0.dataCache = DataCache.Swiftfin.default + } + + /// The `ImagePipeline` used for images that should have longer lifetimes and usable + /// without a connection, like user profile images and server splashscreens. + static let branding: ImagePipeline = ImagePipeline { + $0.dataCache = DataCache.Swiftfin.branding + } +} diff --git a/Shared/Extensions/UIApplication.swift b/Shared/Extensions/UIApplication.swift index 738eecf7..fd83c130 100644 --- a/Shared/Extensions/UIApplication.swift +++ b/Shared/Extensions/UIApplication.swift @@ -34,6 +34,10 @@ extension UIApplication { } func setAppearance(_ newAppearance: UIUserInterfaceStyle) { - keyWindow?.overrideUserInterfaceStyle = newAppearance + guard let keyWindow else { return } + + UIView.transition(with: keyWindow, duration: 0.2, options: .transitionCrossDissolve) { + keyWindow.overrideUserInterfaceStyle = newAppearance + } } } diff --git a/Shared/Extensions/URL.swift b/Shared/Extensions/URL.swift index 594a71e4..379beb49 100644 --- a/Shared/Extensions/URL.swift +++ b/Shared/Extensions/URL.swift @@ -59,6 +59,11 @@ extension URL { } } + // doesn't have `?` but doesn't matter + func pathAndQuery() -> String? { + path + (query ?? "") + } + var sizeOnDisk: Int { do { guard let size = try directoryTotalAllocatedSize(includingSubfolders: true) else { return -1 } diff --git a/Shared/Services/LogManager.swift b/Shared/Services/LogManager.swift index 7324e058..e2d85a6b 100644 --- a/Shared/Services/LogManager.swift +++ b/Shared/Services/LogManager.swift @@ -17,26 +17,34 @@ import Pulse enum LogManager { static let service = Factory(scope: .singleton) { - .init(label: "Swiftfin") + Logger(label: "org.jellyfin.swiftfin") } + // TODO: make rules for logging sessions and redacting + static let pulseNetworkLogger = Factory(scope: .singleton) { var configuration = NetworkLogger.Configuration() - configuration.willHandleEvent = { event -> LoggerStore.Event? in - switch event { - case let .networkTaskCreated(networkTask): - if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil { - return nil - } - case let .networkTaskCompleted(networkTask): - if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil { - return nil - } - default: () - } - return event - } + // TODO: this used to be necessary to stop the mass of image requests + // clogging the logs, however don't seem necessary anymore? + // Find out how to get images to be logged and have an option to + // turn it on, via SuperUser. + +// configuration.willHandleEvent = { event -> LoggerStore.Event? in +// switch event { +// case let .networkTaskCreated(networkTask): +// if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil { +// return nil +// } +// case let .networkTaskCompleted(networkTask): +// if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil { +// return nil +// } +// default: () +// } +// +// return event +// } return NetworkLogger(configuration: configuration) } diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift index 304cd4af..9bab59db 100644 --- a/Shared/Services/SwiftfinDefaults.swift +++ b/Shared/Services/SwiftfinDefaults.swift @@ -90,7 +90,7 @@ extension Defaults.Keys { static let selectUserUseSplashscreen: Key = AppKey("selectUserUseSplashscreen", default: true) static let signOutOnBackground: Key = AppKey("signOutOnBackground", default: true) - static let signOutOnClose: Key = AppKey("signOutOnClose", default: true) + static let signOutOnClose: Key = AppKey("signOutOnClose", default: false) } // MARK: User diff --git a/Shared/Services/SwiftfinNotifications.swift b/Shared/Services/SwiftfinNotifications.swift index 8de2e338..8ea4837b 100644 --- a/Shared/Services/SwiftfinNotifications.swift +++ b/Shared/Services/SwiftfinNotifications.swift @@ -82,4 +82,6 @@ extension Notifications.Key { static let didConnectToServer = NotificationKey("didConnectToServer") static let didDeleteServer = NotificationKey("didDeleteServer") + + static let didChangeUserProfileImage = NotificationKey("didChangeUserProfileImage") } diff --git a/Shared/Services/UserSession.swift b/Shared/Services/UserSession.swift index 7d97eaa3..c8477f49 100644 --- a/Shared/Services/UserSession.swift +++ b/Shared/Services/UserSession.swift @@ -41,12 +41,12 @@ final class UserSession { fileprivate extension Container.Scope { - static let userSessionScope = Cached() +// static let userSessionScope = . } extension UserSession { - static let current = Factory(scope: .userSessionScope) { + static let current = Factory(scope: .cached) { if let lastUserID = Defaults[.lastSignedInUserID], let user = try? SwiftfinStore.dataStack.fetchOne( diff --git a/Shared/SwiftfinStore/SwiftinStore+UserState.swift b/Shared/SwiftfinStore/SwiftinStore+UserState.swift index f4b18227..c9e0c2c6 100644 --- a/Shared/SwiftfinStore/SwiftinStore+UserState.swift +++ b/Shared/SwiftfinStore/SwiftinStore+UserState.swift @@ -140,15 +140,17 @@ extension UserState { return response.value } + // we will always crop to a square, so just use width func profileImageSource( client: JellyfinClient, - maxWidth: CGFloat? = nil, - maxHeight: CGFloat? = nil + maxWidth: CGFloat? = nil ) -> ImageSource { let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) - let scaleHeight = maxHeight == nil ? nil : UIScreen.main.scale(maxHeight!) - let parameters = Paths.GetUserImageParameters(maxWidth: scaleWidth, maxHeight: scaleHeight) + let parameters = Paths.GetUserImageParameters( + tag: data.primaryImageTag, + maxWidth: scaleWidth + ) let request = Paths.getUserImage( userID: id, imageType: "Primary", diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 9f232bd9..dd6639f6 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -182,13 +182,9 @@ final class ConnectToServerViewModel: ViewModel, Eventful, Stateful { if url.scheme != response.scheme || url.host != response.host { - var newURL = response.absoluteString.trimmingSuffix(Paths.getPublicSystemInfo.url?.absoluteString ?? "") - - // if ended in a "/" - if url.absoluteString.last == "/" { - newURL.append("/") - } - + let newURL = response.absoluteString.trimmingSuffix( + Paths.getPublicSystemInfo.url?.absoluteString ?? "" + ) return URL(string: newURL) ?? url } diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 3a808baf..0423d4c2 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -11,8 +11,13 @@ import Defaults import Factory import Files import Foundation +import JellyfinAPI import UIKit +// TODO: should probably break out into a `Settings` and `AppSettings` view models +// - don't need delete user profile image from app settings +// - could clean up all settings view models + final class SettingsViewModel: ViewModel { @Published @@ -57,6 +62,25 @@ final class SettingsViewModel: ViewModel { } } + func deleteCurrentUserProfileImage() { + Task { + let request = Paths.deleteUserImage( + userID: userSession.user.id, + imageType: "Primary" + ) + let _ = try await userSession.client.send(request) + + let currentUserRequest = Paths.getCurrentUser + let response = try await userSession.client.send(currentUserRequest) + + await MainActor.run { + userSession.user.data = response.value + + Notifications[.didChangeUserProfileImage].post() + } + } + } + func select(icon: any AppIcon) { let previousAppIcon = currentAppIcon currentAppIcon = icon diff --git a/Shared/ViewModels/UserProfileImageViewModel.swift b/Shared/ViewModels/UserProfileImageViewModel.swift new file mode 100644 index 00000000..5e3cdf50 --- /dev/null +++ b/Shared/ViewModels/UserProfileImageViewModel.swift @@ -0,0 +1,109 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI +import Nuke +import UIKit + +class UserProfileImageViewModel: ViewModel, Eventful, Stateful { + + enum Action: Equatable { + case cancel + case upload(UIImage) + } + + enum Event: Hashable { + case error(JellyfinAPIError) + case uploaded + } + + enum State: Hashable { + case initial + case uploading + } + + @Published + var state: State = .initial + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var eventSubject: PassthroughSubject = .init() + private var uploadCancellable: AnyCancellable? + + func respond(to action: Action) -> State { + switch action { + case .cancel: + uploadCancellable?.cancel() + + return .initial + case let .upload(image): + + uploadCancellable = Task { + do { + try await upload(image: image) + + await MainActor.run { + self.eventSubject.send(.uploaded) + self.state = .initial + } + } catch is CancellationError { + // cancel doesn't matter + } catch { + await MainActor.run { + self.eventSubject.send(.error(.init(error.localizedDescription))) + self.state = .initial + } + } + } + .asAnyCancellable() + + return .uploading + } + } + + private func upload(image: UIImage) async throws { + + let contentType: String + let imageData: Data + + if let pngData = image.pngData()?.base64EncodedData() { + contentType = "image/png" + imageData = pngData + } else if let jpgData = image.jpegData(compressionQuality: 1)?.base64EncodedData() { + contentType = "image/jpeg" + imageData = jpgData + } else { + logger.error("Unable to convert given profile image to png/jpg") + throw JellyfinAPIError("An internal error occurred") + } + + var request = Paths.postUserImage( + userID: userSession.user.id, + imageType: "Primary", + imageData + ) + request.headers = ["Content-Type": contentType] + + let _ = try await userSession.client.send(request) + + let currentUserRequest = Paths.getCurrentUser + let response = try await userSession.client.send(currentUserRequest) + + await MainActor.run { + userSession.user.data = response.value + + Notifications[.didChangeUserProfileImage].post() + } + } +} diff --git a/Swiftfin tvOS/App/SwiftfinApp.swift b/Swiftfin tvOS/App/SwiftfinApp.swift index bb20c6f7..321b27b6 100644 --- a/Swiftfin tvOS/App/SwiftfinApp.swift +++ b/Swiftfin tvOS/App/SwiftfinApp.swift @@ -10,6 +10,7 @@ import CoreStore import Defaults import Factory import Logging +import Nuke import Pulse import PulseLogHandler import SwiftUI @@ -19,6 +20,11 @@ struct SwiftfinApp: App { init() { + // CoreStore + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + // Logging LoggingSystem.bootstrap { label in @@ -31,8 +37,21 @@ struct SwiftfinApp: App { return MultiplexLogHandler(loggers) } - CoreStoreDefaults.dataStack = SwiftfinStore.dataStack - CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + // Nuke + + ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB + ImageCache.shared.ttl = 300 // 5 min + + ImageDecoderRegistry.shared.register { context in + guard let mimeType = context.urlResponse?.mimeType else { return nil } + return mimeType.contains("svg") ? ImageDecoders.Empty() : nil + } + + ImagePipeline.shared = .Swiftfin.default + + // UIKit + + UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] } var body: some Scene { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index b86b74b2..4a8cda5f 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -277,11 +277,15 @@ E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */; }; 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 */; }; E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */; }; E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */; }; + E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E0E8B2BF7E76F007676DD /* DataCache.swift */; }; + E11E0E8D2BF7E76F007676DD /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E0E8B2BF7E76F007676DD /* DataCache.swift */; }; E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D842902933F87500D1041A /* ItemFields.swift */; }; E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */; }; E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */; }; @@ -388,12 +392,24 @@ E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; }; E14E9DF22BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; }; + E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA15D2BF6F72900DE757A /* PhotoPicker.swift */; }; + E14EA1602BF6FF8900DE757A /* UserProfileImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA15F2BF6FF8900DE757A /* UserProfileImagePicker.swift */; }; + E14EA1652BF70A8E00DE757A /* Mantis in Frameworks */ = {isa = PBXBuildFile; productRef = E14EA1642BF70A8E00DE757A /* Mantis */; }; + E14EA1672BF70F9C00DE757A /* SquareImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA1662BF70F9C00DE757A /* SquareImageCropView.swift */; }; + E14EA1692BF7330A00DE757A /* UserProfileImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */; }; + E14EA16A2BF7333B00DE757A /* UserProfileImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */; }; E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; }; E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; }; E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; }; E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; }; E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; }; E14EDECD2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; }; + E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0B92BFD44F500944FFA /* ImagePipeline.swift */; }; + E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0B92BFD44F500944FFA /* ImagePipeline.swift */; }; + E150C0BD2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */; }; + E150C0BE2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */; }; + E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E150C0C02BFD62FD00944FFA /* JellyfinAPI */; }; + E150C0C32BFD6DA200944FFA /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E150C0C22BFD6DA200944FFA /* JellyfinAPI */; }; E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E15210552946DF1B00375CC2 /* PulseLogHandler */; }; E15210582946DF1B00375CC2 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E15210572946DF1B00375CC2 /* PulseUI */; }; E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; @@ -531,8 +547,6 @@ E1763A642BF3C9AA004DF6AB /* ListRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */; }; E1763A662BF3CA83004DF6AB /* FullScreenMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A652BF3CA83004DF6AB /* FullScreenMenu.swift */; }; E1763A6A2BF3D177004DF6AB /* PublicUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A692BF3D177004DF6AB /* PublicUserRow.swift */; }; - E1763A6D2BF3DE17004DF6AB /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */; }; - E1763A6F2BF3DE23004DF6AB /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */; }; E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; }; E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; }; E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */; }; @@ -555,6 +569,8 @@ 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 */; }; E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; }; E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E18443CA2A037773002DDDC8 /* UDPBroadcast */; }; E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */; }; @@ -1110,10 +1126,12 @@ E11BDF762B8513B40045C54A /* ItemGenre.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemGenre.swift; sourceTree = ""; }; E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedCaseIterable.swift; sourceTree = ""; }; E11BDF962B865F550045C54A /* ItemTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTag.swift; sourceTree = ""; }; + E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageCoordinator.swift; sourceTree = ""; }; E11CEB8A28998552003E74C7 /* View-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View-iOS.swift"; sourceTree = ""; }; E11CEB8C28999B4A003E74C7 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = ""; }; E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemContentView.swift; sourceTree = ""; }; + E11E0E8B2BF7E76F007676DD /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; E122A9122788EAAD0060FA63 /* MediaStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStream.swift; sourceTree = ""; }; E12376AD2A33D680001F5B44 /* AboutView+Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AboutView+Card.swift"; sourceTree = ""; }; E12376AF2A33D6AE001F5B44 /* AboutViewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewCard.swift; sourceTree = ""; }; @@ -1182,9 +1200,15 @@ E149CCAC2BE6ECC8008B9331 /* Storable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storable.swift; sourceTree = ""; }; E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLetter.swift; sourceTree = ""; }; + E14EA15D2BF6F72900DE757A /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; + E14EA15F2BF6FF8900DE757A /* UserProfileImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImagePicker.swift; sourceTree = ""; }; + E14EA1662BF70F9C00DE757A /* SquareImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquareImageCropView.swift; sourceTree = ""; }; + E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageViewModel.swift; sourceTree = ""; }; E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyItemFilter.swift; sourceTree = ""; }; E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterType.swift; sourceTree = ""; }; E14EDECB2B8FB709000F00A4 /* ItemYear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemYear.swift; sourceTree = ""; }; + E150C0B92BFD44F500944FFA /* ImagePipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipeline.swift; sourceTree = ""; }; + E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnNotificationView.swift; sourceTree = SOURCE_ROOT; }; E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedLightAppIcon.swift; sourceTree = ""; }; E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; @@ -1263,6 +1287,7 @@ 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 = ""; }; @@ -1527,7 +1552,7 @@ 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, E132D3CF2BD217AA0058A2DF /* CollectionVGrid in Frameworks */, E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */, - E1763A6F2BF3DE23004DF6AB /* JellyfinAPI in Frameworks */, + E150C0C32BFD6DA200944FFA /* JellyfinAPI in Frameworks */, E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */, 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */, E13AF3B628A0C598009093AB /* Nuke in Frameworks */, @@ -1547,7 +1572,6 @@ E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */, 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */, - E1763A6D2BF3DE17004DF6AB /* JellyfinAPI in Frameworks */, E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, @@ -1557,6 +1581,7 @@ E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */, E10706102942F57D00646DAF /* Pulse in Frameworks */, E192608328D2D0DB002314B4 /* Factory in Frameworks */, + E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */, E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */, E1523F822B132C350062821A /* CollectionHStack in Frameworks */, E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */, @@ -1564,6 +1589,7 @@ 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, 62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */, E1575E3C293C6B15001665B1 /* Files in Frameworks */, + E14EA1652BF70A8E00DE757A /* Mantis in Frameworks */, 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */, 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */, @@ -1643,6 +1669,7 @@ E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */, + E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, BD0BA2292AD6501300306A8D /* VideoPlayerManager */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, @@ -2054,10 +2081,12 @@ E133328729538D8D00EE76AB /* Files.swift */, E11CEB8C28999B4A003E74C7 /* Font.swift */, E10432F52BE4426F006FF9DD /* FormatStyle.swift */, + E1803EA02BFBD6CF0039F90E /* Hashable.swift */, E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, E139CC1E28EC83E400688DE2 /* Int.swift */, E1AD105226D96D5F003E4A08 /* JellyfinAPI */, E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */, + E150C0B82BFD44E900944FFA /* Nuke */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */, E145EB442BE0AD4E003BF6F3 /* Set.swift */, @@ -2105,8 +2134,9 @@ E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */, E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */, 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */, - 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */, E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */, + 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */, + E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */, E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */, E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */, @@ -2616,6 +2646,24 @@ path = StoredValue; sourceTree = ""; }; + E14EA1612BF6FF8D00DE757A /* UserProfileImagePicker */ = { + isa = PBXGroup; + children = ( + E14EA1622BF7008A00DE757A /* Components */, + E14EA15F2BF6FF8900DE757A /* UserProfileImagePicker.swift */, + ); + path = UserProfileImagePicker; + sourceTree = ""; + }; + E14EA1622BF7008A00DE757A /* Components */ = { + isa = PBXGroup; + children = ( + E14EA15D2BF6F72900DE757A /* PhotoPicker.swift */, + E14EA1662BF70F9C00DE757A /* SquareImageCropView.swift */, + ); + path = Components; + sourceTree = ""; + }; E14EDECA2B8FB66F000F00A4 /* ItemFilter */ = { isa = PBXGroup; children = ( @@ -2643,12 +2691,22 @@ path = ItemView; sourceTree = ""; }; + E150C0B82BFD44E900944FFA /* Nuke */ = { + isa = PBXGroup; + children = ( + E11E0E8B2BF7E76F007676DD /* DataCache.swift */, + E150C0B92BFD44F500944FFA /* ImagePipeline.swift */, + ); + path = Nuke; + sourceTree = ""; + }; E1545BD62BDC559500D9578F /* UserProfileSettingsView */ = { isa = PBXGroup; children = ( 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */, E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, + E14EA1612BF6FF8D00DE757A /* UserProfileImagePicker */, E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, ); path = UserProfileSettingsView; @@ -3114,6 +3172,7 @@ E1D37F472B9C648E00343D2B /* MaxHeightText.swift */, E1DC983F296DEBA500982F06 /* PosterIndicators */, E1FE69A628C29B720021BC93 /* ProgressBar.swift */, + E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */, E187A60129AB28F0008387E6 /* RotateContentView.swift */, E18E01FF288749200022598C /* RowDivider.swift */, E1E1643D28BB074000323B0A /* SelectorView.swift */, @@ -3487,7 +3546,7 @@ E1153DD12BBB649C00424D36 /* SVGKit */, E132D3CE2BD217AA0058A2DF /* CollectionVGrid */, E19D41B12BF2BFA50082B8B2 /* KeychainSwift */, - E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */, + E150C0C22BFD6DA200944FFA /* JellyfinAPI */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -3542,7 +3601,8 @@ E132D3C72BD200C10058A2DF /* CollectionVGrid */, E132D3CC2BD2179C0058A2DF /* CollectionVGrid */, E145EB4A2BE16849003BF6F3 /* KeychainSwift */, - E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */, + E14EA1642BF70A8E00DE757A /* Mantis */, + E150C0C02BFD62FD00944FFA /* JellyfinAPI */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -3614,7 +3674,8 @@ E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */, E132D3CB2BD2179C0058A2DF /* XCRemoteSwiftPackageReference "CollectionVGrid" */, E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */, - E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, + E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */, + E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -3834,6 +3895,7 @@ C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */, + E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */, E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */, @@ -3854,6 +3916,7 @@ E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */, E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */, + E14EA16A2BF7333B00DE757A /* UserProfileImageViewModel.swift in Sources */, E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */, E12376B12A33DB33001F5B44 /* MediaSourceInfoCoordinator.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, @@ -3866,6 +3929,7 @@ E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */, E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */, + E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */, E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, E1575E93293E7B1E001665B1 /* Double.swift in Sources */, E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */, @@ -3893,6 +3957,8 @@ 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, + E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */, + E11E0E8D2BF7E76F007676DD /* DataCache.swift in Sources */, E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */, E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, @@ -3934,6 +4000,7 @@ E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */, E19D41B32BF2BFEF0082B8B2 /* URLSessionConfiguration.swift in Sources */, E10B1ECE2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */, + E150C0BE2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */, E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, @@ -4137,6 +4204,7 @@ E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, + E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E11895AF2893840F0042947B /* NavigationBarOffsetView.swift in Sources */, @@ -4161,6 +4229,7 @@ E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */, E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, + E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, E18E01FA288747580022598C /* AboutAppView.swift in Sources */, @@ -4257,6 +4326,7 @@ 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, + E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, @@ -4354,6 +4424,7 @@ E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, + E14EA1602BF6FF8900DE757A /* UserProfileImagePicker.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, @@ -4392,11 +4463,14 @@ E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E12376AE2A33D680001F5B44 /* AboutView+Card.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */, + E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */, E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */, BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */, + E14EA1672BF70F9C00DE757A /* SquareImageCropView.swift in Sources */, E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */, E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, + E14EA1692BF7330A00DE757A /* UserProfileImageViewModel.swift in Sources */, E18ACA952A15A3E100BB4F35 /* (null) in Sources */, E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */, E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, @@ -4414,6 +4488,7 @@ E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */, E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, + E150C0BD2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */, E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, @@ -4429,6 +4504,7 @@ E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */, E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, + E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */, E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, E1D8429329340B8300D1041A /* Utilities.swift in Sources */, E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */, @@ -5031,6 +5107,22 @@ minimumVersion = 24.0.0; }; }; + E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/guoyingtao/Mantis"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; + E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.3.0; + }; + }; E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Pulse"; @@ -5047,14 +5139,6 @@ minimumVersion = 4.0.0; }; }; - E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.3.4; - }; - }; E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LePips/VLCUI"; @@ -5263,6 +5347,21 @@ package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */; productName = KeychainSwift; }; + E14EA1642BF70A8E00DE757A /* Mantis */ = { + isa = XCSwiftPackageProductDependency; + package = E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */; + productName = Mantis; + }; + E150C0C02BFD62FD00944FFA /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + E150C0C22BFD6DA200944FFA /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; E15210532946DF1B00375CC2 /* Pulse */ = { isa = XCSwiftPackageProductDependency; package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; @@ -5310,16 +5409,6 @@ package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = SwiftUIIntrospect; }; - E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */ = { - isa = XCSwiftPackageProductDependency; - package = E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; - }; - E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */ = { - isa = XCSwiftPackageProductDependency; - package = E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; - }; E18443CA2A037773002DDDC8 /* UDPBroadcast */ = { isa = XCSwiftPackageProductDependency; package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ac76b012..0ae77f3d 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "68a42015b2d42a14d418b6e13427443de55c970d5b3764bbc969e1b3f8c3a78b", + "originHash" : "323b2ad9aaa9c000faf264d68272f0e9fab1349d9f910a0b95ee6aea10460f31", "pins" : [ { "identity" : "blurhashkit", @@ -34,7 +34,7 @@ "location" : "https://github.com/LePips/CollectionVGrid", "state" : { "branch" : "main", - "revision" : "7204e5f717ea571efb4600ecb71c2412e0dec921" + "revision" : "b50b5241df5fc1d71e5a09f6a87731c67c2a79e5" } }, { @@ -109,13 +109,22 @@ "version" : "24.0.0" } }, + { + "identity" : "mantis", + "kind" : "remoteSourceControl", + "location" : "https://github.com/guoyingtao/Mantis", + "state" : { + "revision" : "ccc498ea429774d948a0a8aacacde207f7ffff48", + "version" : "2.21.0" + } + }, { "identity" : "nuke", "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke", "state" : { - "revision" : "8e431251dea0081b6ab154dab61a6ec74e4b6577", - "version" : "12.6.0" + "revision" : "7395c7a9dcd390bbcfad17a731d8d529602702c6", + "version" : "12.7.0" } }, { @@ -123,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Pulse", "state" : { - "revision" : "4f34c4f91cda623a7627e6d5e35dbbbb514b8daa", - "version" : "4.1.1" + "revision" : "d1e39ffaaa8b8becff80cb193c93a78e32077af8", + "version" : "4.2.0" } }, { @@ -195,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect", "state" : { - "revision" : "7dc5b287f8040e4ad5038739850b758e78f77808", - "version" : "1.1.4" + "revision" : "0cd2a5a5895306bc21d54a2254302d24a9a571e4", + "version" : "1.1.3" } }, { diff --git a/Swiftfin/App/SwiftfinApp.swift b/Swiftfin/App/SwiftfinApp.swift index dd01fb68..d8f73eff 100644 --- a/Swiftfin/App/SwiftfinApp.swift +++ b/Swiftfin/App/SwiftfinApp.swift @@ -10,6 +10,7 @@ import CoreStore import Defaults import Factory import Logging +import Nuke import PreferencesView import Pulse import PulseLogHandler @@ -26,6 +27,11 @@ struct SwiftfinApp: App { init() { + // CoreStore + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + // Logging LoggingSystem.bootstrap { label in @@ -38,14 +44,27 @@ struct SwiftfinApp: App { return MultiplexLogHandler(loggers) } - CoreStoreDefaults.dataStack = SwiftfinStore.dataStack - CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + // Nuke + + ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB + ImageCache.shared.ttl = 300 // 5 min + + ImageDecoderRegistry.shared.register { context in + guard let mimeType = context.urlResponse?.mimeType else { return nil } + return mimeType.contains("svg") ? ImageDecoders.Empty() : nil + } + + ImagePipeline.shared = .Swiftfin.default + + // UIKit UIScrollView.appearance().keyboardDismissMode = .onDrag // Sometimes the tab bar won't appear properly on push, always have material background UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified) + // Swiftfin + // don't keep last user id if Defaults[.signOutOnClose] { Defaults[.lastSignedInUserID] = nil diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift index 2f976a2e..08f1aef6 100644 --- a/Swiftfin/Components/SettingsBarButton.swift +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -31,23 +31,29 @@ struct SettingsBarButton: View { ZStack { Color.clear - ImageView(user.profileImageSource( - client: server.client, - maxWidth: 120 - )) - .image { image in - image - .clipShape(.circle) - .aspectRatio(1, contentMode: .fit) - .posterBorder(ratio: 1 / 2, of: \.width) - .onAppear { - isUserImage = true - } - } - .placeholder { _ in - Color.clear + RedrawOnNotificationView(.didChangeUserProfileImage) { + ImageView(user.profileImageSource( + client: server.client, + maxWidth: 120 + )) + .pipeline(.Swiftfin.branding) + .image { image in + image + .posterBorder(ratio: 1 / 2, of: \.width) + .onAppear { + isUserImage = true + } + } + .placeholder { _ in + Color.clear + } + .onDisappear { + isUserImage = false + } } } + .aspectRatio(contentMode: .fill) + .clipShape(.circle) } } .accessibilityLabel(L10n.settings) diff --git a/Swiftfin/Views/ConnectToServerView.swift b/Swiftfin/Views/ConnectToServerView.swift index b917c74f..8d464c60 100644 --- a/Swiftfin/Views/ConnectToServerView.swift +++ b/Swiftfin/Views/ConnectToServerView.swift @@ -175,7 +175,7 @@ struct ConnectToServerView: View { router.popLast() } } message: { server in - Text("\(server.name) is already connected.") + L10n.serverAlreadyExistsPrompt(server.name).text } } } diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift b/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift index fadb1b81..b1b097ae 100644 --- a/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift +++ b/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift @@ -79,7 +79,7 @@ extension SelectUserView { if serverSelection == .all { Menu { - Text("Select server") + Text("Select Server") ForEach(servers) { server in Button { diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift b/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift index 5a847283..f739386a 100644 --- a/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift @@ -84,7 +84,7 @@ extension SelectUserView { if serverSelection == .all { Menu { - Text("Select server") + Text("Select Server") ForEach(servers) { server in Button { diff --git a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift index c4c0e744..b71801e9 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift @@ -77,6 +77,7 @@ extension SelectUserView { Color.clear ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) + .pipeline(.Swiftfin.branding) .image { image in image .posterBorder(ratio: 1 / 2, of: \.width) @@ -88,7 +89,7 @@ extension SelectUserView { personView } } - .aspectRatio(1, contentMode: .fill) + .aspectRatio(contentMode: .fill) .clipShape(.circle) .overlay { if isEditing { diff --git a/Swiftfin/Views/SelectUserView/Components/UserRow.swift b/Swiftfin/Views/SelectUserView/Components/UserRow.swift index d13e3f69..616fbc2c 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserRow.swift @@ -77,6 +77,7 @@ extension SelectUserView { Color.clear ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) + .pipeline(.Swiftfin.branding) .image { image in image .posterBorder(ratio: 1 / 2, of: \.width) @@ -93,7 +94,7 @@ extension SelectUserView { .opacity(isSelected ? 0 : 0.5) } } - .aspectRatio(1, contentMode: .fill) + .aspectRatio(contentMode: .fill) .clipShape(.circle) } diff --git a/Swiftfin/Views/SelectUserView/SelectUserView.swift b/Swiftfin/Views/SelectUserView/SelectUserView.swift index 8d150ab5..57bf6293 100644 --- a/Swiftfin/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin/Views/SelectUserView/SelectUserView.swift @@ -438,6 +438,7 @@ struct SelectUserView: View { Color.clear ImageView(splashScreenImageSource) + .pipeline(.Swiftfin.branding) .aspectRatio(contentMode: .fill) .id(splashScreenImageSource) .transition(.opacity) diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index 0fdfd11b..b6c8b536 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -20,13 +20,16 @@ extension SettingsView { @ViewBuilder private var imageView: some View { - ImageView(userSession.user.profileImageSource(client: userSession.client, maxWidth: 120, maxHeight: 120)) - .placeholder { _ in - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .failure { - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } + RedrawOnNotificationView(.didChangeUserProfileImage) { + ImageView(userSession.user.profileImageSource(client: userSession.client, maxWidth: 120)) + .pipeline(.Swiftfin.branding) + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + } } var body: some View { @@ -34,6 +37,10 @@ extension SettingsView { action() } label: { HStack { + + // TODO: check properly with non-uniform images and look for workaround + // Note: for an unknown reason, using a non uniform aspect ratio will cause a + // "view origin is invalid" crash within SwiftUI imageView .aspectRatio(1, contentMode: .fill) .clipShape(.circle) diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift new file mode 100644 index 00000000..f497d8b5 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift @@ -0,0 +1,78 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import 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 { + + var onCancel: () -> Void + var onSelectedImage: (UIImage) -> Void + + init(onCancel: @escaping () -> Void, onSelectedImage: @escaping (UIImage) -> Void) { + self.onCancel = onCancel + self.onSelectedImage = onSelectedImage + } + + 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 + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + 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/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift new file mode 100644 index 00000000..d66907a3 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift @@ -0,0 +1,193 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Mantis +import SwiftUI + +extension UserProfileImagePicker { + + struct SquareImageCropView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: UserProfileImageCoordinator.Router + + @State + private var error: Error? = nil + @State + private var isPresentingError: Bool = false + @StateObject + private var proxy: _SquareImageCropView.Proxy = .init() + @StateObject + private var viewModel = UserProfileImageViewModel() + + let image: UIImage + + 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("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("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("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 + isPresentingError = true + case .uploaded: + router.dismissCoordinator() + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .destructive) + } message: { error in + Text(error.localizedDescription) + } + } + } + + 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/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift new file mode 100644 index 00000000..cefe33dd --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift @@ -0,0 +1,23 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct UserProfileImagePicker: View { + + @EnvironmentObject + private var router: UserProfileImageCoordinator.Router + + var body: some View { + PhotoPicker { + router.dismissCoordinator() + } onSelectedImage: { image in + router.route(to: \.cropImage, image) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index f7a92a85..96bdca0d 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -6,11 +6,15 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Defaults import Factory import SwiftUI struct UserProfileSettingsView: View { + @Default(.accentColor) + private var accentColor + @EnvironmentObject private var router: SettingsCoordinator.Router @@ -19,21 +23,28 @@ struct UserProfileSettingsView: View { @State private var isPresentingConfirmReset: Bool = false + @State + private var isPresentingProfileImageOptions: Bool = false @ViewBuilder private var imageView: some View { - ImageView( - viewModel.userSession.user.profileImageSource( - client: viewModel.userSession.client, - maxWidth: 120, - maxHeight: 120 + RedrawOnNotificationView(name: .init("didChangeUserProfileImage")) { + ImageView( + viewModel.userSession.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 120 + ) ) - ) - .placeholder { _ in - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .failure { - SystemImageContentView(systemName: "person.fill", ratio: 0.5) + .pipeline(.Swiftfin.branding) + .image { image in + image.posterBorder(ratio: 1 / 2, of: \.width) + } + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } } } @@ -42,18 +53,21 @@ struct UserProfileSettingsView: View { Section { VStack(alignment: .center) { Button { - // TODO: photo picker + isPresentingProfileImageOptions = true } label: { ZStack(alignment: .bottomTrailing) { imageView - .frame(width: 150, height: 150) + .aspectRatio(contentMode: .fill) .clipShape(.circle) + .frame(width: 150, height: 150) .shadow(radius: 5) - // TODO: uncomment when photo picker implemented -// Image(systemName: "pencil.circle.fill") -// .resizable() -// .frame(width: 30, height: 30) + Image(systemName: "pencil.circle.fill") + .resizable() + .frame(width: 30, height: 30) + .shadow(radius: 10) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) } } @@ -106,5 +120,19 @@ struct UserProfileSettingsView: View { } message: { Text("Are you sure you want to reset all user settings?") } + .confirmationDialog( + "Profile Image", + isPresented: $isPresentingProfileImageOptions, + titleVisibility: .visible + ) { + + Button("Select Image") { + router.route(to: \.photoPicker, viewModel) + } + + Button("Delete", role: .destructive) { + viewModel.deleteCurrentUserProfileImage() + } + } } } diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift index c4d6133b..0b9b3e7e 100644 --- a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift +++ b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift @@ -57,6 +57,7 @@ extension UserSignInView { Color.clear ImageView(user.profileImageSource(client: client, maxWidth: 120)) + .pipeline(.Swiftfin.branding) .image { image in image .posterBorder(ratio: 0.5, of: \.width)