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/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/Components/UserProfileImage/UserProfileHeroImage.swift b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift new file mode 100644 index 000000000..01352720f --- /dev/null +++ b/Shared/Components/UserProfileImage/UserProfileHeroImage.swift @@ -0,0 +1,101 @@ +// +// 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 UserProfileHeroImage: 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.posters, + 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) { + UserProfileImage( + userID: user.id, + source: source, + pipeline: userSession?.user.id == user.id ? .Swiftfin.local : .Swiftfin.posters + ) + .frame(width: 150, height: 150) + + 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, + isPresented: $isPresentingOptions, + titleVisibility: .visible + ) { + Button(L10n.selectImage, action: onUpdate) + + Button(L10n.delete, role: .destructive, action: onDelete) + } + } +} diff --git a/Shared/Components/UserProfileImage/UserProfileImage.swift b/Shared/Components/UserProfileImage/UserProfileImage.swift new file mode 100644 index 000000000..aab0aa755 --- /dev/null +++ b/Shared/Components/UserProfileImage/UserProfileImage.swift @@ -0,0 +1,86 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import 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? + private let source: ImageSource + private let pipeline: ImagePipeline + private let 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) + } + } +} + +// MARK: - Initializer + +extension UserProfileImage { + + 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/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 5542d587c..3cbe0a51a 100644 --- a/Shared/Coordinators/UserProfileImageCoordinator.swift +++ b/Shared/Coordinators/UserProfileImageCoordinator.swift @@ -11,19 +11,34 @@ 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 @@ -32,7 +47,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/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..c3eff914f 100644 --- a/Shared/Extensions/Nuke/DataCache.swift +++ b/Shared/Extensions/Nuke/DataCache.swift @@ -21,48 +21,49 @@ extension DataCache { extension DataCache.Swiftfin { - static let `default`: DataCache? = { - let dataCache = try? DataCache(name: "org.jellyfin.swiftfin") { name in - URL(string: name)?.pathAndQuery() ?? name + static let posters: DataCache? = { + + 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 * 500 // 500 MB + dataCache?.sizeLimit = 1024 * 1024 * 1000 // 1000 MB return dataCache }() - /// The `DataCache` used for images that should have longer lifetimes, usable without a - /// connection, and not affected by other caching size limits. - /// - /// Current 150 MB is more than necessary. - static let branding: DataCache? = { + /// The `DataCache` used for server and user images that should be usable + /// 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 - // this adds some latency, but fine since - // this DataCache is special - if name.range(of: "Splashscreen") != nil { + guard let url = name.url else { return nil } + + // Since multi-url servers are supported, key splashscreens with the server ID. + // + // Additional latency from Core Data fetch 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 + return ImagePipeline.cacheKey(for: url) } } diff --git a/Shared/Extensions/Nuke/ImagePipeline.swift b/Shared/Extensions/Nuke/ImagePipeline.swift index 836068044..fb4faff97 100644 --- a/Shared/Extensions/Nuke/ImagePipeline.swift +++ b/Shared/Extensions/Nuke/ImagePipeline.swift @@ -10,20 +10,57 @@ 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(delegate: SwiftfinImagePipelineDelegate()) { + $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(delegate: SwiftfinImagePipelineDelegate()) { + $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 } + return ImagePipeline.cacheKey(for: url) } } 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..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 ?? "") } @@ -80,4 +80,8 @@ extension URL { return -1 } } + + var components: URLComponents? { + URLComponents(url: self, resolvingAgainstBaseURL: false) + } } diff --git a/Shared/Services/Notifications.swift b/Shared/Services/Notifications.swift index ce726b23e..003287fec 100644 --- a/Shared/Services/Notifications.swift +++ b/Shared/Services/Notifications.swift @@ -150,8 +150,9 @@ extension Notifications.Key { // MARK: - User - static var didChangeUserProfileImage: Key { - Key("didChangeUserProfileImage") + /// - Payload: The ID of the user whose Profile Image changed. + static var didChangeUserProfile: Key { + Key("didChangeUserProfile") } static var didAddServerUser: Key { diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index f64386563..862150a06 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -906,6 +906,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 @@ -998,6 +1000,12 @@ 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 Settings + internal static let resetSettings = L10n.tr("Localizable", "resetSettings", fallback: "Reset Settings") + /// Reset Swiftfin user settings + internal static let resetSettingsDescription = L10n.tr("Localizable", "resetSettingsDescription", fallback: "Reset Swiftfin user settings") + /// 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") /// Restart Server @@ -1056,6 +1064,8 @@ 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 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 @@ -1338,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/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/AdminDashboard/ServerUserAdminViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift index f3eba6f18..420db69e8 100644 --- a/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift @@ -24,7 +24,7 @@ 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) @@ -68,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 @@ -81,7 +93,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl case .cancel: return .initial - case .loadDetails: + case .refresh: userTaskCancellable?.cancel() userTaskCancellable = Task { @@ -280,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..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 @@ -24,6 +25,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) @@ -49,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 @@ -63,10 +67,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 +189,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 { @@ -154,7 +214,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { .sorted(using: \.name) await MainActor.run { - self.users = newUsers + self.users = IdentifiedArray(uniqueElements: newUsers) } } @@ -179,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 ?? "") }) } } @@ -197,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 ?? "" }) } } } 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/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 96295ee30..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 { @@ -62,25 +61,6 @@ final class SettingsViewModel: ViewModel { } } - func deleteCurrentUserProfileImage() { - Task { - let request = Paths.deleteUserImage( - userID: userSession.user.id, - imageType: "Primary" - ) - let _ = try await userSession.client.send(request) - - let currentUserRequest = Paths.getCurrentUser - let response = try await userSession.client.send(currentUserRequest) - - await MainActor.run { - userSession.user.data = response.value - - Notifications[.didChangeUserProfileImage].post() - } - } - } - func select(icon: any AppIcon) { let previousAppIcon = currentAppIcon currentAppIcon = icon diff --git a/Shared/ViewModels/UserProfileImageViewModel.swift b/Shared/ViewModels/UserProfileImageViewModel.swift index 5e3cdf50f..16fdeafbd 100644 --- a/Shared/ViewModels/UserProfileImageViewModel.swift +++ b/Shared/ViewModels/UserProfileImageViewModel.swift @@ -14,51 +14,77 @@ import UIKit class UserProfileImageViewModel: ViewModel, Eventful, Stateful { + // MARK: - Action + enum Action: Equatable { case cancel + 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 + + let user: UserDto + + // MARK: - Task Variables private var eventSubject: PassthroughSubject = .init() private var uploadCancellable: AnyCancellable? + // MARK: - Initializer + + init(user: UserDto) { + self.user = user + } + + // MARK: - Respond to Action + func respond(to action: Action) -> State { switch action { case .cancel: uploadCancellable?.cancel() - return .initial - case let .upload(image): + case let .upload(image): uploadCancellable = Task { do { - try await upload(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))) @@ -68,11 +94,41 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { } .asAnyCancellable() - return .uploading + 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))) + self.state = .initial + } + } + } + .asAnyCancellable() + + return state } } - private func upload(image: UIImage) async throws { + // MARK: - Upload Image + + private func upload(_ image: UIImage) async throws { + + guard let userID = user.id else { return } let contentType: String let imageData: Data @@ -89,7 +145,7 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { } var request = Paths.postUserImage( - userID: userSession.user.id, + userID: userID, imageType: "Primary", imageData ) @@ -97,13 +153,46 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful { let _ = try await userSession.client.send(request) - let currentUserRequest = Paths.getCurrentUser - let response = try await userSession.client.send(currentUserRequest) + sweepProfileImageCache() + + await MainActor.run { + Notifications[.didChangeUserProfile].post(userID) + } + } + + // 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 { - userSession.user.data = response.value + 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) + } - Notifications[.didChangeUserProfileImage].post() + 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/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 diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift index ead41f4cb..af8a5e8dd 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 - } + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ) + ) } .aspectRatio(1, contentMode: .fill) } diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserButton.swift index f7ffbbf32..7e06660c1 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.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 288ca5416..38ad6ae09 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -98,6 +98,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 /* 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 */; }; @@ -138,6 +139,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 /* 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,6 +1243,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 /* 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 = ""; }; @@ -1270,6 +1275,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 /* UserProfileHeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileHeroImage.swift; sourceTree = ""; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E75B34A2D164AC100D16531 /* PurgeUnusedStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeUnusedStrings.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; @@ -2462,6 +2468,15 @@ path = ActiveSessionDetailView; sourceTree = ""; }; + 4E7315722D14752400EA2A95 /* UserProfileImage */ = { + isa = PBXGroup; + children = ( + 4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */, + 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */, + ); + path = UserProfileImage; + sourceTree = ""; + }; 4E75B34D2D16583900D16531 /* Translations */ = { isa = PBXGroup; children = ( @@ -3831,6 +3846,7 @@ E10B1EAF2BD9769500A92EAF /* SelectUserView */, E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, + 4E49DEDF2CE55F7F00352DCD /* UserProfileImagePicker */, E1171A1A28A2215800FA1AF5 /* UserSignInView */, E193D5452719418B00900D82 /* VideoPlayer */, ); @@ -3901,7 +3917,6 @@ isa = PBXGroup; children = ( 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, - 4E49DEDF2CE55F7F00352DCD /* UserProfileImagePicker */, E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, ); @@ -4417,6 +4432,7 @@ E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */, E1A1528928FD22F600600579 /* TextPairView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */, + 4E7315722D14752400EA2A95 /* UserProfileImage */, E1B5784028F8AFCB00D42911 /* WrappedView.swift */, ); path = Components; @@ -5158,6 +5174,7 @@ E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E10B1ED12BD9AFF200A92EAF /* V2UserModel.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 */, @@ -5359,6 +5376,7 @@ E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, 4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */, 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */, + 4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, @@ -5788,6 +5806,7 @@ 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, + 4E5508732D13AFED002A5345 /* UserProfileImage.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, @@ -5802,6 +5821,7 @@ E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, 4EFE0C7E2D0156A900D4834D /* PersonKind.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/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/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/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift index 08f1aef65..5aba32614 100644 --- a/Swiftfin/Components/SettingsBarButton.swift +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -31,29 +31,17 @@ struct SettingsBarButton: View { ZStack { Color.clear - RedrawOnNotificationView(.didChangeUserProfileImage) { - ImageView(user.profileImageSource( + UserProfileImage( + 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.local + ) { + Color.clear } } - .aspectRatio(contentMode: .fill) - .clipShape(.circle) } } .accessibilityLabel(L10n.settings) 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 b673d6db9..6298722f8 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -11,41 +11,82 @@ import JellyfinAPI import SwiftUI struct ServerUserDetailsView: View { - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router + + // MARK: - Current Date @CurrentDate private var currentDate: Date + // MARK: - State, Observed, & Environment Objects + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + @StateObject private var viewModel: ServerUserAdminViewModel + @StateObject + private var profileViewModel: UserProfileImageViewModel + + // 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._profileViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: user)) + self.username = user.name ?? "" } // MARK: - Body var body: some View { List { - // TODO: Replace with Update Profile Picture & Username - AdminDashboardView.UserSection( + UserProfileHeroImage( user: viewModel.user, - lastActivityDate: viewModel.user.lastActivityDate - ) + source: viewModel.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 150 + ) + ) { + router.route(to: \.userPhotoPicker, profileViewModel) + } onDelete: { + profileViewModel.send(.delete) + } Section { + 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 + } + } + } if let userId = viewModel.user.id { ChevronButton(L10n.password) .onSelect { router.route(to: \.resetUserPassword, userId) } } - } - - Section(L10n.advanced) { ChevronButton(L10n.permissions) .onSelect { router.route(to: \.userPermissions, viewModel) @@ -77,7 +118,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) @@ -90,7 +131,17 @@ struct ServerUserDetailsView: View { } .navigationTitle(L10n.user) .onAppear { - viewModel.send(.loadDetails) + viewModel.send(.refresh) + } + .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/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift index fe006974b..84f5d4208 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -75,24 +75,20 @@ 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) + UserProfileImage( + 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/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) } diff --git a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift index 311025c09..e3c8d3cc0 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 - } + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.local + ) } - .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..4752108d7 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 - } + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.local + ) if isEditing { Color.black 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 7a04a3f8e..a3335d2f2 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -20,20 +20,6 @@ extension SettingsView { private let user: UserDto private let action: (() -> Void)? - @ViewBuilder - private var imageView: some View { - RedrawOnNotificationView(.didChangeUserProfileImage) { - 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 } @@ -44,10 +30,14 @@ extension SettingsView { // `.aspectRatio(contentMode: .fill)` on `imageView` alone // causes a crash on some iOS versions ZStack { - imageView + UserProfileImage( + 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 038b6c83d..87b1c5889 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -8,79 +8,39 @@ import Defaults import Factory +import JellyfinAPI import SwiftUI struct UserProfileSettingsView: View { - @Default(.accentColor) - private var accentColor - @EnvironmentObject private var router: SettingsCoordinator.Router @ObservedObject - var viewModel: SettingsViewModel + private var viewModel: SettingsViewModel + @StateObject + private var profileImageViewModel: UserProfileImageViewModel @State private var isPresentingConfirmReset: Bool = false - @State - private var isPresentingProfileImageOptions: Bool = false - @ViewBuilder - private var imageView: some View { - RedrawOnNotificationView(.didChangeUserProfileImage) { - ImageView( - 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) - } - } + init(viewModel: SettingsViewModel) { + self.viewModel = viewModel + self._profileImageViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: viewModel.userSession.user.data)) } 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) + UserProfileHeroImage( + user: profileImageViewModel.user, + source: viewModel.userSession.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 150 + ) + ) { + router.route(to: \.photoPicker, profileImageViewModel) + } onDelete: { + profileImageViewModel.send(.delete) } Section { @@ -105,15 +65,19 @@ 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) } } - .alert("Reset Settings", isPresented: $isPresentingConfirmReset) { + .confirmationDialog( + L10n.resetSettings, + isPresented: $isPresentingConfirmReset, + titleVisibility: .visible + ) { Button(L10n.reset, role: .destructive) { do { try viewModel.userSession.user.deleteSettings() @@ -122,21 +86,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() - } + Text(L10n.resetSettingsMessage) } } } diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift b/Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift similarity index 92% rename from Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/PhotoPicker.swift rename to Swiftfin/Views/UserProfileImagePicker/Components/PhotoPicker.swift index f497d8b54..2f42dc85f 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/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/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift b/Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift similarity index 96% rename from Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/Components/SquareImageCropView.swift rename to Swiftfin/Views/UserProfileImagePicker/Components/SquareImageCropView.swift index 57726e1c0..709fc5a35 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/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 @@ -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/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift similarity index 81% rename from Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileImagePicker/UserProfileImagePicker.swift rename to Swiftfin/Views/UserProfileImagePicker/UserProfileImagePicker.swift index cefe33dda..dd9130340 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/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() diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift index 16960b13d..6a22cbd86 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,14 @@ 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 - } + UserProfileImage( + 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/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 8eccb96c2..4ca18cb6f 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1291,6 +1291,9 @@ /// Production Locations "productionLocations" = "Production Locations"; +/// Profile Image +"profileImage" = "Profile Image"; + /// Profiles "profiles" = "Profiles"; @@ -1423,6 +1426,15 @@ /// Reset all settings back to defaults. "resetAllSettings" = "Reset all settings back to defaults."; +/// Reset Settings +"resetSettings" = "Reset Settings"; + +/// Reset Swiftfin user settings +"resetSettingsDescription" = "Reset Swiftfin user settings"; + +/// Are you sure you want to reset all user settings? +"resetSettingsMessage" = "Are you sure you want to reset all user settings?"; + /// Reset User Settings "resetUserSettings" = "Reset User Settings"; @@ -1507,6 +1519,9 @@ /// Select All "selectAll" = "Select All"; +/// Select Image +"selectImage" = "Select Image"; + /// Series "series" = "Series"; @@ -1915,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";