From 777428a83736183a8bbfc07d4f69e639aa6691de Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 12 Dec 2024 16:18:02 -0700 Subject: [PATCH 01/22] Make user profile more generic. Still need to make it work for the reset image / other stuff like delete & username. --- .../UserProfileImageCoordinator.swift | 4 +- Shared/Services/Notifications.swift | 3 +- Shared/ViewModels/SettingsViewModel.swift | 6 +- .../UserProfileImageViewModel.swift | 15 ++-- Swiftfin.xcodeproj/project.pbxproj | 6 +- .../ServerUserDetailsView.swift | 14 +-- .../UserProfileSettingsView.swift | 73 ++++----------- .../Components/PhotoPicker.swift | 0 .../Components/SquareImageCropView.swift | 2 +- .../UserProfileImagePicker.swift | 0 .../UserProfileImageView.swift | 89 +++++++++++++++++++ 11 files changed, 134 insertions(+), 78 deletions(-) rename Swiftfin/Views/{SettingsView/UserProfileSettingsView => }/UserProfileImagePicker/Components/PhotoPicker.swift (100%) rename Swiftfin/Views/{SettingsView/UserProfileSettingsView => }/UserProfileImagePicker/Components/SquareImageCropView.swift (98%) rename Swiftfin/Views/{SettingsView/UserProfileSettingsView => }/UserProfileImagePicker/UserProfileImagePicker.swift (100%) create mode 100644 Swiftfin/Views/UserProfileImagePicker/UserProfileImageView.swift diff --git a/Shared/Coordinators/UserProfileImageCoordinator.swift b/Shared/Coordinators/UserProfileImageCoordinator.swift index 5542d587c..13935e407 100644 --- a/Shared/Coordinators/UserProfileImageCoordinator.swift +++ b/Shared/Coordinators/UserProfileImageCoordinator.swift @@ -21,9 +21,7 @@ final class UserProfileImageCoordinator: NavigationCoordinatable { func makeCropImage(image: UIImage) -> some View { #if os(iOS) - UserProfileImagePicker.SquareImageCropView( - image: image - ) + UserProfileImagePicker.SquareImageCropView(image: image) #else AssertionFailureView("not implemented") #endif diff --git a/Shared/Services/Notifications.swift b/Shared/Services/Notifications.swift index ce726b23e..7c5668ead 100644 --- a/Shared/Services/Notifications.swift +++ b/Shared/Services/Notifications.swift @@ -150,7 +150,8 @@ extension Notifications.Key { // MARK: - User - static var didChangeUserProfileImage: Key { + /// - Payload: The ID of the user whose Profile Image changed. + static var didChangeUserProfileImage: Key { Key("didChangeUserProfileImage") } diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 96295ee30..547297412 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -62,10 +62,10 @@ final class SettingsViewModel: ViewModel { } } - func deleteCurrentUserProfileImage() { + func deleteCurrentUserProfileImage(userID: String) { Task { let request = Paths.deleteUserImage( - userID: userSession.user.id, + userID: userID, imageType: "Primary" ) let _ = try await userSession.client.send(request) @@ -76,7 +76,7 @@ final class SettingsViewModel: ViewModel { await MainActor.run { userSession.user.data = response.value - Notifications[.didChangeUserProfileImage].post() + Notifications[.didChangeUserProfileImage].post(userID) } } } diff --git a/Shared/ViewModels/UserProfileImageViewModel.swift b/Shared/ViewModels/UserProfileImageViewModel.swift index 5e3cdf50f..96a1b9392 100644 --- a/Shared/ViewModels/UserProfileImageViewModel.swift +++ b/Shared/ViewModels/UserProfileImageViewModel.swift @@ -16,7 +16,7 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { enum Action: Equatable { case cancel - case upload(UIImage) + case upload(userID: String, image: UIImage) } enum Event: Hashable { @@ -47,11 +47,11 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { uploadCancellable?.cancel() return .initial - case let .upload(image): + case let .upload(userID, image): uploadCancellable = Task { do { - try await upload(image: image) + try await upload(userID: userID, image: image) await MainActor.run { self.eventSubject.send(.uploaded) @@ -72,7 +72,9 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { } } - private func upload(image: UIImage) async throws { + // MARK: - Upload Image + + private func upload(userID: String, image: UIImage) async throws { let contentType: String let imageData: Data @@ -89,7 +91,7 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { } var request = Paths.postUserImage( - userID: userSession.user.id, + userID: userID, imageType: "Primary", imageData ) @@ -102,8 +104,7 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { await MainActor.run { userSession.user.data = response.value - - Notifications[.didChangeUserProfileImage].post() + Notifications[.didChangeUserProfileImage].post(userID) } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index f151b3736..4d79c869d 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; }; 4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; }; 4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; }; + 4E4718252D0B95BC0080274D /* UserProfileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4718242D0B95B00080274D /* UserProfileImageView.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 */; }; @@ -1217,6 +1218,7 @@ 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.swift; sourceTree = ""; }; 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsSection.swift; sourceTree = ""; }; 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameInput.swift; sourceTree = ""; }; + 4E4718242D0B95B00080274D /* UserProfileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageView.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 = ""; }; @@ -2256,6 +2258,7 @@ children = ( 4E49DEDE2CE55F7F00352DCD /* Components */, 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */, + 4E4718242D0B95B00080274D /* UserProfileImageView.swift */, ); path = UserProfileImagePicker; sourceTree = ""; @@ -3765,6 +3768,7 @@ E10B1EAF2BD9769500A92EAF /* SelectUserView */, E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, + 4E49DEDF2CE55F7F00352DCD /* UserProfileImagePicker */, E1171A1A28A2215800FA1AF5 /* UserSignInView */, E193D5452719418B00900D82 /* VideoPlayer */, ); @@ -3835,7 +3839,6 @@ isa = PBXGroup; children = ( 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, - 4E49DEDF2CE55F7F00352DCD /* UserProfileImagePicker */, E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, ); @@ -5978,6 +5981,7 @@ E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, + 4E4718252D0B95BC0080274D /* UserProfileImageView.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index b673d6db9..4f19aa59d 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -30,11 +30,15 @@ struct ServerUserDetailsView: View { var body: some View { List { - // TODO: Replace with Update Profile Picture & Username - AdminDashboardView.UserSection( - user: viewModel.user, - lastActivityDate: viewModel.user.lastActivityDate - ) + UserProfileImageView( + username: viewModel.user.name, + imageSource: viewModel.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 120 + ) + ) { + print("Test") + } Section { if let userId = viewModel.user.id { diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index afebdcd6b..5cb440a5d 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -26,61 +26,16 @@ struct UserProfileSettingsView: View { @State private var isPresentingProfileImageOptions: Bool = false - @ViewBuilder - private var imageView: some View { - RedrawOnNotificationView(.didChangeUserProfileImage) { - ImageView( - viewModel.userSession.user.profileImageSource( + var body: some View { + List { + UserProfileImageView( + username: viewModel.userSession.user.username, + imageSource: viewModel.userSession.user.profileImageSource( client: viewModel.userSession.client, maxWidth: 120 ) - ) - .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) - } - } - } - - var body: some View { - List { - Section { - VStack(alignment: .center) { - Button { - isPresentingProfileImageOptions = true - } label: { - ZStack(alignment: .bottomTrailing) { - // `.aspectRatio(contentMode: .fill)` on `imageView` alone - // causes a crash on some iOS versions - ZStack { - imageView - } - .aspectRatio(1, contentMode: .fill) - .clipShape(.circle) - .frame(width: 150, height: 150) - .shadow(radius: 5) - - Image(systemName: "pencil.circle.fill") - .resizable() - .frame(width: 30, height: 30) - .shadow(radius: 10) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - } - } - - Text(viewModel.userSession.user.username) - .fontWeight(.semibold) - .font(.title2) - } - .frame(maxWidth: .infinity) - .listRowBackground(Color.clear) + ) { + isPresentingProfileImageOptions = true } Section { @@ -89,14 +44,14 @@ struct UserProfileSettingsView: View { router.route(to: \.quickConnect) } - ChevronButton("Password") + ChevronButton(L10n.password) .onSelect { router.route(to: \.resetUserPassword, viewModel.userSession.user.id) } } Section { - ChevronButton("Security") + ChevronButton(L10n.security) .onSelect { router.route(to: \.localSecurity) } @@ -113,7 +68,11 @@ struct UserProfileSettingsView: View { Text("Reset Swiftfin user settings") } } - .alert("Reset Settings", isPresented: $isPresentingConfirmReset) { + .confirmationDialog( + "Reset Settings", + isPresented: $isPresentingConfirmReset, + titleVisibility: .visible + ) { Button("Reset", role: .destructive) { do { try viewModel.userSession.user.deleteSettings() @@ -134,8 +93,8 @@ struct UserProfileSettingsView: View { router.route(to: \.photoPicker, viewModel) } - Button("Delete", role: .destructive) { - viewModel.deleteCurrentUserProfileImage() + Button(L10n.delete, role: .destructive) { + viewModel.deleteCurrentUserProfileImage(userID: viewModel.userSession.user.id) } } } diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift b/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift rename to Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift b/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift similarity index 98% rename from Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift rename to Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift index 57726e1c0..e4a747389 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift +++ b/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift @@ -42,7 +42,7 @@ extension UserProfileImagePicker { var body: some View { _SquareImageCropView(initialImage: image, proxy: proxy) { - viewModel.send(.upload($0)) + viewModel.send(.upload(userID: viewModel.userSession.user.id, image: $0)) } .animation(.linear(duration: 0.1), value: viewModel.state) .interactiveDismissDisabled(viewModel.state == .uploading) diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift rename to Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift diff --git a/Swiftfin/Views/UserProfileImagePicker/UserProfileImageView.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImageView.swift new file mode 100644 index 000000000..7a238e7dc --- /dev/null +++ b/Swiftfin/Views/UserProfileImagePicker/UserProfileImageView.swift @@ -0,0 +1,89 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct UserProfileImageView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - User Profile Variables + + let username: String? + let imageSource: ImageSource + + // MARK: - User Profile Action + + let onSelect: () -> Void + + // MARK: - Dialog State + + @State + private var isPresentingOptions = false + + // MARK: - Image View + + @ViewBuilder + private var imageView: some View { + RedrawOnNotificationView(.didChangeUserProfileImage) { + ImageView(imageSource) + .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) + } + } + } + + // MARK: - Body + + var body: some View { + Section { + VStack(alignment: .center) { + Button { + onSelect() + } label: { + ZStack(alignment: .bottomTrailing) { + // `.aspectRatio(contentMode: .fill)` on `imageView` alone + // causes a crash on some iOS versions + ZStack { + imageView + } + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + .frame(width: 150, height: 150) + .shadow(radius: 5) + + Image(systemName: "pencil.circle.fill") + .resizable() + .frame(width: 30, height: 30) + .shadow(radius: 10) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + + Text(username ?? L10n.unknown) + .fontWeight(.semibold) + .font(.title2) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + } +} From b36e9b97d5c8dcf4e2e574a61d47b284d56de367 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 12 Dec 2024 22:02:09 -0700 Subject: [PATCH 02/22] Username Changing and PFP deletion. --- Shared/Strings/Strings.swift | 14 ++++- .../ServerUserAdminViewModel.swift | 47 ++++++++++++++ Swiftfin.xcodeproj/project.pbxproj | 8 +-- .../APIKeyView/Components/APIKeysRow.swift | 4 +- .../ServerUserDetailsView.swift | 61 ++++++++++++++++--- .../UserProfileSettingsView.swift | 30 +++------ ...ImageView.swift => UserProfileImage.swift} | 21 +++++-- Translations/en.lproj/Localizable.strings | 20 ++++++ 8 files changed, 163 insertions(+), 42 deletions(-) rename Swiftfin/Views/UserProfileImagePicker/{UserProfileImageView.swift => UserProfileImage.swift} (83%) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index eb61fe726..5a7e8b577 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -46,8 +46,8 @@ internal enum L10n { internal static let additionalSecurityAccessDescription = L10n.tr("Localizable", "additionalSecurityAccessDescription", fallback: "Additional security access for users signed in to this device. This does not change any Jellyfin server user settings.") /// Add Server internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") - /// Add Trigger - internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add Trigger") + /// Add trigger + internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add trigger") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") /// Add User @@ -1046,6 +1046,8 @@ internal enum L10n { internal static let production = L10n.tr("Localizable", "production", fallback: "Production") /// Production Locations internal static let productionLocations = L10n.tr("Localizable", "productionLocations", fallback: "Production Locations") + /// Profile Image + internal static let profileImage = L10n.tr("Localizable", "profileImage", fallback: "Profile Image") /// Profiles internal static let profiles = L10n.tr("Localizable", "profiles", fallback: "Profiles") /// Programs @@ -1164,6 +1166,12 @@ internal enum L10n { internal static let resetAllSettings = L10n.tr("Localizable", "resetAllSettings", fallback: "Reset all settings back to defaults.") /// Reset App Settings internal static let resetAppSettings = L10n.tr("Localizable", "resetAppSettings", fallback: "Reset App Settings") + /// Reset Settings - Button + internal static let resetSettings = L10n.tr("Localizable", "resetSettings", fallback: "Reset Settings") + /// Reset Settings - Footer + internal static let resetSettingsDescription = L10n.tr("Localizable", "resetSettingsDescription", fallback: "Reset Swiftfin user settings") + /// Reset Settings - Dialog Message + internal static let resetSettingsMessage = L10n.tr("Localizable", "resetSettingsMessage", fallback: "Are you sure you want to reset all user settings?") /// Reset User Settings internal static let resetUserSettings = L10n.tr("Localizable", "resetUserSettings", fallback: "Reset User Settings") /// Restart Server @@ -1238,6 +1246,8 @@ internal enum L10n { internal static let selectAll = L10n.tr("Localizable", "selectAll", fallback: "Select All") /// Select Cast Destination internal static let selectCastDestination = L10n.tr("Localizable", "selectCastDestination", fallback: "Select Cast Destination") + /// Select Image + internal static let selectImage = L10n.tr("Localizable", "selectImage", fallback: "Select Image") /// Series internal static let series = L10n.tr("Localizable", "series", fallback: "Series") /// Series Backdrop diff --git a/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift index f3eba6f18..08a945711 100644 --- a/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift @@ -29,6 +29,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl case updatePolicy(UserPolicy) case updateConfiguration(UserConfiguration) case updateUsername(String) + case deleteProfileImage } // MARK: - Background State @@ -218,6 +219,52 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl .asAnyCancellable() return state + + case .deleteProfileImage: + userTaskCancellable?.cancel() + + userTaskCancellable = Task { + do { + await MainActor.run { + _ = backgroundStates.append(.updating) + } + + try await deleteUserProfileImage() + + await MainActor.run { + state = .content + _ = backgroundStates.remove(.updating) + } + } catch { + await MainActor.run { + state = .error(.init(error.localizedDescription)) + eventSubject.send(.error(.init(error.localizedDescription))) + _ = backgroundStates.remove(.updating) + } + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: - Delete User Profile Image + + private func deleteUserProfileImage() async throws { + guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") } + let request = Paths.deleteUserImage( + userID: userID, + imageType: "Primary" + ) + let _ = try await userSession.client.send(request) + + let userRequest = Paths.getUserByID(userID: userID) + let response = try await userSession.client.send(userRequest) + + await MainActor.run { + user = response.value + Notifications[.didChangeUserProfileImage].post(userID) } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 4d79c869d..4ba8991f5 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -69,7 +69,7 @@ 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; }; 4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; }; 4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; }; - 4E4718252D0B95BC0080274D /* UserProfileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4718242D0B95B00080274D /* UserProfileImageView.swift */; }; + 4E4718252D0B95BC0080274D /* UserProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4718242D0B95B00080274D /* UserProfileImage.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 */; }; @@ -1218,7 +1218,7 @@ 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.swift; sourceTree = ""; }; 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsSection.swift; sourceTree = ""; }; 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameInput.swift; sourceTree = ""; }; - 4E4718242D0B95B00080274D /* UserProfileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageView.swift; sourceTree = ""; }; + 4E4718242D0B95B00080274D /* UserProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImage.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 = ""; }; @@ -2257,8 +2257,8 @@ isa = PBXGroup; children = ( 4E49DEDE2CE55F7F00352DCD /* Components */, + 4E4718242D0B95B00080274D /* UserProfileImage.swift */, 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */, - 4E4718242D0B95B00080274D /* UserProfileImageView.swift */, ); path = UserProfileImagePicker; sourceTree = ""; @@ -5981,7 +5981,7 @@ E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, - 4E4718252D0B95BC0080274D /* UserProfileImageView.swift in Sources */, + 4E4718252D0B95BC0080274D /* UserProfileImage.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, diff --git a/Swiftfin/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift b/Swiftfin/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift index 44549e1a1..b81799869 100644 --- a/Swiftfin/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift +++ b/Swiftfin/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift @@ -15,11 +15,11 @@ extension APIKeysView { struct APIKeysRow: View { - // MARK: - Actions + // MARK: - API Key Variables let apiKey: AuthenticationInfo - // MARK: - Actions + // MARK: - API Key Actions let onSelect: () -> Void let onDelete: () -> Void diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 4f19aa59d..d6fe5584d 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -11,33 +11,53 @@ import JellyfinAPI import SwiftUI struct ServerUserDetailsView: View { - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router + + // MARK: - Current Date @CurrentDate private var currentDate: Date + // MARK: - State & Environment Objects + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + @StateObject private var viewModel: ServerUserAdminViewModel + // MARK: - Dialog State + + @State + private var username: String + @State + private var isPresentingUsername = false + + // MARK: - Error State + + @State + private var error: Error? + // MARK: - Initializer init(user: UserDto) { - _viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user)) + self._viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user)) + self.username = user.name ?? "" } // MARK: - Body var body: some View { List { - UserProfileImageView( + UserProfileImage( username: viewModel.user.name, imageSource: viewModel.user.profileImageSource( client: viewModel.userSession.client, maxWidth: 120 ) ) { - print("Test") + print("Selected") + } delete: { + viewModel.send(.deleteProfileImage) } Section { @@ -47,13 +67,26 @@ struct ServerUserDetailsView: View { router.route(to: \.resetUserPassword, userId) } } - } - - Section(L10n.advanced) { ChevronButton(L10n.permissions) .onSelect { router.route(to: \.userPermissions, viewModel) } + ChevronAlertButton( + L10n.username, + subtitle: viewModel.user.name + ) { + TextField(L10n.username, text: $username) + HStack { + Button(L10n.cancel) { + username = viewModel.user.name ?? "" + isPresentingUsername = false + } + Button(L10n.save) { + viewModel.send(.updateUsername(username)) + isPresentingUsername = false + } + } + } } Section(L10n.access) { @@ -81,7 +114,7 @@ struct ServerUserDetailsView: View { .onSelect { router.route(to: \.userAllowedTags, viewModel) } - // TODO: Block items - blockedTags + // TODO: Block items - blockedTags ChevronButton("Block items") .onSelect { router.route(to: \.userBlockedTags, viewModel) @@ -96,5 +129,15 @@ struct ServerUserDetailsView: View { .onAppear { viewModel.send(.loadDetails) } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + username = viewModel.user.name ?? "" + case .updated: + break + } + } + .errorMessage($error) } } diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index 5cb440a5d..cbf67c886 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -28,14 +28,16 @@ struct UserProfileSettingsView: View { var body: some View { List { - UserProfileImageView( + UserProfileImage( username: viewModel.userSession.user.username, imageSource: viewModel.userSession.user.profileImageSource( client: viewModel.userSession.client, maxWidth: 120 ) ) { - isPresentingProfileImageOptions = true + router.route(to: \.photoPicker, viewModel) + } delete: { + viewModel.deleteCurrentUserProfileImage(userID: viewModel.userSession.user.id) } Section { @@ -60,20 +62,20 @@ struct UserProfileSettingsView: View { Section { // TODO: move under future "Storage" tab // when downloads implemented - Button("Reset Settings") { + Button(L10n.resetSettings) { isPresentingConfirmReset = true } .foregroundStyle(.red) } footer: { - Text("Reset Swiftfin user settings") + Text(L10n.resetSettingsDescription) } } .confirmationDialog( - "Reset Settings", + L10n.resetSettings, isPresented: $isPresentingConfirmReset, titleVisibility: .visible ) { - Button("Reset", role: .destructive) { + Button(L10n.reset, role: .destructive) { do { try viewModel.userSession.user.deleteSettings() } catch { @@ -81,21 +83,7 @@ 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(L10n.delete, role: .destructive) { - viewModel.deleteCurrentUserProfileImage(userID: viewModel.userSession.user.id) - } + Text(L10n.resetSettingsMessage) } } } diff --git a/Swiftfin/Views/UserProfileImagePicker/UserProfileImageView.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift similarity index 83% rename from Swiftfin/Views/UserProfileImagePicker/UserProfileImageView.swift rename to Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift index 7a238e7dc..59b6ea84c 100644 --- a/Swiftfin/Views/UserProfileImagePicker/UserProfileImageView.swift +++ b/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift @@ -10,7 +10,7 @@ import Defaults import JellyfinAPI import SwiftUI -struct UserProfileImageView: View { +struct UserProfileImage: View { // MARK: - Defaults @@ -22,9 +22,10 @@ struct UserProfileImageView: View { let username: String? let imageSource: ImageSource - // MARK: - User Profile Action + // MARK: - User Profile Action Menu - let onSelect: () -> Void + let select: () -> Void + let delete: () -> Void // MARK: - Dialog State @@ -56,7 +57,7 @@ struct UserProfileImageView: View { Section { VStack(alignment: .center) { Button { - onSelect() + isPresentingOptions = true } label: { ZStack(alignment: .bottomTrailing) { // `.aspectRatio(contentMode: .fill)` on `imageView` alone @@ -85,5 +86,17 @@ struct UserProfileImageView: View { .frame(maxWidth: .infinity) .listRowBackground(Color.clear) } + .confirmationDialog( + L10n.profileImage, + isPresented: $isPresentingOptions, + titleVisibility: .visible + ) { + Button(L10n.selectImage) { + select() + } + Button(L10n.delete, role: .destructive) { + delete() + } + } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 84ddeb8d9..651beaa6a 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -2300,3 +2300,23 @@ // Delete Selected Schedules - Button // Button label for deleting all selected Access Schedules "deleteSelectedSchedules" = "Delete Selected Schedules"; + +// Profile Image - Dialog Title +// Title for the profile image confirmation dialog +"profileImage" = "Profile Image"; + +// Select Image - Button +// Button label for selecting a profile image +"selectImage" = "Select Image"; + +/* Reset Settings - Button */ +// Button label for resetting settings +"resetSettings" = "Reset Settings"; + +/* Reset Settings - Footer */ +// Footer text for reset settings section +"resetSettingsDescription" = "Reset Swiftfin user settings"; + +/* Reset Settings - Dialog Message */ +// Message displayed in the reset settings confirmation dialog +"resetSettingsMessage" = "Are you sure you want to reset all user settings?"; From ebf4e1b5e7114d4befb1e9a1aee80aa17b104992 Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 14 Dec 2024 00:29:23 -0700 Subject: [PATCH 03/22] Functional, refreshing, and good to go! --- .../AdminDashboardCoordinator.swift | 6 ++ Shared/Coordinators/SettingsCoordinator.swift | 4 +- .../UserProfileImageCoordinator.swift | 22 ++++- Shared/Services/Notifications.swift | 4 +- .../ServerUserAdminViewModel.swift | 66 ++++--------- .../AdminDashboard/ServerUsersViewModel.swift | 57 +++++++++++ Shared/ViewModels/SettingsViewModel.swift | 19 ---- .../UserProfileImageViewModel.swift | 96 +++++++++++++++---- Swiftfin/Components/SettingsBarButton.swift | 2 +- .../ServerUserDetailsView.swift | 12 ++- .../Components/ServerUsersRow.swift | 20 ++-- .../Components/UserProfileRow.swift | 2 +- .../UserProfileSettingsView.swift | 11 ++- .../Components/PhotoPicker.swift | 12 +++ .../Components/SquareImageCropView.swift | 11 ++- .../UserProfileImage.swift | 2 +- .../UserProfileImagePicker.swift | 7 ++ 17 files changed, 239 insertions(+), 114 deletions(-) diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift index 3c596ec99..4dfcecb9d 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -72,6 +72,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { var userEditAccessSchedules = makeUserEditAccessSchedules @Route(.modal) var userAddAccessSchedule = makeUserAddAccessSchedule + @Route(.modal) + var userPhotoPicker = makeUserPhotoPicker // MARK: - Route: API Keys @@ -139,6 +141,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { ServerUserDetailsView(user: user) } + func makeUserPhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel)) + } + func makeAddServerUser() -> NavigationViewCoordinator { NavigationViewCoordinator { AddServerUserView() diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index da42ffc74..e4517738d 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -123,8 +123,8 @@ final class SettingsCoordinator: NavigationCoordinatable { UserLocalSecurityView() } - func makePhotoPicker(viewModel: SettingsViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(UserProfileImageCoordinator()) + func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel)) } @ViewBuilder diff --git a/Shared/Coordinators/UserProfileImageCoordinator.swift b/Shared/Coordinators/UserProfileImageCoordinator.swift index 13935e407..df4eef366 100644 --- a/Shared/Coordinators/UserProfileImageCoordinator.swift +++ b/Shared/Coordinators/UserProfileImageCoordinator.swift @@ -6,22 +6,40 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import Stinsen import SwiftUI final class UserProfileImageCoordinator: NavigationCoordinatable { + // MARK: - Navigation Components + let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start) @Root var start = makeStart + // MARK: - Routes + @Route(.push) var cropImage = makeCropImage + // MARK: - Observed Object + + @ObservedObject + var viewModel: UserProfileImageViewModel + + // MARK: - Initializer + + init(viewModel: UserProfileImageViewModel) { + self.viewModel = viewModel + } + + // MARK: - Views + func makeCropImage(image: UIImage) -> some View { #if os(iOS) - UserProfileImagePicker.SquareImageCropView(image: image) + UserProfileImagePicker.SquareImageCropView(viewModel: viewModel, image: image) #else AssertionFailureView("not implemented") #endif @@ -30,7 +48,7 @@ final class UserProfileImageCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { #if os(iOS) - UserProfileImagePicker() + UserProfileImagePicker(viewModel: viewModel) #else AssertionFailureView("not implemented") #endif diff --git a/Shared/Services/Notifications.swift b/Shared/Services/Notifications.swift index 7c5668ead..003287fec 100644 --- a/Shared/Services/Notifications.swift +++ b/Shared/Services/Notifications.swift @@ -151,8 +151,8 @@ extension Notifications.Key { // MARK: - User /// - Payload: The ID of the user whose Profile Image changed. - static var didChangeUserProfileImage: Key { - Key("didChangeUserProfileImage") + static var didChangeUserProfile: Key { + Key("didChangeUserProfile") } static var didAddServerUser: Key { diff --git a/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift index 08a945711..420db69e8 100644 --- a/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift @@ -24,12 +24,11 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl enum Action: Equatable { case cancel - case loadDetails + case refresh case loadLibraries(isHidden: Bool? = false) case updatePolicy(UserPolicy) case updateConfiguration(UserConfiguration) case updateUsername(String) - case deleteProfileImage } // MARK: - Background State @@ -69,10 +68,22 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl .eraseToAnyPublisher() } - // MARK: - Initialize + // MARK: - Initializer init(user: UserDto) { self.user = user + super.init() + + Notifications[.didChangeUserProfile] + .publisher + .sink { userID in + guard userID == self.user.id else { return } + + Task { + await self.send(.refresh) + } + } + .store(in: &cancellables) } // MARK: - Respond @@ -82,7 +93,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl case .cancel: return .initial - case .loadDetails: + case .refresh: userTaskCancellable?.cancel() userTaskCancellable = Task { @@ -219,52 +230,6 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl .asAnyCancellable() return state - - case .deleteProfileImage: - userTaskCancellable?.cancel() - - userTaskCancellable = Task { - do { - await MainActor.run { - _ = backgroundStates.append(.updating) - } - - try await deleteUserProfileImage() - - await MainActor.run { - state = .content - _ = backgroundStates.remove(.updating) - } - } catch { - await MainActor.run { - state = .error(.init(error.localizedDescription)) - eventSubject.send(.error(.init(error.localizedDescription))) - _ = backgroundStates.remove(.updating) - } - } - } - .asAnyCancellable() - - return state - } - } - - // MARK: - Delete User Profile Image - - private func deleteUserProfileImage() async throws { - guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") } - let request = Paths.deleteUserImage( - userID: userID, - imageType: "Primary" - ) - let _ = try await userSession.client.send(request) - - let userRequest = Paths.getUserByID(userID: userID) - let response = try await userSession.client.send(userRequest) - - await MainActor.run { - user = response.value - Notifications[.didChangeUserProfileImage].post(userID) } } @@ -327,6 +292,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl await MainActor.run { self.user.name = username + Notifications[.didChangeUserProfile].post(userID) } } } diff --git a/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift index 7dab67a8a..9e4f75e97 100644 --- a/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift @@ -24,6 +24,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { // MARK: Actions enum Action: Equatable { + case refreshUser(String) case getUsers(isHidden: Bool = false, isDisabled: Bool = false) case deleteUsers([String]) case appendUser(UserDto) @@ -63,10 +64,51 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { private var userTask: AnyCancellable? private var eventSubject: PassthroughSubject = .init() + // MARK: - Initializer + + override init() { + super.init() + + Notifications[.didChangeUserProfile] + .publisher + .sink { userID in + Task { + await self.send(.refreshUser(userID)) + } + } + .store(in: &cancellables) + } + // MARK: - Respond to Action func respond(to action: Action) -> State { switch action { + case let .refreshUser(userID): + userTask?.cancel() + backgroundStates.append(.gettingUsers) + + userTask = Task { + do { + try await refreshUser(userID) + + await MainActor.run { + state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + self.eventSubject.send(.error(.init(error.localizedDescription))) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.gettingUsers) + } + } + .asAnyCancellable() + + return state + case let .getUsers(isHidden, isDisabled): userTask?.cancel() backgroundStates.append(.gettingUsers) @@ -144,6 +186,21 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { } } + // MARK: - Refresh User + + private func refreshUser(_ userID: String) async throws { + let request = Paths.getUserByID(userID: userID) + let response = try await userSession.client.send(request) + + let newUser = response.value + + await MainActor.run { + if let index = self.users.firstIndex(where: { $0.id == userID }) { + self.users[index] = newUser + } + } + } + // MARK: - Load Users private func loadUsers(isHidden: Bool, isDisabled: Bool) async throws { diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 547297412..5c2386ccb 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -62,25 +62,6 @@ final class SettingsViewModel: ViewModel { } } - func deleteCurrentUserProfileImage(userID: String) { - Task { - let request = Paths.deleteUserImage( - userID: userID, - 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(userID) - } - } - } - func select(icon: any AppIcon) { let previousAppIcon = currentAppIcon currentAppIcon = icon diff --git a/Shared/ViewModels/UserProfileImageViewModel.swift b/Shared/ViewModels/UserProfileImageViewModel.swift index 96a1b9392..17bed55c9 100644 --- a/Shared/ViewModels/UserProfileImageViewModel.swift +++ b/Shared/ViewModels/UserProfileImageViewModel.swift @@ -14,51 +14,104 @@ import UIKit class UserProfileImageViewModel: ViewModel, Eventful, Stateful { + // MARK: - Action + enum Action: Equatable { case cancel - case upload(userID: String, image: UIImage) + case delete + case upload(UIImage) } + // MARK: - Event + enum Event: Hashable { case error(JellyfinAPIError) + case deleted case uploaded } + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + // MARK: - State + enum State: Hashable { case initial + case deleting case uploading } @Published var state: State = .initial - var events: AnyPublisher { - eventSubject - .receive(on: RunLoop.main) - .eraseToAnyPublisher() - } + // MARK: - Published Values + + @Published + var userID: String + + // MARK: - Task Variables private var eventSubject: PassthroughSubject = .init() private var uploadCancellable: AnyCancellable? + // MARK: - Initializer + + init(userID: String) { + self.userID = userID + } + + // MARK: - Respond to Action + func respond(to action: Action) -> State { switch action { case .cancel: uploadCancellable?.cancel() - return .initial - case let .upload(userID, image): + case let .upload(image): uploadCancellable = Task { do { - try await upload(userID: userID, image: image) + await MainActor.run { + self.state = .uploading + } + + try await upload(image) await MainActor.run { self.eventSubject.send(.uploaded) self.state = .initial } } catch is CancellationError { - // cancel doesn't matter + // Cancel doesn't matter + } catch { + await MainActor.run { + self.eventSubject.send(.error(.init(error.localizedDescription))) + self.state = .initial + } + } + } + .asAnyCancellable() + + return state + + case .delete: + uploadCancellable = Task { + do { + await MainActor.run { + self.state = .deleting + } + + try await delete() + + await MainActor.run { + self.eventSubject.send(.deleted) + self.state = .initial + } + } catch is CancellationError { + // Cancel doesn't matter } catch { await MainActor.run { self.eventSubject.send(.error(.init(error.localizedDescription))) @@ -68,14 +121,13 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { } .asAnyCancellable() - return .uploading + return state } } // MARK: - Upload Image - private func upload(userID: String, image: UIImage) async throws { - + private func upload(_ image: UIImage) async throws { let contentType: String let imageData: Data @@ -99,12 +151,22 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { let _ = try await userSession.client.send(request) - let currentUserRequest = Paths.getCurrentUser - let response = try await userSession.client.send(currentUserRequest) + await MainActor.run { + Notifications[.didChangeUserProfile].post(userID) + } + } + + // MARK: - Delete Image + + private func delete() async throws { + let request = Paths.deleteUserImage( + userID: userID, + imageType: "Primary" + ) + let _ = try await userSession.client.send(request) await MainActor.run { - userSession.user.data = response.value - Notifications[.didChangeUserProfileImage].post(userID) + Notifications[.didChangeUserProfile].post(userID) } } } diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift index 08f1aef65..ff5931750 100644 --- a/Swiftfin/Components/SettingsBarButton.swift +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -31,7 +31,7 @@ struct SettingsBarButton: View { ZStack { Color.clear - RedrawOnNotificationView(.didChangeUserProfileImage) { + RedrawOnNotificationView(.didChangeUserProfile) { ImageView(user.profileImageSource( client: server.client, maxWidth: 120 diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index d6fe5584d..ae8087200 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -17,7 +17,7 @@ struct ServerUserDetailsView: View { @CurrentDate private var currentDate: Date - // MARK: - State & Environment Objects + // MARK: - State, Observed, & Environment Objects @EnvironmentObject private var router: AdminDashboardCoordinator.Router @@ -25,6 +25,9 @@ struct ServerUserDetailsView: View { @StateObject private var viewModel: ServerUserAdminViewModel + @ObservedObject + private var profileViewModel: UserProfileImageViewModel + // MARK: - Dialog State @State @@ -41,6 +44,7 @@ struct ServerUserDetailsView: View { init(user: UserDto) { self._viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user)) + self.profileViewModel = .init(userID: user.id!) self.username = user.name ?? "" } @@ -55,9 +59,9 @@ struct ServerUserDetailsView: View { maxWidth: 120 ) ) { - print("Selected") + router.route(to: \.userPhotoPicker, profileViewModel) } delete: { - viewModel.send(.deleteProfileImage) + profileViewModel.send(.delete) } Section { @@ -127,7 +131,7 @@ struct ServerUserDetailsView: View { } .navigationTitle(L10n.user) .onAppear { - viewModel.send(.loadDetails) + viewModel.send(.refresh) } .onReceive(viewModel.events) { event in switch event { diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift index fe006974b..1ba973dc2 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -75,15 +75,17 @@ extension ServerUsersView { @ViewBuilder private var userImage: some View { ZStack { - ImageView(user.profileImageSource(client: userSession!.client)) - .pipeline(.Swiftfin.branding) - .placeholder { _ in - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .failure { - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .grayscale(userActive ? 0.0 : 1.0) + RedrawOnNotificationView(.didChangeUserProfile) { + ImageView(user.profileImageSource(client: userSession!.client)) + .pipeline(.Swiftfin.branding) + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .grayscale(userActive ? 0.0 : 1.0) + } if isEditing { Color.black diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index 7a04a3f8e..71242ba82 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -22,7 +22,7 @@ extension SettingsView { @ViewBuilder private var imageView: some View { - RedrawOnNotificationView(.didChangeUserProfileImage) { + RedrawOnNotificationView(.didChangeUserProfile) { ImageView(user.profileImageSource(client: userSession.client, maxWidth: 120)) .pipeline(.Swiftfin.branding) .placeholder { _ in diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index cbf67c886..f9156502c 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -20,12 +20,19 @@ struct UserProfileSettingsView: View { @ObservedObject var viewModel: SettingsViewModel + @ObservedObject + var profileViewModel: UserProfileImageViewModel @State private var isPresentingConfirmReset: Bool = false @State private var isPresentingProfileImageOptions: Bool = false + init(viewModel: SettingsViewModel) { + self.viewModel = viewModel + self.profileViewModel = .init(userID: viewModel.userSession.user.id) + } + var body: some View { List { UserProfileImage( @@ -35,9 +42,9 @@ struct UserProfileSettingsView: View { maxWidth: 120 ) ) { - router.route(to: \.photoPicker, viewModel) + router.route(to: \.photoPicker, profileViewModel) } delete: { - viewModel.deleteCurrentUserProfileImage(userID: viewModel.userSession.user.id) + profileViewModel.send(.delete) } Section { diff --git a/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift b/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift index f497d8b54..2f42dc85f 100644 --- a/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift +++ b/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift @@ -19,14 +19,20 @@ 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()) @@ -45,12 +51,18 @@ extension UserProfileImagePicker { 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)? diff --git a/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift b/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift index e4a747389..709fc5a35 100644 --- a/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift +++ b/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift @@ -19,15 +19,16 @@ extension UserProfileImagePicker { @Default(.accentColor) private var accentColor - // MARK: - State & Environment Objects + // MARK: - State, Observed, & Environment Objects @EnvironmentObject private var router: UserProfileImageCoordinator.Router @StateObject private var proxy: _SquareImageCropView.Proxy = .init() - @StateObject - private var viewModel = UserProfileImageViewModel() + + @ObservedObject + var viewModel: UserProfileImageViewModel // MARK: - Image Variable @@ -42,7 +43,7 @@ extension UserProfileImagePicker { var body: some View { _SquareImageCropView(initialImage: image, proxy: proxy) { - viewModel.send(.upload(userID: viewModel.userSession.user.id, image: $0)) + viewModel.send(.upload($0)) } .animation(.linear(duration: 0.1), value: viewModel.state) .interactiveDismissDisabled(viewModel.state == .uploading) @@ -98,6 +99,8 @@ extension UserProfileImagePicker { switch event { case let .error(eventError): error = eventError + case .deleted: + break case .uploaded: router.dismissCoordinator() } diff --git a/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift index 59b6ea84c..337d069e9 100644 --- a/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift +++ b/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift @@ -36,7 +36,7 @@ struct UserProfileImage: View { @ViewBuilder private var imageView: some View { - RedrawOnNotificationView(.didChangeUserProfileImage) { + RedrawOnNotificationView(.didChangeUserProfile) { ImageView(imageSource) .pipeline(.Swiftfin.branding) .image { image in diff --git a/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift index cefe33dda..dd9130340 100644 --- a/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift +++ b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift @@ -10,9 +10,16 @@ import SwiftUI struct UserProfileImagePicker: View { + // MARK: - Observed, & Environment Objects + @EnvironmentObject private var router: UserProfileImageCoordinator.Router + @ObservedObject + var viewModel: UserProfileImageViewModel + + // MARK: - Body + var body: some View { PhotoPicker { router.dismissCoordinator() From e9e3bcbe90b4278cfb5c13e7ce8600b2e4b2555b Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 14 Dec 2024 00:36:05 -0700 Subject: [PATCH 04/22] Clean up localizations --- .../UserProfileImageCoordinator.swift | 1 - Shared/ViewModels/SettingsViewModel.swift | 1 - Translations/en.lproj/Localizable.strings | 15 +++++---------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/Shared/Coordinators/UserProfileImageCoordinator.swift b/Shared/Coordinators/UserProfileImageCoordinator.swift index df4eef366..3cbe0a51a 100644 --- a/Shared/Coordinators/UserProfileImageCoordinator.swift +++ b/Shared/Coordinators/UserProfileImageCoordinator.swift @@ -6,7 +6,6 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import JellyfinAPI import Stinsen import SwiftUI diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 5c2386ccb..41abc488d 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -15,7 +15,6 @@ 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 { diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 651beaa6a..b6953217d 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -2301,22 +2301,17 @@ // Button label for deleting all selected Access Schedules "deleteSelectedSchedules" = "Delete Selected Schedules"; -// Profile Image - Dialog Title -// Title for the profile image confirmation dialog +/// Profile Image "profileImage" = "Profile Image"; -// Select Image - Button -// Button label for selecting a profile image +/// Select Image "selectImage" = "Select Image"; -/* Reset Settings - Button */ -// Button label for resetting settings +/// Reset Settings "resetSettings" = "Reset Settings"; -/* Reset Settings - Footer */ -// Footer text for reset settings section +/// Reset Swiftfin user settings "resetSettingsDescription" = "Reset Swiftfin user settings"; -/* Reset Settings - Dialog Message */ -// Message displayed in the reset settings confirmation dialog +/// Are you sure you want to reset all user settings? "resetSettingsMessage" = "Are you sure you want to reset all user settings?"; From 82c6629e429b7b1b3f8b271367c9744ce2d34dfc Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 17 Dec 2024 18:44:45 -0700 Subject: [PATCH 05/22] Migrate [UserDto] -> IdentifiedArrayOf --- Shared/Strings/Strings.swift | 6 +++--- .../AdminDashboard/ServerUsersViewModel.swift | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 5a7e8b577..483931bd4 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -1166,11 +1166,11 @@ internal enum L10n { internal static let resetAllSettings = L10n.tr("Localizable", "resetAllSettings", fallback: "Reset all settings back to defaults.") /// Reset App Settings internal static let resetAppSettings = L10n.tr("Localizable", "resetAppSettings", fallback: "Reset App Settings") - /// Reset Settings - Button + /// Reset Settings internal static let resetSettings = L10n.tr("Localizable", "resetSettings", fallback: "Reset Settings") - /// Reset Settings - Footer + /// Reset Swiftfin user settings internal static let resetSettingsDescription = L10n.tr("Localizable", "resetSettingsDescription", fallback: "Reset Swiftfin user settings") - /// Reset Settings - Dialog Message + /// Are you sure you want to reset all user settings? internal static let resetSettingsMessage = L10n.tr("Localizable", "resetSettingsMessage", fallback: "Are you sure you want to reset all user settings?") /// Reset User Settings internal static let resetUserSettings = L10n.tr("Localizable", "resetUserSettings", fallback: "Reset User Settings") diff --git a/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift index 9e4f75e97..05b9c0566 100644 --- a/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift @@ -8,6 +8,7 @@ import Combine import Foundation +import IdentifiedCollections import JellyfinAPI import OrderedCollections import SwiftUI @@ -50,8 +51,10 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { @Published final var backgroundStates: OrderedSet = [] + @Published - final var users: [UserDto] = [] + final var users: IdentifiedArrayOf = [] + @Published final var state: State = .initial @@ -211,7 +214,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { .sorted(using: \.name) await MainActor.run { - self.users = newUsers + self.users = IdentifiedArray(uniqueElements: newUsers) } } @@ -236,9 +239,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { } await MainActor.run { - self.users = self.users.filter { - !userIdsToDelete.contains($0.id ?? "") - } + self.users.removeAll(where: { userIdsToDelete.contains($0.id ?? "") }) } } @@ -254,7 +255,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { private func appendUser(user: UserDto) async { await MainActor.run { users.append(user) - users = users.sorted(using: \.name) + users.sort(by: { $0.name ?? "" < $1.name ?? "" }) } } } From 214a244e09073f20b46804387399259d77818212 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 17 Dec 2024 18:47:10 -0700 Subject: [PATCH 06/22] Solve "Username should probably be at the top of this section." --- .../ServerUserDetailsView.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index ae8087200..8af724dde 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -65,16 +65,6 @@ struct ServerUserDetailsView: View { } Section { - if let userId = viewModel.user.id { - ChevronButton(L10n.password) - .onSelect { - router.route(to: \.resetUserPassword, userId) - } - } - ChevronButton(L10n.permissions) - .onSelect { - router.route(to: \.userPermissions, viewModel) - } ChevronAlertButton( L10n.username, subtitle: viewModel.user.name @@ -91,6 +81,16 @@ struct ServerUserDetailsView: View { } } } + if let userId = viewModel.user.id { + ChevronButton(L10n.password) + .onSelect { + router.route(to: \.resetUserPassword, userId) + } + } + ChevronButton(L10n.permissions) + .onSelect { + router.route(to: \.userPermissions, viewModel) + } } Section(L10n.access) { From 121f5b712a7ba01459a4f7d38435c3af16faea54 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Wed, 18 Dec 2024 09:51:05 -0700 Subject: [PATCH 07/22] allow notification filter --- RedrawOnNotificationView.swift | 11 +++++++++-- .../ServerUsersView/Components/ServerUsersRow.swift | 5 ++++- .../SettingsView/Components/UserProfileRow.swift | 5 ++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/RedrawOnNotificationView.swift b/RedrawOnNotificationView.swift index 9b86d4a66..fb0cda448 100644 --- a/RedrawOnNotificationView.swift +++ b/RedrawOnNotificationView.swift @@ -13,10 +13,16 @@ struct RedrawOnNotificationView: View { @State private var id = 0 + private let filter: (P) -> Bool private let key: Notifications.Key

private let content: () -> Content - init(_ key: Notifications.Key

, @ViewBuilder content: @escaping () -> Content) { + init( + _ key: Notifications.Key

, + filter: @escaping (P) -> Bool = { _ in true }, + @ViewBuilder content: @escaping () -> Content + ) { + self.filter = filter self.key = key self.content = content } @@ -24,7 +30,8 @@ struct RedrawOnNotificationView: View { var body: some View { content() .id(id) - .onNotification(key) { _ in + .onNotification(key) { p in + guard filter(p) else { return } id += 1 } } diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift index 1ba973dc2..aa6eec6c7 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -75,7 +75,10 @@ extension ServerUsersView { @ViewBuilder private var userImage: some View { ZStack { - RedrawOnNotificationView(.didChangeUserProfile) { + RedrawOnNotificationView( + .didChangeUserProfile, + filter: { $0 == user.id } + ) { ImageView(user.profileImageSource(client: userSession!.client)) .pipeline(.Swiftfin.branding) .placeholder { _ in diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index 71242ba82..e899ee163 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -22,7 +22,10 @@ extension SettingsView { @ViewBuilder private var imageView: some View { - RedrawOnNotificationView(.didChangeUserProfile) { + RedrawOnNotificationView( + .didChangeUserProfile, + filter: { $0 == user.id } + ) { ImageView(user.profileImageSource(client: userSession.client, maxWidth: 120)) .pipeline(.Swiftfin.branding) .placeholder { _ in From 896def15757fad04b0fa58fe729238be2e15abbd Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 18 Dec 2024 20:45:08 -0700 Subject: [PATCH 08/22] WIP: Created `UserProfileHeroImage` but I haven't used it anywhere. --- Swiftfin.xcodeproj/project.pbxproj | 4 ++ .../Components/UserProfileHeroImage.swift | 63 +++++++++++++++++++ .../UserProfileHeroImage.swift | 63 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 Swiftfin/Components/UserProfileHeroImage.swift create mode 100644 Swiftfin/Views/UserProfileImage/UserProfileHeroImage.swift diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 4ba8991f5..0f4345126 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; }; 4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; }; 4E537A8D2D04410E00659A1A /* ServerUserLiveTVAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */; }; + 4E5508732D13AFED002A5345 /* UserProfileHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */; }; 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; @@ -1237,6 +1238,7 @@ 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDeviceAccessView.swift; sourceTree = ""; }; 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserLiveTVAccessView.swift; sourceTree = ""; }; + 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileHeroImage.swift; sourceTree = ""; }; 4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = ""; }; @@ -3189,6 +3191,7 @@ E1581E26291EF59800D6C640 /* SplitContentView.swift */, E1D27EE62BBC955F00152D16 /* UnmaskSecureField.swift */, E157562F29355B7900976E1F /* UpdateView.swift */, + 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */, 4E661A282CEFE68100025C99 /* Video3DFormatPicker.swift */, ); path = Components; @@ -5681,6 +5684,7 @@ 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, + 4E5508732D13AFED002A5345 /* UserProfileHeroImage.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, diff --git a/Swiftfin/Components/UserProfileHeroImage.swift b/Swiftfin/Components/UserProfileHeroImage.swift new file mode 100644 index 000000000..849343af5 --- /dev/null +++ b/Swiftfin/Components/UserProfileHeroImage.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import Nuke +import SwiftUI + +struct UserProfileHeroImage: View { + + // MARK: - User Variables + + private let userId: String? + private let size: CGFloat + private let source: ImageSource + private let pipeline: ImagePipeline + + // MARK: - Initializer + + init( + userId: String?, + size: CGFloat = 150, + source: ImageSource, + pipeline: ImagePipeline = .Swiftfin.default + ) { + self.userId = userId + self.size = size + self.source = source + self.pipeline = pipeline + } + + // MARK: - Body + + var body: some View { + RedrawOnNotificationView( + .didChangeUserProfile, + filter: { + $0 == userId + } + ) { + ImageView(source) + .pipeline(pipeline) + .image { + $0.posterBorder(ratio: 1 / 2, of: \.width) + } + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .aspectRatio(1, contentMode: .fill) + .clipShape(Circle()) + .frame(width: size, height: size) + .shadow(radius: 5) + } + } +} diff --git a/Swiftfin/Views/UserProfileImage/UserProfileHeroImage.swift b/Swiftfin/Views/UserProfileImage/UserProfileHeroImage.swift new file mode 100644 index 000000000..8d6041afd --- /dev/null +++ b/Swiftfin/Views/UserProfileImage/UserProfileHeroImage.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import Nuke +import SwiftUI + +struct UserProfileHeroImage: View { + + // MARK: - User Variables + + private let userId: String? + private let size: CGFloat + private let source: ImageSource + private let pipeline: ImagePipeline + + // MARK: - Initializer + + init( + userId: String?, + size: CGFloat = 150, + source: ImageSource, + pipeline: ImagePipeline = .Swiftfin.default + ) { + self.userId = userId + self.source = source + self.size = size + self.pipeline = pipeline + } + + // MARK: - Body + + var body: some View { + RedrawOnNotificationView( + .didChangeUserProfile, + filter: { + $0 == userId + } + ) { + ImageView(source) + .pipeline(pipeline) + .image { + $0.posterBorder(ratio: 1 / 2, of: \.width) + } + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .aspectRatio(1, contentMode: .fill) + .clipShape(Circle()) + .frame(width: size, height: size) + .shadow(radius: 5) + } + } +} From 0d1265b881e8e37aeec616c9aa8c74ec83b90291 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 19 Dec 2024 09:57:56 -0700 Subject: [PATCH 09/22] Centralize UserProfileHeroImages --- .../UserEditableHeroImage.swift | 77 +++++++++++-------- .../UserProfileHeroImage.swift | 14 ++-- Shared/Strings/Strings.swift | 4 +- .../Components/UserGridButton.swift | 18 ++--- .../Components/PublicUserRow.swift | 21 ++--- Swiftfin.xcodeproj/project.pbxproj | 22 ++++-- Swiftfin/Components/SettingsBarButton.swift | 27 ++----- .../ServerUserDetailsView.swift | 10 +-- .../Components/ServerUsersRow.swift | 25 ++---- .../Components/UserGridButton.swift | 41 ++-------- .../SelectUserView/Components/UserRow.swift | 20 ++--- .../Components/UserProfileRow.swift | 27 ++----- .../SettingsView/SettingsView.swift | 2 +- .../UserProfileSettingsView.swift | 14 ++-- .../Components/PublicUserRow.swift | 42 ++-------- Translations/en.lproj/Localizable.strings | 2 +- 16 files changed, 147 insertions(+), 219 deletions(-) rename Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift => Shared/Components/UserProfileImage/UserEditableHeroImage.swift (57%) rename {Swiftfin/Components => Shared/Components/UserProfileImage}/UserProfileHeroImage.swift (78%) diff --git a/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift b/Shared/Components/UserProfileImage/UserEditableHeroImage.swift similarity index 57% rename from Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift rename to Shared/Components/UserProfileImage/UserEditableHeroImage.swift index 337d069e9..ba262abf4 100644 --- a/Swiftfin/Views/UserProfileImagePicker/UserProfileImage.swift +++ b/Shared/Components/UserProfileImage/UserEditableHeroImage.swift @@ -7,48 +7,53 @@ // import Defaults +import Factory import JellyfinAPI +import Nuke import SwiftUI -struct UserProfileImage: View { +struct UserEditableHeroImage: View { - // MARK: - Defaults + // MARK: - Accent Color @Default(.accentColor) private var accentColor - // MARK: - User Profile Variables + // MARK: - User Session - let username: String? - let imageSource: ImageSource + @Injected(\.currentUserSession) + private var userSession - // MARK: - User Profile Action Menu + // MARK: - User Variables - let select: () -> Void - let delete: () -> Void + private let user: UserDto + private let source: ImageSource + private let pipeline: ImagePipeline - // MARK: - Dialog State + // MARK: - User Actions - @State - private var isPresentingOptions = false + private let onUpdate: () -> Void + private let onDelete: () -> Void - // MARK: - Image View + // MARK: - Dialog State - @ViewBuilder - private var imageView: some View { - RedrawOnNotificationView(.didChangeUserProfile) { - ImageView(imageSource) - .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) - } - } + @State + private var isPresentingOptions: Bool = false + + // MARK: - Initializer + + init( + user: UserDto, + source: ImageSource, + pipeline: ImagePipeline = .Swiftfin.default, + onUpdate: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.source = source + self.pipeline = pipeline + self.onUpdate = onUpdate + self.onDelete = onDelete } // MARK: - Body @@ -63,7 +68,11 @@ struct UserProfileImage: View { // `.aspectRatio(contentMode: .fill)` on `imageView` alone // causes a crash on some iOS versions ZStack { - imageView + UserProfileHeroImage( + userId: user.id, + source: source, + pipeline: userSession?.user.id == user.id ? .Swiftfin.branding : .Swiftfin.default + ) } .aspectRatio(1, contentMode: .fill) .clipShape(.circle) @@ -79,7 +88,7 @@ struct UserProfileImage: View { } } - Text(username ?? L10n.unknown) + Text(user.name ?? L10n.unknown) .fontWeight(.semibold) .font(.title2) } @@ -87,15 +96,19 @@ struct UserProfileImage: View { .listRowBackground(Color.clear) } .confirmationDialog( - L10n.profileImage, + """ + \(L10n.profileImage) + \(L10n.viewsMayRequireRestart) + """, isPresented: $isPresentingOptions, titleVisibility: .visible ) { + Text(L10n.viewsMayRequireRestart) Button(L10n.selectImage) { - select() + onUpdate() } Button(L10n.delete, role: .destructive) { - delete() + onDelete() } } } diff --git a/Swiftfin/Components/UserProfileHeroImage.swift b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift similarity index 78% rename from Swiftfin/Components/UserProfileHeroImage.swift rename to Shared/Components/UserProfileImage/UserProfileHeroImage.swift index 849343af5..3d5e47c16 100644 --- a/Swiftfin/Components/UserProfileHeroImage.swift +++ b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift @@ -16,22 +16,22 @@ struct UserProfileHeroImage: View { // MARK: - User Variables private let userId: String? - private let size: CGFloat private let source: ImageSource private let pipeline: ImagePipeline + private let placeholder: any View // MARK: - Initializer init( userId: String?, - size: CGFloat = 150, source: ImageSource, - pipeline: ImagePipeline = .Swiftfin.default + pipeline: ImagePipeline = .Swiftfin.default, + placeholder: any View = SystemImageContentView(systemName: "person.fill", ratio: 0.5) ) { self.userId = userId - self.size = size self.source = source self.pipeline = pipeline + self.placeholder = placeholder } // MARK: - Body @@ -49,14 +49,14 @@ struct UserProfileHeroImage: View { $0.posterBorder(ratio: 1 / 2, of: \.width) } .placeholder { _ in - SystemImageContentView(systemName: "person.fill", ratio: 0.5) + placeholder } .failure { - SystemImageContentView(systemName: "person.fill", ratio: 0.5) + placeholder } + .posterShadow() .aspectRatio(1, contentMode: .fill) .clipShape(Circle()) - .frame(width: size, height: size) .shadow(radius: 5) } } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 483931bd4..17e4105ad 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -16,8 +16,6 @@ internal enum L10n { internal static let absolute = L10n.tr("Localizable", "absolute", fallback: "Absolute") /// Accent Color internal static let accentColor = L10n.tr("Localizable", "accentColor", fallback: "Accent Color") - /// Some views may need an app restart to update. - internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.") /// Access internal static let access = L10n.tr("Localizable", "access", fallback: "Access") /// Accessibility @@ -1576,6 +1574,8 @@ internal enum L10n { internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") /// Video transcoding 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.") /// Weekday internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday") /// Weekend diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift index ead41f4cb..5f29d7cb7 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift @@ -69,17 +69,13 @@ extension SelectUserView { ZStack { Color.clear - ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) - .image { image in - image - .posterBorder(ratio: 1 / 2, of: \.width) - } - .placeholder { _ in - personView - } - .failure { - personView - } + UserProfileHeroImage( + userId: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ) + ) } .aspectRatio(1, contentMode: .fill) } diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift index 7843b6219..03c47b998 100644 --- a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift +++ b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift @@ -49,21 +49,14 @@ extension UserSignInView { ZStack { Color.clear - ImageView(user.profileImageSource(client: client, maxWidth: 120)) - .image { image in - image - .posterBorder(ratio: 0.5, of: \.width) - } - .placeholder { _ in - personView - } - .failure { - personView - } + UserProfileHeroImage( + userId: user.id, + source: user.profileImageSource( + client: client, + maxWidth: 120 + ) + ) } - .aspectRatio(1, contentMode: .fill) - .posterShadow() - .clipShape(.circle) .frame(width: 50, height: 50) Text(user.name ?? .emptyDash) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 0f4345126..90a992e03 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -69,7 +69,6 @@ 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; }; 4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; }; 4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; }; - 4E4718252D0B95BC0080274D /* UserProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4718242D0B95B00080274D /* UserProfileImage.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 */; }; @@ -138,6 +137,9 @@ 4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BBF2CB34775007CBD5D /* HomeSection.swift */; }; 4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */; }; 4E71D6892C80910900A0174D /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */; }; + 4E7315742D14772700EA2A95 /* UserEditableHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */; }; + 4E7315752D1485C900EA2A95 /* UserProfileHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */; }; + 4E7315762D1485CC00EA2A95 /* UserEditableHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */; }; 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; @@ -1219,7 +1221,6 @@ 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.swift; sourceTree = ""; }; 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsSection.swift; sourceTree = ""; }; 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameInput.swift; sourceTree = ""; }; - 4E4718242D0B95B00080274D /* UserProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImage.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 = ""; }; @@ -1270,6 +1271,7 @@ 4E699BBF2CB34775007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = ""; }; 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionDetailView.swift; sourceTree = ""; }; 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEditableHeroImage.swift; sourceTree = ""; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreModal.swift; sourceTree = ""; }; @@ -2259,7 +2261,6 @@ isa = PBXGroup; children = ( 4E49DEDE2CE55F7F00352DCD /* Components */, - 4E4718242D0B95B00080274D /* UserProfileImage.swift */, 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */, ); path = UserProfileImagePicker; @@ -2444,6 +2445,15 @@ path = ActiveSessionDetailView; sourceTree = ""; }; + 4E7315722D14752400EA2A95 /* UserProfileImage */ = { + isa = PBXGroup; + children = ( + 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */, + 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */, + ); + path = UserProfileImage; + sourceTree = ""; + }; 4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = { isa = PBXGroup; children = ( @@ -3191,7 +3201,6 @@ E1581E26291EF59800D6C640 /* SplitContentView.swift */, E1D27EE62BBC955F00152D16 /* UnmaskSecureField.swift */, E157562F29355B7900976E1F /* UpdateView.swift */, - 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */, 4E661A282CEFE68100025C99 /* Video3DFormatPicker.swift */, ); path = Components; @@ -4357,6 +4366,7 @@ E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */, E1A1528928FD22F600600579 /* TextPairView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */, + 4E7315722D14752400EA2A95 /* UserProfileImage */, E1B5784028F8AFCB00D42911 /* WrappedView.swift */, ); path = Components; @@ -5054,6 +5064,7 @@ E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */, + 4E7315762D1485CC00EA2A95 /* UserEditableHeroImage.swift in Sources */, E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */, E190704C2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */, @@ -5255,6 +5266,7 @@ E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, 4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */, 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */, + 4E7315752D1485C900EA2A95 /* UserProfileHeroImage.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, @@ -5699,6 +5711,7 @@ E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, 4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */, + 4E7315742D14772700EA2A95 /* UserEditableHeroImage.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, @@ -5985,7 +5998,6 @@ E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, - 4E4718252D0B95BC0080274D /* UserProfileImage.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift index ff5931750..6cdac8df8 100644 --- a/Swiftfin/Components/SettingsBarButton.swift +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -31,29 +31,16 @@ struct SettingsBarButton: View { ZStack { Color.clear - RedrawOnNotificationView(.didChangeUserProfile) { - ImageView(user.profileImageSource( + UserProfileHeroImage( + userId: user.id, + source: 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 - } - } + ), + pipeline: .Swiftfin.branding, + placeholder: Color.clear + ) } - .aspectRatio(contentMode: .fill) - .clipShape(.circle) } } .accessibilityLabel(L10n.settings) diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 8af724dde..47f50afc9 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -52,15 +52,15 @@ struct ServerUserDetailsView: View { var body: some View { List { - UserProfileImage( - username: viewModel.user.name, - imageSource: viewModel.user.profileImageSource( + UserEditableHeroImage( + user: viewModel.user, + source: viewModel.user.profileImageSource( client: viewModel.userSession.client, - maxWidth: 120 + maxWidth: 150 ) ) { router.route(to: \.userPhotoPicker, profileViewModel) - } delete: { + } onDelete: { profileViewModel.send(.delete) } diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift index aa6eec6c7..2edcfa422 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -75,29 +75,20 @@ extension ServerUsersView { @ViewBuilder private var userImage: some View { ZStack { - RedrawOnNotificationView( - .didChangeUserProfile, - filter: { $0 == user.id } - ) { - ImageView(user.profileImageSource(client: userSession!.client)) - .pipeline(.Swiftfin.branding) - .placeholder { _ in - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .failure { - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .grayscale(userActive ? 0.0 : 1.0) - } + UserProfileHeroImage( + userId: user.id, + source: user.profileImageSource( + client: userSession!.client, + maxWidth: 60 + ) + ) + .grayscale(userActive ? 0.0 : 1.0) if isEditing { Color.black .opacity(isSelected ? 0 : 0.5) } } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - .posterShadow() .frame(width: 60, height: 60) } diff --git a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift index 311025c09..a445d8baa 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift @@ -50,25 +50,6 @@ extension SelectUserView { return isSelected ? .primary : .secondary } - @ViewBuilder - private var personView: some View { - ZStack { - Group { - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - } - .posterShadow() - - RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - } - var body: some View { Button { action() @@ -77,21 +58,15 @@ extension SelectUserView { ZStack { Color.clear - ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) - .pipeline(.Swiftfin.branding) - .image { image in - image - .posterBorder(ratio: 1 / 2, of: \.width) - } - .placeholder { _ in - personView - } - .failure { - personView - } + UserProfileHeroImage( + userId: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.branding + ) } - .aspectRatio(contentMode: .fill) - .clipShape(.circle) .overlay { if isEditing { ZStack(alignment: .bottomTrailing) { diff --git a/Swiftfin/Views/SelectUserView/Components/UserRow.swift b/Swiftfin/Views/SelectUserView/Components/UserRow.swift index b8246c6bf..0b5832ee5 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserRow.swift @@ -74,18 +74,14 @@ extension SelectUserView { ZStack { Color.clear - ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) - .pipeline(.Swiftfin.branding) - .image { image in - image - .posterBorder(ratio: 1 / 2, of: \.width) - } - .placeholder { _ in - personView - } - .failure { - personView - } + UserProfileHeroImage( + userId: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.branding + ) if isEditing { Color.black diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index e899ee163..2e6ca28c6 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -20,23 +20,6 @@ extension SettingsView { private let user: UserDto private let action: (() -> Void)? - @ViewBuilder - private var imageView: some View { - RedrawOnNotificationView( - .didChangeUserProfile, - filter: { $0 == user.id } - ) { - ImageView(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 { Button { guard let action else { return } @@ -47,10 +30,14 @@ extension SettingsView { // `.aspectRatio(contentMode: .fill)` on `imageView` alone // causes a crash on some iOS versions ZStack { - imageView + UserProfileHeroImage( + userId: user.id, + source: user.profileImageSource( + client: userSession.client, + maxWidth: 120 + ) + ) } - .aspectRatio(1, contentMode: .fill) - .clipShape(.circle) .frame(width: 50, height: 50) Text(user.name ?? L10n.unknown) diff --git a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift index 1d95a2429..d0990d916 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -102,7 +102,7 @@ struct SettingsView: View { Section { ColorPicker(L10n.accentColor, selection: $accentColor, supportsOpacity: false) } footer: { - Text(L10n.accentColorDescription) + Text(L10n.viewsMayRequireRestart) } ChevronButton(L10n.logs) diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index f9156502c..e90e74734 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -8,6 +8,7 @@ import Defaults import Factory +import JellyfinAPI import SwiftUI struct UserProfileSettingsView: View { @@ -35,15 +36,18 @@ struct UserProfileSettingsView: View { var body: some View { List { - UserProfileImage( - username: viewModel.userSession.user.username, - imageSource: viewModel.userSession.user.profileImageSource( + UserEditableHeroImage( + user: UserDto( + id: viewModel.userSession.user.id, + name: viewModel.userSession.user.username + ), + source: viewModel.userSession.user.profileImageSource( client: viewModel.userSession.client, - maxWidth: 120 + maxWidth: 150 ) ) { router.route(to: \.photoPicker, profileViewModel) - } delete: { + } onDelete: { profileViewModel.send(.delete) } diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift index 16960b13d..6749aa809 100644 --- a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift +++ b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift @@ -30,25 +30,6 @@ extension UserSignInView { self.action = action } - @ViewBuilder - private var personView: some View { - ZStack { - Group { - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - } - .posterShadow() - - RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - } - var body: some View { Button { action() @@ -57,22 +38,15 @@ extension UserSignInView { ZStack { Color.clear - ImageView(user.profileImageSource(client: client, maxWidth: 120)) - .pipeline(.Swiftfin.branding) - .image { image in - image - .posterBorder(ratio: 0.5, of: \.width) - } - .placeholder { _ in - personView - } - .failure { - personView - } + UserProfileHeroImage( + userId: user.id, + source: user.profileImageSource( + client: client, + maxWidth: 120 + ), + pipeline: .Swiftfin.default + ) } - .aspectRatio(1, contentMode: .fill) - .posterShadow() - .clipShape(.circle) .frame(width: 50, height: 50) Text(user.name ?? .emptyDash) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index b6953217d..49cd4fce7 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -248,7 +248,7 @@ "invertedLight" = "Inverted Light"; "appIcon" = "App Icon"; "accentColor" = "Accent Color"; -"accentColorDescription" = "Some views may need an app restart to update."; +"viewsMayRequireRestart" = "Some views may need an app restart to update."; "dismiss" = "Dismiss"; "played" = "Played"; "unplayed" = "Unplayed"; From 064949b1d8d3e04f64d6b179902a24a4a1f06dd1 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 19 Dec 2024 13:59:39 -0700 Subject: [PATCH 10/22] Rename UserProfileImages --- .../UserEditableHeroImage.swift | 115 ------------------ .../UserProfileHeroImage.swift | 102 ++++++++++++---- .../UserProfileImage/UserProfileImage.swift | 63 ++++++++++ .../Components/UserGridButton.swift | 2 +- .../Components/PublicUserRow.swift | 2 +- Swiftfin.xcodeproj/project.pbxproj | 24 ++-- Swiftfin/Components/SettingsBarButton.swift | 2 +- .../ServerUserDetailsView.swift | 2 +- .../Components/ServerUsersRow.swift | 2 +- .../Components/UserGridButton.swift | 2 +- .../SelectUserView/Components/UserRow.swift | 2 +- .../Components/UserProfileRow.swift | 2 +- .../UserProfileSettingsView.swift | 2 +- .../Components/PublicUserRow.swift | 2 +- 14 files changed, 162 insertions(+), 162 deletions(-) delete mode 100644 Shared/Components/UserProfileImage/UserEditableHeroImage.swift create mode 100644 Shared/Components/UserProfileImage/UserProfileImage.swift diff --git a/Shared/Components/UserProfileImage/UserEditableHeroImage.swift b/Shared/Components/UserProfileImage/UserEditableHeroImage.swift deleted file mode 100644 index ba262abf4..000000000 --- a/Shared/Components/UserProfileImage/UserEditableHeroImage.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import Nuke -import SwiftUI - -struct UserEditableHeroImage: View { - - // MARK: - Accent Color - - @Default(.accentColor) - private var accentColor - - // MARK: - User Session - - @Injected(\.currentUserSession) - private var userSession - - // MARK: - User Variables - - private let user: UserDto - private let source: ImageSource - private let pipeline: ImagePipeline - - // MARK: - User Actions - - private let onUpdate: () -> Void - private let onDelete: () -> Void - - // MARK: - Dialog State - - @State - private var isPresentingOptions: Bool = false - - // MARK: - Initializer - - init( - user: UserDto, - source: ImageSource, - pipeline: ImagePipeline = .Swiftfin.default, - onUpdate: @escaping () -> Void, - onDelete: @escaping () -> Void - ) { - self.user = user - self.source = source - self.pipeline = pipeline - self.onUpdate = onUpdate - self.onDelete = onDelete - } - - // MARK: - Body - - var body: some View { - Section { - VStack(alignment: .center) { - Button { - isPresentingOptions = true - } label: { - ZStack(alignment: .bottomTrailing) { - // `.aspectRatio(contentMode: .fill)` on `imageView` alone - // causes a crash on some iOS versions - ZStack { - UserProfileHeroImage( - userId: user.id, - source: source, - pipeline: userSession?.user.id == user.id ? .Swiftfin.branding : .Swiftfin.default - ) - } - .aspectRatio(1, contentMode: .fill) - .clipShape(.circle) - .frame(width: 150, height: 150) - .shadow(radius: 5) - - Image(systemName: "pencil.circle.fill") - .resizable() - .frame(width: 30, height: 30) - .shadow(radius: 10) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - } - } - - Text(user.name ?? L10n.unknown) - .fontWeight(.semibold) - .font(.title2) - } - .frame(maxWidth: .infinity) - .listRowBackground(Color.clear) - } - .confirmationDialog( - """ - \(L10n.profileImage) - \(L10n.viewsMayRequireRestart) - """, - isPresented: $isPresentingOptions, - titleVisibility: .visible - ) { - Text(L10n.viewsMayRequireRestart) - Button(L10n.selectImage) { - onUpdate() - } - Button(L10n.delete, role: .destructive) { - onDelete() - } - } - } -} diff --git a/Shared/Components/UserProfileImage/UserProfileHeroImage.swift b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift index 3d5e47c16..52c4fb9fc 100644 --- a/Shared/Components/UserProfileImage/UserProfileHeroImage.swift +++ b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift @@ -7,57 +7,109 @@ // import Defaults +import Factory import JellyfinAPI import Nuke import SwiftUI struct UserProfileHeroImage: View { + // MARK: - Accent Color + + @Default(.accentColor) + private var accentColor + + // MARK: - User Session + + @Injected(\.currentUserSession) + private var userSession + // MARK: - User Variables - private let userId: String? + private let user: UserDto private let source: ImageSource private let pipeline: ImagePipeline - private let placeholder: any View + + // MARK: - User Actions + + private let onUpdate: () -> Void + private let onDelete: () -> Void + + // MARK: - Dialog State + + @State + private var isPresentingOptions: Bool = false // MARK: - Initializer init( - userId: String?, + user: UserDto, source: ImageSource, pipeline: ImagePipeline = .Swiftfin.default, - placeholder: any View = SystemImageContentView(systemName: "person.fill", ratio: 0.5) + onUpdate: @escaping () -> Void, + onDelete: @escaping () -> Void ) { - self.userId = userId + self.user = user self.source = source self.pipeline = pipeline - self.placeholder = placeholder + self.onUpdate = onUpdate + self.onDelete = onDelete } // MARK: - Body var body: some View { - RedrawOnNotificationView( - .didChangeUserProfile, - filter: { - $0 == userId + Section { + VStack(alignment: .center) { + Button { + isPresentingOptions = true + } label: { + ZStack(alignment: .bottomTrailing) { + // `.aspectRatio(contentMode: .fill)` on `imageView` alone + // causes a crash on some iOS versions + ZStack { + UserProfileImage( + userId: user.id, + source: source, + pipeline: userSession?.user.id == user.id ? .Swiftfin.branding : .Swiftfin.default + ) + } + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + .frame(width: 150, height: 150) + .shadow(radius: 5) + + Image(systemName: "pencil.circle.fill") + .resizable() + .frame(width: 30, height: 30) + .shadow(radius: 10) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + + Text(user.name ?? L10n.unknown) + .fontWeight(.semibold) + .font(.title2) } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + .confirmationDialog( + """ + \(L10n.profileImage) + \(L10n.viewsMayRequireRestart) + """, + isPresented: $isPresentingOptions, + titleVisibility: .visible ) { - ImageView(source) - .pipeline(pipeline) - .image { - $0.posterBorder(ratio: 1 / 2, of: \.width) - } - .placeholder { _ in - placeholder - } - .failure { - placeholder - } - .posterShadow() - .aspectRatio(1, contentMode: .fill) - .clipShape(Circle()) - .shadow(radius: 5) + Text(L10n.viewsMayRequireRestart) + Button(L10n.selectImage) { + onUpdate() + } + Button(L10n.delete, role: .destructive) { + onDelete() + } } } } diff --git a/Shared/Components/UserProfileImage/UserProfileImage.swift b/Shared/Components/UserProfileImage/UserProfileImage.swift new file mode 100644 index 000000000..2b7761922 --- /dev/null +++ b/Shared/Components/UserProfileImage/UserProfileImage.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import Nuke +import SwiftUI + +struct UserProfileImage: View { + + // MARK: - User Variables + + private let userId: String? + private let source: ImageSource + private let pipeline: ImagePipeline + private let placeholder: any View + + // MARK: - Initializer + + init( + userId: String?, + source: ImageSource, + pipeline: ImagePipeline = .Swiftfin.default, + placeholder: any View = SystemImageContentView(systemName: "person.fill", ratio: 0.5) + ) { + self.userId = userId + self.source = source + self.pipeline = pipeline + self.placeholder = placeholder + } + + // MARK: - Body + + var body: some View { + RedrawOnNotificationView( + .didChangeUserProfile, + filter: { + $0 == userId + } + ) { + ImageView(source) + .pipeline(pipeline) + .image { + $0.posterBorder(ratio: 1 / 2, of: \.width) + } + .placeholder { _ in + placeholder + } + .failure { + placeholder + } + .posterShadow() + .aspectRatio(1, contentMode: .fill) + .clipShape(Circle()) + .shadow(radius: 5) + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift index 5f29d7cb7..0eb3f2f70 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift @@ -69,7 +69,7 @@ extension SelectUserView { ZStack { Color.clear - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: server.client, diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift index 03c47b998..6f4bc75b9 100644 --- a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift +++ b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift @@ -49,7 +49,7 @@ extension UserSignInView { ZStack { Color.clear - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: client, diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 90a992e03..4cff3ec4b 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -96,7 +96,7 @@ 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; }; 4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; }; 4E537A8D2D04410E00659A1A /* ServerUserLiveTVAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */; }; - 4E5508732D13AFED002A5345 /* UserProfileHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */; }; + 4E5508732D13AFED002A5345 /* UserProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */; }; 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; @@ -137,9 +137,9 @@ 4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BBF2CB34775007CBD5D /* HomeSection.swift */; }; 4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */; }; 4E71D6892C80910900A0174D /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */; }; - 4E7315742D14772700EA2A95 /* UserEditableHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */; }; - 4E7315752D1485C900EA2A95 /* UserProfileHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */; }; - 4E7315762D1485CC00EA2A95 /* UserEditableHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */; }; + 4E7315742D14772700EA2A95 /* UserProfileHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */; }; + 4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */; }; + 4E7315762D1485CC00EA2A95 /* UserProfileHeroImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */; }; 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; @@ -1239,7 +1239,7 @@ 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDeviceAccessView.swift; sourceTree = ""; }; 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserLiveTVAccessView.swift; sourceTree = ""; }; - 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileHeroImage.swift; sourceTree = ""; }; + 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImage.swift; sourceTree = ""; }; 4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = ""; }; @@ -1271,7 +1271,7 @@ 4E699BBF2CB34775007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = ""; }; 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionDetailView.swift; sourceTree = ""; }; 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; - 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEditableHeroImage.swift; sourceTree = ""; }; + 4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileHeroImage.swift; sourceTree = ""; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreModal.swift; sourceTree = ""; }; @@ -2448,8 +2448,8 @@ 4E7315722D14752400EA2A95 /* UserProfileImage */ = { isa = PBXGroup; children = ( - 4E7315732D14770E00EA2A95 /* UserEditableHeroImage.swift */, - 4E5508722D13AFE3002A5345 /* UserProfileHeroImage.swift */, + 4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */, + 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */, ); path = UserProfileImage; sourceTree = ""; @@ -5064,7 +5064,7 @@ E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */, - 4E7315762D1485CC00EA2A95 /* UserEditableHeroImage.swift in Sources */, + 4E7315762D1485CC00EA2A95 /* UserProfileHeroImage.swift in Sources */, E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */, E190704C2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */, @@ -5266,7 +5266,7 @@ E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, 4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */, 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */, - 4E7315752D1485C900EA2A95 /* UserProfileHeroImage.swift in Sources */, + 4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, @@ -5696,7 +5696,7 @@ 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, - 4E5508732D13AFED002A5345 /* UserProfileHeroImage.swift in Sources */, + 4E5508732D13AFED002A5345 /* UserProfileImage.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, @@ -5711,7 +5711,7 @@ E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, 4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */, - 4E7315742D14772700EA2A95 /* UserEditableHeroImage.swift in Sources */, + 4E7315742D14772700EA2A95 /* UserProfileHeroImage.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift index 6cdac8df8..fefbc64ec 100644 --- a/Swiftfin/Components/SettingsBarButton.swift +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -31,7 +31,7 @@ struct SettingsBarButton: View { ZStack { Color.clear - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: server.client, diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 47f50afc9..2f2260309 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -52,7 +52,7 @@ struct ServerUserDetailsView: View { var body: some View { List { - UserEditableHeroImage( + UserProfileHeroImage( user: viewModel.user, source: viewModel.user.profileImageSource( client: viewModel.userSession.client, diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift index 2edcfa422..9feee8844 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -75,7 +75,7 @@ extension ServerUsersView { @ViewBuilder private var userImage: some View { ZStack { - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: userSession!.client, diff --git a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift index a445d8baa..fdcdcfa08 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift @@ -58,7 +58,7 @@ extension SelectUserView { ZStack { Color.clear - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: server.client, diff --git a/Swiftfin/Views/SelectUserView/Components/UserRow.swift b/Swiftfin/Views/SelectUserView/Components/UserRow.swift index 0b5832ee5..fb82f3b18 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserRow.swift @@ -74,7 +74,7 @@ extension SelectUserView { ZStack { Color.clear - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: server.client, diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index 2e6ca28c6..f57e1cfce 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -30,7 +30,7 @@ extension SettingsView { // `.aspectRatio(contentMode: .fill)` on `imageView` alone // causes a crash on some iOS versions ZStack { - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: userSession.client, diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index e90e74734..1d9b9e8fb 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -36,7 +36,7 @@ struct UserProfileSettingsView: View { var body: some View { List { - UserEditableHeroImage( + UserProfileHeroImage( user: UserDto( id: viewModel.userSession.user.id, name: viewModel.userSession.user.username diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift index 6749aa809..a74bb3795 100644 --- a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift +++ b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift @@ -38,7 +38,7 @@ extension UserSignInView { ZStack { Color.clear - UserProfileHeroImage( + UserProfileImage( userId: user.id, source: user.profileImageSource( client: client, From 275c625c0834b0e776695bd4bd908cdfa9111bd2 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 19 Dec 2024 16:28:50 -0700 Subject: [PATCH 11/22] Fix Merge Issue? --- .../Components/PublicUserButton.swift | 97 +++++++++++++++++++ .../Components/PublicUserRow.swift | 74 -------------- Swiftfin.xcodeproj/project.pbxproj | 8 +- 3 files changed, 101 insertions(+), 78 deletions(-) create mode 100644 Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift delete mode 100644 Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift new file mode 100644 index 000000000..f7ffbbf32 --- /dev/null +++ b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift @@ -0,0 +1,97 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension UserSignInView { + + struct PublicUserButton: View { + + // MARK: - Environment Variables + + @Environment(\.isEnabled) + private var isEnabled: Bool + + // MARK: - Public User Variables + + private let user: UserDto + private let client: JellyfinClient + private let action: () -> Void + + // MARK: - Initializer + + init( + user: UserDto, + client: JellyfinClient, + action: @escaping () -> Void + ) { + self.user = user + self.client = client + self.action = action + } + + // MARK: - Fallback Person View + + @ViewBuilder + private var fallbackPersonView: some View { + ZStack { + Color.secondarySystemFill + + RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + + // MARK: - Person View + + @ViewBuilder + private var personView: some View { + ZStack { + Color.clear + + ImageView(user.profileImageSource(client: client, maxWidth: 120)) + .image { image in + image + .posterBorder(ratio: 0.5, of: \.width) + } + .placeholder { _ in + fallbackPersonView + } + .failure { + fallbackPersonView + } + } + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + personView + .aspectRatio(1, contentMode: .fill) + .posterShadow() + .clipShape(.circle) + .frame(width: 150, height: 150) + .hoverEffect(.highlight) + + Text(user.name ?? .emptyDash) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(1) + .padding(.bottom) + } + .buttonBorderShape(.circle) + .buttonStyle(.borderless) + .disabled(!isEnabled) + .foregroundStyle(.primary) + } + } +} diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift deleted file mode 100644 index 6f4bc75b9..000000000 --- a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: change from list to grid button - -extension UserSignInView { - - struct PublicUserRow: View { - - private let user: UserDto - private let client: JellyfinClient - private let action: () -> Void - - init( - user: UserDto, - client: JellyfinClient, - action: @escaping () -> Void - ) { - self.user = user - self.client = client - self.action = action - } - - @ViewBuilder - private var personView: some View { - ZStack { - Color.secondarySystemFill - - RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - } - - var body: some View { - Button { - action() - } label: { - HStack { - ZStack { - Color.clear - - UserProfileImage( - userId: user.id, - source: user.profileImageSource( - client: client, - maxWidth: 120 - ) - ) - } - .frame(width: 50, height: 50) - - Text(user.name ?? .emptyDash) - .fontWeight(.semibold) - .foregroundStyle(.primary) - .lineLimit(1) - - Spacer() - } - } - .buttonStyle(.card) - .foregroundStyle(.primary) - } - } -} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 4cff3ec4b..a144aed01 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -763,7 +763,7 @@ E1763A2B2BF3046E004DF6AB /* UserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */; }; 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 */; }; + E1763A6A2BF3D177004DF6AB /* PublicUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A692BF3D177004DF6AB /* PublicUserButton.swift */; }; 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 */; }; @@ -1695,7 +1695,7 @@ E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserGridButton.swift; sourceTree = ""; }; E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowButton.swift; sourceTree = ""; }; E1763A652BF3CA83004DF6AB /* FullScreenMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenMenu.swift; sourceTree = ""; }; - E1763A692BF3D177004DF6AB /* PublicUserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicUserRow.swift; sourceTree = ""; }; + E1763A692BF3D177004DF6AB /* PublicUserButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicUserButton.swift; sourceTree = ""; }; E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftfinStore+Mappings.swift"; sourceTree = ""; }; E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoadingView.swift; sourceTree = ""; }; E1763A752BF3FF01004DF6AB /* AppLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoadingView.swift; sourceTree = ""; }; @@ -4016,7 +4016,7 @@ E1763A682BF3D16E004DF6AB /* Components */ = { isa = PBXGroup; children = ( - E1763A692BF3D177004DF6AB /* PublicUserRow.swift */, + E1763A692BF3D177004DF6AB /* PublicUserButton.swift */, ); path = Components; sourceTree = ""; @@ -5331,7 +5331,7 @@ E1575E7D293E77B5001665B1 /* PosterDisplayType.swift in Sources */, E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */, - E1763A6A2BF3D177004DF6AB /* PublicUserRow.swift in Sources */, + E1763A6A2BF3D177004DF6AB /* PublicUserButton.swift in Sources */, E1E6C44B29AED2B70064123F /* HorizontalAlignment.swift in Sources */, 4E35CE672CBED8B600DBD886 /* ServerTicks.swift in Sources */, E193D549271941CC00900D82 /* UserSignInView.swift in Sources */, From 6c48ac0ccc754c8a6a45cb46967fbfff8f53460c Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 19 Dec 2024 16:32:45 -0700 Subject: [PATCH 12/22] Move to UserProfileImage --- .../Components/PublicUserButton.swift | 32 ++++--------------- .../Views/UserSignInView/UserSignInView.swift | 2 +- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift index f7ffbbf32..7c685e14e 100644 --- a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift +++ b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift @@ -36,20 +36,6 @@ extension UserSignInView { self.action = action } - // MARK: - Fallback Person View - - @ViewBuilder - private var fallbackPersonView: some View { - ZStack { - Color.secondarySystemFill - - RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - } - // MARK: - Person View @ViewBuilder @@ -57,17 +43,13 @@ extension UserSignInView { ZStack { Color.clear - ImageView(user.profileImageSource(client: client, maxWidth: 120)) - .image { image in - image - .posterBorder(ratio: 0.5, of: \.width) - } - .placeholder { _ in - fallbackPersonView - } - .failure { - fallbackPersonView - } + UserProfileImage( + userId: user.id, + source: user.profileImageSource( + client: client, + maxWidth: 120 + ) + ) } } diff --git a/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift index 790e9f63a..33b02d9cf 100644 --- a/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift @@ -137,7 +137,7 @@ struct UserSignInView: View { .frame(maxWidth: .infinity) } else { ForEach(viewModel.publicUsers, id: \.id) { user in - PublicUserRow( + PublicUserButton( user: user, client: viewModel.server.client ) { From 157334f6f4b69c1e3bbeba0a92c75cb02a9a474c Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 20 Dec 2024 13:38:43 -0700 Subject: [PATCH 13/22] Merge with Main --- Shared/Strings/Strings.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index e47d53aef..72d254aa1 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -16,6 +16,8 @@ internal enum L10n { internal static let absolute = L10n.tr("Localizable", "absolute", fallback: "Absolute") /// Accent Color internal static let accentColor = L10n.tr("Localizable", "accentColor", fallback: "Accent Color") + /// Some views may need an app restart to update. + internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.") /// Access internal static let access = L10n.tr("Localizable", "access", fallback: "Access") /// Accessibility @@ -998,8 +1000,6 @@ internal enum L10n { internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset") /// Reset all settings back to defaults. internal static let resetAllSettings = L10n.tr("Localizable", "resetAllSettings", fallback: "Reset all settings back to defaults.") - /// Reset App Settings - internal static let resetAppSettings = L10n.tr("Localizable", "resetAppSettings", fallback: "Reset App Settings") /// Reset Settings internal static let resetSettings = L10n.tr("Localizable", "resetSettings", fallback: "Reset Settings") /// Reset Swiftfin user settings @@ -1064,8 +1064,6 @@ internal enum L10n { internal static let seeMore = L10n.tr("Localizable", "seeMore", fallback: "See More") /// Select All internal static let selectAll = L10n.tr("Localizable", "selectAll", fallback: "Select All") - /// Select Cast Destination - internal static let selectCastDestination = L10n.tr("Localizable", "selectCastDestination", fallback: "Select Cast Destination") /// Select Image internal static let selectImage = L10n.tr("Localizable", "selectImage", fallback: "Select Image") /// Series From 17acf9607e7dfe2a6307d7712e3ea353ef8c619f Mon Sep 17 00:00:00 2001 From: Joe Kribs Date: Sat, 21 Dec 2024 01:34:08 -0700 Subject: [PATCH 14/22] Fix Merge? --- Swiftfin.xcodeproj/project.pbxproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 298461864..b8c55926d 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -2475,7 +2475,8 @@ 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */, ); path = UserProfileImage; - sourceTree = ""; + sourceTree = ""; + }; 4E75B34D2D16583900D16531 /* Translations */ = { isa = PBXGroup; children = ( From a99bdcde9de99a4421a1ff2662cdb978e04199f9 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 23 Dec 2024 00:14:30 -0700 Subject: [PATCH 15/22] Clear the cache on update. --- .../UserProfileImage/UserProfileImage.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Shared/Components/UserProfileImage/UserProfileImage.swift b/Shared/Components/UserProfileImage/UserProfileImage.swift index 2b7761922..8ffe5f4bc 100644 --- a/Shared/Components/UserProfileImage/UserProfileImage.swift +++ b/Shared/Components/UserProfileImage/UserProfileImage.swift @@ -7,12 +7,18 @@ // import Defaults +import Factory import JellyfinAPI import Nuke import SwiftUI struct UserProfileImage: View { + // MARK: - Inject Logger + + @Injected(\.logService) + private var logger + // MARK: - User Variables private let userId: String? @@ -59,5 +65,21 @@ struct UserProfileImage: View { .clipShape(Circle()) .shadow(radius: 5) } + .onNotification(.didChangeUserProfile) { notificationUser in + if notificationUser == userId { + let imageURL = source.url + + guard let imageURL else { + logger.info("No user profile image URL found") + return + } + + let request = ImageRequest(url: imageURL) + + pipeline.cache[request] = nil + pipeline.configuration.dataCache?.removeData(for: imageURL.absoluteString) + logger.info("Image removed from cache: \(imageURL.absoluteString)") + } + } } } From 38f7656cea706a0773b5c3a8f192aedfe23a989e Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 23 Dec 2024 00:22:15 -0700 Subject: [PATCH 16/22] Delete duplicate `UserProfileImage` --- Swiftfin.xcodeproj/project.pbxproj | 2 +- .../UserProfileHeroImage.swift | 63 ------------------- 2 files changed, 1 insertion(+), 64 deletions(-) delete mode 100644 Swiftfin/Views/UserProfileImage/UserProfileHeroImage.swift diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index b8c55926d..38ad6ae09 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -2475,7 +2475,7 @@ 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */, ); path = UserProfileImage; - sourceTree = ""; + sourceTree = ""; }; 4E75B34D2D16583900D16531 /* Translations */ = { isa = PBXGroup; diff --git a/Swiftfin/Views/UserProfileImage/UserProfileHeroImage.swift b/Swiftfin/Views/UserProfileImage/UserProfileHeroImage.swift deleted file mode 100644 index 8d6041afd..000000000 --- a/Swiftfin/Views/UserProfileImage/UserProfileHeroImage.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import Nuke -import SwiftUI - -struct UserProfileHeroImage: View { - - // MARK: - User Variables - - private let userId: String? - private let size: CGFloat - private let source: ImageSource - private let pipeline: ImagePipeline - - // MARK: - Initializer - - init( - userId: String?, - size: CGFloat = 150, - source: ImageSource, - pipeline: ImagePipeline = .Swiftfin.default - ) { - self.userId = userId - self.source = source - self.size = size - self.pipeline = pipeline - } - - // MARK: - Body - - var body: some View { - RedrawOnNotificationView( - .didChangeUserProfile, - filter: { - $0 == userId - } - ) { - ImageView(source) - .pipeline(pipeline) - .image { - $0.posterBorder(ratio: 1 / 2, of: \.width) - } - .placeholder { _ in - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .failure { - SystemImageContentView(systemName: "person.fill", ratio: 0.5) - } - .aspectRatio(1, contentMode: .fill) - .clipShape(Circle()) - .frame(width: size, height: size) - .shadow(radius: 5) - } - } -} From 04cfe96b55a47f9dc6749e8ae4299c3e9a441c91 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 28 Dec 2024 16:33:26 -0700 Subject: [PATCH 17/22] wip --- Shared/Components/ImageView.swift | 1 + .../BaseItemDto/BaseItemDto+Images.swift | 10 ++-- Shared/Extensions/Nuke/DataCache.swift | 60 ++++++++++++------- Shared/Extensions/String.swift | 18 ++++++ Shared/Extensions/URL.swift | 4 ++ Swiftfin/Components/PosterButton.swift | 10 +++- .../Components/EpisodeCard.swift | 2 +- .../MediaView/Components/MediaItem.swift | 4 +- .../Components/LibraryRow.swift | 9 ++- 9 files changed, 83 insertions(+), 35 deletions(-) diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index cdf7665d6..11eda88de 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -60,6 +60,7 @@ struct ImageView: View { } } .pipeline(pipeline) + .onDisappear(.lowerPriority) } else { failure() .eraseToAnyView() diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift index 80f958ad3..cef5fef00 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift @@ -58,7 +58,7 @@ extension BaseItemDto { maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesID ?? "", - force: true + requireTag: false ) } @@ -70,7 +70,7 @@ extension BaseItemDto { maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesID ?? "", - force: true + requireTag: false ) return ImageSource( @@ -86,16 +86,14 @@ extension BaseItemDto { maxWidth: CGFloat?, maxHeight: CGFloat?, itemID: String, - force: Bool = false + requireTag: Bool = true ) -> URL? { let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) let scaleHeight = maxHeight == nil ? nil : UIScreen.main.scale(maxHeight!) let tag = getImageTag(for: type) - if tag == nil && !force { - return nil - } + guard tag != nil || !requireTag else { return nil } // TODO: client passing for widget/shared group views? guard let client = Container.shared.currentUserSession()?.client else { return nil } diff --git a/Shared/Extensions/Nuke/DataCache.swift b/Shared/Extensions/Nuke/DataCache.swift index 143b2282f..75f1c6bc5 100644 --- a/Shared/Extensions/Nuke/DataCache.swift +++ b/Shared/Extensions/Nuke/DataCache.swift @@ -22,19 +22,34 @@ extension DataCache { extension DataCache.Swiftfin { static let `default`: DataCache? = { - let dataCache = try? DataCache(name: "org.jellyfin.swiftfin") { name in - URL(string: name)?.pathAndQuery() ?? name + + let dataCache = try? DataCache(name: "org.jellyfin.swiftfin/Images") { name in + + guard var components = name.url?.components else { return nil } + + var maxWidthValue: String? = nil + + if let maxWidth = components.queryItems?.first(where: { $0.name == "maxWidth" }) { + maxWidthValue = maxWidth.value + components.queryItems = components.queryItems?.filter { $0.name != "maxWidth" } + } + + guard let newURL = components.url, let urlSHA = newURL.absoluteString.sha1 else { return nil } + + if let maxWidthValue { + return urlSHA + "-\(maxWidthValue)" + } else { + return urlSHA + } } - dataCache?.sizeLimit = 1024 * 1024 * 500 // 500 MB + dataCache?.sizeLimit = 1024 * 1024 * 1000 // 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. + /// The `DataCache` used for server and user images that should be usable + /// without a consistent connection. static let branding: DataCache? = { guard let root = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil @@ -44,25 +59,30 @@ extension DataCache.Swiftfin { 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 { + guard let url = name.url else { return name } + + // Since multi-url servers is supported, + // key splashscreens with the server ID. + // + // Additional latency from Core Data round + // trip is acceptable. + if url.path.contains("Splashscreen") { - // TODO: potential issue where url ends with `/`, if - // not found, retry with `/` appended - let prefix = name.trimmingSuffix("/Branding/Splashscreen?") + // Account for hosting at a path + guard let prefixURL = url.absoluteString.trimmingSuffix("/Branding/Splashscreen?").url else { return nil } - // 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 } + // We can assume that the request + // is from the current server + let urlFilter: Where = Where(\.$currentURL == prefixURL) guard let server = try? SwiftfinStore.dataStack.fetchOne( From() - .where(\.$currentURL == prefixURL) - ) else { return name } + .where(urlFilter) + ) else { return nil } - return "\(server.id)-splashscreen" + return "\(server.id)-splashscreen".sha1 } else { - return URL(string: name)?.pathAndQuery() ?? name + // TODO: size differences... + return url.pathAndQuery()?.sha1 } } diff --git a/Shared/Extensions/String.swift b/Shared/Extensions/String.swift index 77813789c..a19d80791 100644 --- a/Shared/Extensions/String.swift +++ b/Shared/Extensions/String.swift @@ -7,6 +7,7 @@ // import Algorithms +import CryptoKit import Foundation import SwiftUI @@ -117,6 +118,23 @@ extension String { return s } + var sha1: String? { + guard let input = data(using: .utf8) else { return nil } + return Insecure.SHA1.hash(data: input) + .reduce(into: "") { partialResult, byte in + partialResult += String(format: "%02x", byte) + } + } + + var base64: String? { + guard let input = data(using: .utf8) else { return nil } + return input.base64EncodedString() + } + + var url: URL? { + URL(string: self) + } + // TODO: remove after iOS 15 support removed func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat { diff --git a/Shared/Extensions/URL.swift b/Shared/Extensions/URL.swift index 3f718732e..57ffb592f 100644 --- a/Shared/Extensions/URL.swift +++ b/Shared/Extensions/URL.swift @@ -80,4 +80,8 @@ extension URL { return -1 } } + + var components: URLComponents? { + URLComponents(url: self, resolvingAgainstBaseURL: false) + } } diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift index f282721c0..4d0842c97 100644 --- a/Swiftfin/Components/PosterButton.swift +++ b/Swiftfin/Components/PosterButton.swift @@ -13,10 +13,14 @@ import SwiftUI // TODO: expose `ImageView.image` modifier for image aspect fill/fit // TODO: allow `content` to trigger `onSelect`? // - not in button label to avoid context menu visual oddities -// TODO: get width/height for images from layout size? // TODO: why don't shadows work with failure image views? // - due to `Color`? +/// Retrieving images by exact pixel dimensions is a bit +/// intense for normal usage and eases cache usage and modifications. +private let landscapeMaxWidth: CGFloat = 200 +private let portraitMaxWidth: CGFloat = 200 + struct PosterButton: View { private var item: Item @@ -29,9 +33,9 @@ struct PosterButton: View { private func imageView(from item: Item) -> ImageView { switch type { case .landscape: - ImageView(item.landscapeImageSources(maxWidth: 500)) + ImageView(item.landscapeImageSources(maxWidth: landscapeMaxWidth)) case .portrait: - ImageView(item.portraitImageSources(maxWidth: 200)) + ImageView(item.portraitImageSources(maxWidth: portraitMaxWidth)) } } diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift index 3b0628598..c044dd733 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -59,7 +59,7 @@ extension SeriesEpisodeSelector { ZStack { Color.clear - ImageView(episode.imageSource(.primary, maxWidth: 500)) + ImageView(episode.imageSource(.primary, maxWidth: 250)) .failure { SystemImageContentView(systemName: episode.systemImage) } diff --git a/Swiftfin/Views/MediaView/Components/MediaItem.swift b/Swiftfin/Views/MediaView/Components/MediaItem.swift index a40b80f85..72ccfaeb7 100644 --- a/Swiftfin/Views/MediaView/Components/MediaItem.swift +++ b/Swiftfin/Views/MediaView/Components/MediaItem.swift @@ -55,9 +55,9 @@ extension MediaView { } if case let MediaViewModel.MediaType.collectionFolder(item) = mediaType { - self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + self.imageSources = [item.imageSource(.primary, maxWidth: 200)] } else if case let MediaViewModel.MediaType.liveTV(item) = mediaType { - self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + self.imageSources = [item.imageSource(.primary, maxWidth: 200)] } } } diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift index 8042568b9..00a2020bd 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift @@ -10,6 +10,9 @@ import Defaults import JellyfinAPI import SwiftUI +private let landscapeMaxWidth: CGFloat = 110 +private let portraitMaxWidth: CGFloat = 60 + extension PagingLibraryView { struct LibraryRow: View { @@ -21,9 +24,9 @@ extension PagingLibraryView { private func imageView(from element: Element) -> ImageView { switch posterType { case .landscape: - ImageView(element.landscapeImageSources(maxWidth: 110)) + ImageView(element.landscapeImageSources(maxWidth: landscapeMaxWidth)) case .portrait: - ImageView(element.portraitImageSources(maxWidth: 60)) + ImageView(element.portraitImageSources(maxWidth: portraitMaxWidth)) } } @@ -96,7 +99,7 @@ extension PagingLibraryView { } } .posterStyle(posterType) - .frame(width: posterType == .landscape ? 110 : 60) + .frame(width: posterType == .landscape ? landscapeMaxWidth : portraitMaxWidth) .posterShadow() .padding(.vertical, 8) } From e69e33b2474ac3d56ade34d958f28c21c39df9ce Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 28 Dec 2024 17:48:39 -0700 Subject: [PATCH 18/22] wip --- .../UserProfileHeroImage.swift | 19 ++---- .../UserProfileImage/UserProfileImage.swift | 63 ++++++++++--------- Shared/Extensions/Nuke/DataCache.swift | 43 ++++--------- Shared/Extensions/Nuke/ImagePipeline.swift | 53 +++++++++++++--- Shared/Extensions/URL.swift | 2 +- .../SwiftinStore+UserState.swift | 1 - .../MediaViewModel/MediaViewModel.swift | 2 +- .../UserProfileImageViewModel.swift | 34 ++++++++-- .../Components/UserGridButton.swift | 2 +- .../Components/PublicUserButton.swift | 2 +- Swiftfin/App/SwiftfinApp.swift | 2 +- Swiftfin/Components/SettingsBarButton.swift | 9 +-- .../ServerUserDetailsView.swift | 4 +- .../Components/ServerUsersRow.swift | 2 +- .../Components/UserGridButton.swift | 4 +- .../SelectUserView/Components/UserRow.swift | 4 +- .../Views/SelectUserView/SelectUserView.swift | 2 +- .../Components/UserProfileRow.swift | 2 +- .../UserProfileSettingsView.swift | 22 +++---- .../Components/PublicUserRow.swift | 5 +- 20 files changed, 154 insertions(+), 123 deletions(-) diff --git a/Shared/Components/UserProfileImage/UserProfileHeroImage.swift b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift index 52c4fb9fc..6678b0cf2 100644 --- a/Shared/Components/UserProfileImage/UserProfileHeroImage.swift +++ b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift @@ -45,7 +45,7 @@ struct UserProfileHeroImage: View { init( user: UserDto, source: ImageSource, - pipeline: ImagePipeline = .Swiftfin.default, + pipeline: ImagePipeline = .Swiftfin.posters, onUpdate: @escaping () -> Void, onDelete: @escaping () -> Void ) { @@ -65,19 +65,12 @@ struct UserProfileHeroImage: View { isPresentingOptions = true } label: { ZStack(alignment: .bottomTrailing) { - // `.aspectRatio(contentMode: .fill)` on `imageView` alone - // causes a crash on some iOS versions - ZStack { - UserProfileImage( - userId: user.id, - source: source, - pipeline: userSession?.user.id == user.id ? .Swiftfin.branding : .Swiftfin.default - ) - } - .aspectRatio(1, contentMode: .fill) - .clipShape(.circle) + UserProfileImage( + userID: user.id, + source: source, + pipeline: userSession?.user.id == user.id ? .Swiftfin.local : .Swiftfin.posters + ) .frame(width: 150, height: 150) - .shadow(radius: 5) Image(systemName: "pencil.circle.fill") .resizable() diff --git a/Shared/Components/UserProfileImage/UserProfileImage.swift b/Shared/Components/UserProfileImage/UserProfileImage.swift index 8ffe5f4bc..aab0aa755 100644 --- a/Shared/Components/UserProfileImage/UserProfileImage.swift +++ b/Shared/Components/UserProfileImage/UserProfileImage.swift @@ -12,7 +12,7 @@ import JellyfinAPI import Nuke import SwiftUI -struct UserProfileImage: View { +struct UserProfileImage: View { // MARK: - Inject Logger @@ -21,24 +21,10 @@ struct UserProfileImage: View { // MARK: - User Variables - private let userId: String? + private let userID: String? private let source: ImageSource private let pipeline: ImagePipeline - private let placeholder: any View - - // MARK: - Initializer - - init( - userId: String?, - source: ImageSource, - pipeline: ImagePipeline = .Swiftfin.default, - placeholder: any View = SystemImageContentView(systemName: "person.fill", ratio: 0.5) - ) { - self.userId = userId - self.source = source - self.pipeline = pipeline - self.placeholder = placeholder - } + private let placeholder: Placeholder // MARK: - Body @@ -46,7 +32,7 @@ struct UserProfileImage: View { RedrawOnNotificationView( .didChangeUserProfile, filter: { - $0 == userId + $0 == userID } ) { ImageView(source) @@ -65,21 +51,36 @@ struct UserProfileImage: View { .clipShape(Circle()) .shadow(radius: 5) } - .onNotification(.didChangeUserProfile) { notificationUser in - if notificationUser == userId { - let imageURL = source.url + } +} - guard let imageURL else { - logger.info("No user profile image URL found") - return - } +// MARK: - Initializer - let request = ImageRequest(url: imageURL) +extension UserProfileImage { - pipeline.cache[request] = nil - pipeline.configuration.dataCache?.removeData(for: imageURL.absoluteString) - logger.info("Image removed from cache: \(imageURL.absoluteString)") - } - } + init( + userID: String?, + source: ImageSource, + pipeline: ImagePipeline = .Swiftfin.posters, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.userID = userID + self.source = source + self.pipeline = pipeline + self.placeholder = placeholder() + } +} + +extension UserProfileImage where Placeholder == SystemImageContentView { + + init( + userID: String?, + source: ImageSource, + pipeline: ImagePipeline = .Swiftfin.posters + ) { + self.userID = userID + self.source = source + self.pipeline = pipeline + self.placeholder = SystemImageContentView(systemName: "person.fill", ratio: 0.5) } } diff --git a/Shared/Extensions/Nuke/DataCache.swift b/Shared/Extensions/Nuke/DataCache.swift index 75f1c6bc5..1863331dd 100644 --- a/Shared/Extensions/Nuke/DataCache.swift +++ b/Shared/Extensions/Nuke/DataCache.swift @@ -21,26 +21,11 @@ extension DataCache { extension DataCache.Swiftfin { - static let `default`: DataCache? = { + static let posters: DataCache? = { - let dataCache = try? DataCache(name: "org.jellyfin.swiftfin/Images") { name in - - guard var components = name.url?.components else { return nil } - - var maxWidthValue: String? = nil - - if let maxWidth = components.queryItems?.first(where: { $0.name == "maxWidth" }) { - maxWidthValue = maxWidth.value - components.queryItems = components.queryItems?.filter { $0.name != "maxWidth" } - } - - guard let newURL = components.url, let urlSHA = newURL.absoluteString.sha1 else { return nil } - - if let maxWidthValue { - return urlSHA + "-\(maxWidthValue)" - } else { - return urlSHA - } + let dataCache = try? DataCache(name: "org.jellyfin.swiftfin/Posters") { name in + guard let url = name.url else { return nil } + return ImagePipeline.cacheKey(for: url) } dataCache?.sizeLimit = 1024 * 1024 * 1000 // 500 MB @@ -49,30 +34,27 @@ extension DataCache.Swiftfin { }() /// The `DataCache` used for server and user images that should be usable - /// without a consistent connection. - static let branding: DataCache? = { + /// without an active connection. + static let local: 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 path = root.appendingPathComponent("Caches/org.jellyfin.swiftfin.local", isDirectory: true) let dataCache = try? DataCache(path: path) { name in - guard let url = name.url else { return name } + guard let url = name.url else { return nil } - // Since multi-url servers is supported, - // key splashscreens with the server ID. + // Since multi-url servers are supported, key splashscreens with the server ID. // - // Additional latency from Core Data round - // trip is acceptable. + // Additional latency from Core Data fetch is acceptable. if url.path.contains("Splashscreen") { // Account for hosting at a path guard let prefixURL = url.absoluteString.trimmingSuffix("/Branding/Splashscreen?").url else { return nil } - // We can assume that the request - // is from the current server + // We can assume that the request is from the current server let urlFilter: Where = Where(\.$currentURL == prefixURL) guard let server = try? SwiftfinStore.dataStack.fetchOne( From() @@ -81,8 +63,7 @@ extension DataCache.Swiftfin { return "\(server.id)-splashscreen".sha1 } else { - // TODO: size differences... - return url.pathAndQuery()?.sha1 + return ImagePipeline.cacheKey(for: url) } } diff --git a/Shared/Extensions/Nuke/ImagePipeline.swift b/Shared/Extensions/Nuke/ImagePipeline.swift index 836068044..dfd88ddb2 100644 --- a/Shared/Extensions/Nuke/ImagePipeline.swift +++ b/Shared/Extensions/Nuke/ImagePipeline.swift @@ -10,20 +10,59 @@ import Foundation import Nuke extension ImagePipeline { + enum Swiftfin {} + + static func cacheKey(for url: URL) -> String? { + guard var components = url.components else { return nil } + + var maxWidthValue: String? + + if let maxWidth = components.queryItems?.first(where: { $0.name == "maxWidth" }) { + maxWidthValue = maxWidth.value + components.queryItems = components.queryItems?.filter { $0.name != "maxWidth" } + } + + guard let newURL = components.url, let urlSHA = newURL.pathAndQuery?.sha1 else { return nil } + + if let maxWidthValue { + return urlSHA + "-\(maxWidthValue)" + } else { + return urlSHA + } + } + + func removeItem(for url: URL) { + let request = ImageRequest(url: url) + cache.removeCachedImage(for: request) + cache.removeCachedData(for: request) + + guard let dataCacheKey = Self.cacheKey(for: url) else { return } + configuration.dataCache?.removeData(for: dataCacheKey) + } } 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 default `ImagePipeline` to use for images that are typically posters + /// or server user images that should be presentable with an active connection. + static let posters: ImagePipeline = ImagePipeline { + $0.dataCache = DataCache.Swiftfin.posters } /// 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 + /// without a connection, likes local user profile images and server splashscreens. + static let local: ImagePipeline = ImagePipeline { + $0.dataCache = DataCache.Swiftfin.local + } +} + +final class SwiftfinImagePipelineDelegate: ImagePipelineDelegate { + + func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? { + guard let url = request.url else { return nil } + let k = ImagePipeline.cacheKey(for: url) + print(url, k) + return k } } diff --git a/Shared/Extensions/URL.swift b/Shared/Extensions/URL.swift index 57ffb592f..8802c353c 100644 --- a/Shared/Extensions/URL.swift +++ b/Shared/Extensions/URL.swift @@ -68,7 +68,7 @@ extension URL { } // doesn't have `?` but doesn't matter - func pathAndQuery() -> String? { + var pathAndQuery: String? { path + (query ?? "") } diff --git a/Shared/SwiftfinStore/SwiftinStore+UserState.swift b/Shared/SwiftfinStore/SwiftinStore+UserState.swift index 91d53211a..fd328264e 100644 --- a/Shared/SwiftfinStore/SwiftinStore+UserState.swift +++ b/Shared/SwiftfinStore/SwiftinStore+UserState.swift @@ -152,7 +152,6 @@ extension UserState { let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) let parameters = Paths.GetUserImageParameters( - tag: data.primaryImageTag, maxWidth: scaleWidth ) let request = Paths.getUserImage( diff --git a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift index 6b9f188f6..cf2046941 100644 --- a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift @@ -154,6 +154,6 @@ final class MediaViewModel: ViewModel, Stateful { let response = try await userSession.client.send(request) return (response.value.items ?? []) - .map { $0.imageSource(.backdrop, maxWidth: 500) } + .map { $0.imageSource(.backdrop, maxWidth: 200) } } } diff --git a/Shared/ViewModels/UserProfileImageViewModel.swift b/Shared/ViewModels/UserProfileImageViewModel.swift index 17bed55c9..16fdeafbd 100644 --- a/Shared/ViewModels/UserProfileImageViewModel.swift +++ b/Shared/ViewModels/UserProfileImageViewModel.swift @@ -49,8 +49,7 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { // MARK: - Published Values - @Published - var userID: String + let user: UserDto // MARK: - Task Variables @@ -59,8 +58,8 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { // MARK: - Initializer - init(userID: String) { - self.userID = userID + init(user: UserDto) { + self.user = user } // MARK: - Respond to Action @@ -128,6 +127,9 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { // MARK: - Upload Image private func upload(_ image: UIImage) async throws { + + guard let userID = user.id else { return } + let contentType: String let imageData: Data @@ -151,6 +153,8 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { let _ = try await userSession.client.send(request) + sweepProfileImageCache() + await MainActor.run { Notifications[.didChangeUserProfile].post(userID) } @@ -159,14 +163,36 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { // MARK: - Delete Image private func delete() async throws { + + guard let userID = user.id else { return } + let request = Paths.deleteUserImage( userID: userID, imageType: "Primary" ) let _ = try await userSession.client.send(request) + sweepProfileImageCache() + await MainActor.run { Notifications[.didChangeUserProfile].post(userID) } } + + private func sweepProfileImageCache() { + if let userImageURL = user.profileImageSource(client: userSession.client, maxWidth: 60).url { + ImagePipeline.Swiftfin.local.removeItem(for: userImageURL) + ImagePipeline.Swiftfin.posters.removeItem(for: userImageURL) + } + + if let userImageURL = user.profileImageSource(client: userSession.client, maxWidth: 120).url { + ImagePipeline.Swiftfin.local.removeItem(for: userImageURL) + ImagePipeline.Swiftfin.posters.removeItem(for: userImageURL) + } + + if let userImageURL = user.profileImageSource(client: userSession.client, maxWidth: 150).url { + ImagePipeline.Swiftfin.local.removeItem(for: userImageURL) + ImagePipeline.Swiftfin.posters.removeItem(for: userImageURL) + } + } } diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift index 0eb3f2f70..af8a5e8dd 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift @@ -70,7 +70,7 @@ extension SelectUserView { Color.clear UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: server.client, maxWidth: 120 diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift index 7c685e14e..7e06660c1 100644 --- a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift +++ b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift @@ -44,7 +44,7 @@ extension UserSignInView { Color.clear UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: client, maxWidth: 120 diff --git a/Swiftfin/App/SwiftfinApp.swift b/Swiftfin/App/SwiftfinApp.swift index 66a0e5fd4..1b61eade1 100644 --- a/Swiftfin/App/SwiftfinApp.swift +++ b/Swiftfin/App/SwiftfinApp.swift @@ -54,7 +54,7 @@ struct SwiftfinApp: App { return mimeType.contains("svg") ? ImageDecoders.Empty() : nil } - ImagePipeline.shared = .Swiftfin.default + ImagePipeline.shared = .Swiftfin.posters // UIKit diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift index fefbc64ec..5aba32614 100644 --- a/Swiftfin/Components/SettingsBarButton.swift +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -32,14 +32,15 @@ struct SettingsBarButton: View { Color.clear UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: server.client, maxWidth: 120 ), - pipeline: .Swiftfin.branding, - placeholder: Color.clear - ) + pipeline: .Swiftfin.local + ) { + Color.clear + } } } } diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 2f2260309..6298722f8 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -25,7 +25,7 @@ struct ServerUserDetailsView: View { @StateObject private var viewModel: ServerUserAdminViewModel - @ObservedObject + @StateObject private var profileViewModel: UserProfileImageViewModel // MARK: - Dialog State @@ -44,7 +44,7 @@ struct ServerUserDetailsView: View { init(user: UserDto) { self._viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user)) - self.profileViewModel = .init(userID: user.id!) + self._profileViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: user)) self.username = user.name ?? "" } diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift index 9feee8844..84f5d4208 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -76,7 +76,7 @@ extension ServerUsersView { private var userImage: some View { ZStack { UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: userSession!.client, maxWidth: 60 diff --git a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift index fdcdcfa08..e3c8d3cc0 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift @@ -59,12 +59,12 @@ extension SelectUserView { Color.clear UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: server.client, maxWidth: 120 ), - pipeline: .Swiftfin.branding + pipeline: .Swiftfin.local ) } .overlay { diff --git a/Swiftfin/Views/SelectUserView/Components/UserRow.swift b/Swiftfin/Views/SelectUserView/Components/UserRow.swift index fb82f3b18..4752108d7 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserRow.swift @@ -75,12 +75,12 @@ extension SelectUserView { Color.clear UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: server.client, maxWidth: 120 ), - pipeline: .Swiftfin.branding + pipeline: .Swiftfin.local ) if isEditing { diff --git a/Swiftfin/Views/SelectUserView/SelectUserView.swift b/Swiftfin/Views/SelectUserView/SelectUserView.swift index 798f6abcd..bfdbeb57b 100644 --- a/Swiftfin/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin/Views/SelectUserView/SelectUserView.swift @@ -447,7 +447,7 @@ struct SelectUserView: View { Color.clear ImageView(splashScreenImageSources) - .pipeline(.Swiftfin.branding) + .pipeline(.Swiftfin.local) .aspectRatio(contentMode: .fill) .id(splashScreenImageSources) .transition(.opacity) diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index f57e1cfce..a3335d2f2 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -31,7 +31,7 @@ extension SettingsView { // causes a crash on some iOS versions ZStack { UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: userSession.client, maxWidth: 120 diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index 1d9b9e8fb..87b1c5889 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -13,42 +13,34 @@ import SwiftUI struct UserProfileSettingsView: View { - @Default(.accentColor) - private var accentColor - @EnvironmentObject private var router: SettingsCoordinator.Router @ObservedObject - var viewModel: SettingsViewModel - @ObservedObject - var profileViewModel: UserProfileImageViewModel + private var viewModel: SettingsViewModel + @StateObject + private var profileImageViewModel: UserProfileImageViewModel @State private var isPresentingConfirmReset: Bool = false - @State - private var isPresentingProfileImageOptions: Bool = false init(viewModel: SettingsViewModel) { self.viewModel = viewModel - self.profileViewModel = .init(userID: viewModel.userSession.user.id) + self._profileImageViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: viewModel.userSession.user.data)) } var body: some View { List { UserProfileHeroImage( - user: UserDto( - id: viewModel.userSession.user.id, - name: viewModel.userSession.user.username - ), + user: profileImageViewModel.user, source: viewModel.userSession.user.profileImageSource( client: viewModel.userSession.client, maxWidth: 150 ) ) { - router.route(to: \.photoPicker, profileViewModel) + router.route(to: \.photoPicker, profileImageViewModel) } onDelete: { - profileViewModel.send(.delete) + profileImageViewModel.send(.delete) } Section { diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift index a74bb3795..6a22cbd86 100644 --- a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift +++ b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift @@ -39,12 +39,11 @@ extension UserSignInView { Color.clear UserProfileImage( - userId: user.id, + userID: user.id, source: user.profileImageSource( client: client, maxWidth: 120 - ), - pipeline: .Swiftfin.default + ) ) } .frame(width: 50, height: 50) From 64ecc09d516594c3baa884cc77d39cf9c11253d3 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 28 Dec 2024 17:50:56 -0700 Subject: [PATCH 19/22] Update ImagePipeline.swift --- Shared/Extensions/Nuke/ImagePipeline.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Shared/Extensions/Nuke/ImagePipeline.swift b/Shared/Extensions/Nuke/ImagePipeline.swift index dfd88ddb2..fb4faff97 100644 --- a/Shared/Extensions/Nuke/ImagePipeline.swift +++ b/Shared/Extensions/Nuke/ImagePipeline.swift @@ -46,13 +46,13 @@ extension ImagePipeline.Swiftfin { /// The default `ImagePipeline` to use for images that are typically posters /// or server user images that should be presentable with an active connection. - static let posters: ImagePipeline = ImagePipeline { + static let posters: ImagePipeline = ImagePipeline(delegate: SwiftfinImagePipelineDelegate()) { $0.dataCache = DataCache.Swiftfin.posters } /// The `ImagePipeline` used for images that should have longer lifetimes and usable /// without a connection, likes local user profile images and server splashscreens. - static let local: ImagePipeline = ImagePipeline { + static let local: ImagePipeline = ImagePipeline(delegate: SwiftfinImagePipelineDelegate()) { $0.dataCache = DataCache.Swiftfin.local } } @@ -61,8 +61,6 @@ final class SwiftfinImagePipelineDelegate: ImagePipelineDelegate { func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? { guard let url = request.url else { return nil } - let k = ImagePipeline.cacheKey(for: url) - print(url, k) - return k + return ImagePipeline.cacheKey(for: url) } } From 77f8e7e7377fa7f6b67c57d7b86cbc20da30310a Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 28 Dec 2024 18:27:58 -0700 Subject: [PATCH 20/22] fix tvOS build issue and update comment to be more accurate --- Shared/Extensions/Nuke/DataCache.swift | 2 +- Swiftfin tvOS/App/SwiftfinApp.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Shared/Extensions/Nuke/DataCache.swift b/Shared/Extensions/Nuke/DataCache.swift index 1863331dd..c3eff914f 100644 --- a/Shared/Extensions/Nuke/DataCache.swift +++ b/Shared/Extensions/Nuke/DataCache.swift @@ -28,7 +28,7 @@ extension DataCache.Swiftfin { return ImagePipeline.cacheKey(for: url) } - dataCache?.sizeLimit = 1024 * 1024 * 1000 // 500 MB + dataCache?.sizeLimit = 1024 * 1024 * 1000 // 1000 MB return dataCache }() diff --git a/Swiftfin tvOS/App/SwiftfinApp.swift b/Swiftfin tvOS/App/SwiftfinApp.swift index 26eb28c2d..7683d5521 100644 --- a/Swiftfin tvOS/App/SwiftfinApp.swift +++ b/Swiftfin tvOS/App/SwiftfinApp.swift @@ -47,7 +47,7 @@ struct SwiftfinApp: App { return mimeType.contains("svg") ? ImageDecoders.Empty() : nil } - ImagePipeline.shared = .Swiftfin.default + ImagePipeline.shared = .Swiftfin.posters // UIKit From 3579d27578285a97c64d133f3dee17dfbd2a78ba Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 28 Dec 2024 22:22:44 -0700 Subject: [PATCH 21/22] clean up --- .../UserProfileImage/UserProfileHeroImage.swift | 15 ++++----------- Shared/Strings/Strings.swift | 2 -- Translations/en.lproj/Localizable.strings | 3 --- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/Shared/Components/UserProfileImage/UserProfileHeroImage.swift b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift index 6678b0cf2..01352720f 100644 --- a/Shared/Components/UserProfileImage/UserProfileHeroImage.swift +++ b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift @@ -89,20 +89,13 @@ struct UserProfileHeroImage: View { .listRowBackground(Color.clear) } .confirmationDialog( - """ - \(L10n.profileImage) - \(L10n.viewsMayRequireRestart) - """, + L10n.profileImage, isPresented: $isPresentingOptions, titleVisibility: .visible ) { - Text(L10n.viewsMayRequireRestart) - Button(L10n.selectImage) { - onUpdate() - } - Button(L10n.delete, role: .destructive) { - onDelete() - } + Button(L10n.selectImage, action: onUpdate) + + Button(L10n.delete, role: .destructive, action: onDelete) } } } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 862150a06..c209fa059 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -1348,8 +1348,6 @@ internal enum L10n { internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") /// Video transcoding 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.") /// Weekday internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday") /// Weekend diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 4ca18cb6f..e7a28b3a2 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1930,9 +1930,6 @@ /// Video transcoding "videoTranscoding" = "Video transcoding"; -/// Some views may need an app restart to update. -"viewsMayRequireRestart" = "Some views may need an app restart to update."; - /// Weekday "weekday" = "Weekday"; From b71c6e8b579823c2dd13d38ba67fefe8986f0d61 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 28 Dec 2024 22:28:23 -0700 Subject: [PATCH 22/22] fix string --- Shared/Strings/Strings.swift | 2 ++ Translations/en.lproj/Localizable.strings | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index c209fa059..862150a06 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -1348,6 +1348,8 @@ internal enum L10n { internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") /// Video transcoding 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.") /// Weekday internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday") /// Weekend diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index e7a28b3a2..4ca18cb6f 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1930,6 +1930,9 @@ /// Video transcoding "videoTranscoding" = "Video transcoding"; +/// Some views may need an app restart to update. +"viewsMayRequireRestart" = "Some views may need an app restart to update."; + /// Weekday "weekday" = "Weekday";