[iOS] Admin Dashboard - User Profiles (#1328)

* Make user profile more generic. Still need to make it work for the reset image / other stuff like delete & username.

* Username Changing and PFP deletion.

* Functional, refreshing, and good to go!

* Clean up localizations

* Migrate [UserDto] -> IdentifiedArrayOf<UserDto>

* Solve "Username should probably be at the top of this section."

* allow notification filter

* WIP:

Created `UserProfileHeroImage` but I haven't used it anywhere.

* Centralize UserProfileHeroImages

* Rename UserProfileImages

* Fix Merge Issue?

* Move to UserProfileImage

* Merge with Main

* Fix Merge?

* Clear the cache on update.

* Delete duplicate `UserProfileImage`

* wip

* wip

* Update ImagePipeline.swift

* fix tvOS build issue and update comment to be more accurate

* clean up

* fix string

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2024-12-28 22:35:10 -07:00 committed by GitHub
parent 2f13093cc0
commit 23beb088da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 756 additions and 366 deletions

View File

@ -13,10 +13,16 @@ struct RedrawOnNotificationView<Content: View, P>: View {
@State @State
private var id = 0 private var id = 0
private let filter: (P) -> Bool
private let key: Notifications.Key<P> private let key: Notifications.Key<P>
private let content: () -> Content private let content: () -> Content
init(_ key: Notifications.Key<P>, @ViewBuilder content: @escaping () -> Content) { init(
_ key: Notifications.Key<P>,
filter: @escaping (P) -> Bool = { _ in true },
@ViewBuilder content: @escaping () -> Content
) {
self.filter = filter
self.key = key self.key = key
self.content = content self.content = content
} }
@ -24,7 +30,8 @@ struct RedrawOnNotificationView<Content: View, P>: View {
var body: some View { var body: some View {
content() content()
.id(id) .id(id)
.onNotification(key) { _ in .onNotification(key) { p in
guard filter(p) else { return }
id += 1 id += 1
} }
} }

View File

@ -60,6 +60,7 @@ struct ImageView: View {
} }
} }
.pipeline(pipeline) .pipeline(pipeline)
.onDisappear(.lowerPriority)
} else { } else {
failure() failure()
.eraseToAnyView() .eraseToAnyView()

View File

@ -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)
}
}
}

View File

@ -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<Placeholder: View>: 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)
}
}

View File

@ -72,6 +72,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
var userEditAccessSchedules = makeUserEditAccessSchedules var userEditAccessSchedules = makeUserEditAccessSchedules
@Route(.modal) @Route(.modal)
var userAddAccessSchedule = makeUserAddAccessSchedule var userAddAccessSchedule = makeUserAddAccessSchedule
@Route(.modal)
var userPhotoPicker = makeUserPhotoPicker
// MARK: - Route: API Keys // MARK: - Route: API Keys
@ -139,6 +141,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
ServerUserDetailsView(user: user) ServerUserDetailsView(user: user)
} }
func makeUserPhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
}
func makeAddServerUser() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { func makeAddServerUser() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator { NavigationViewCoordinator {
AddServerUserView() AddServerUserView()

View File

@ -123,8 +123,8 @@ final class SettingsCoordinator: NavigationCoordinatable {
UserLocalSecurityView() UserLocalSecurityView()
} }
func makePhotoPicker(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> { func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
NavigationViewCoordinator(UserProfileImageCoordinator()) NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
} }
@ViewBuilder @ViewBuilder

View File

@ -11,19 +11,34 @@ import SwiftUI
final class UserProfileImageCoordinator: NavigationCoordinatable { final class UserProfileImageCoordinator: NavigationCoordinatable {
// MARK: - Navigation Components
let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start) let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
// MARK: - Routes
@Route(.push) @Route(.push)
var cropImage = makeCropImage 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 { func makeCropImage(image: UIImage) -> some View {
#if os(iOS) #if os(iOS)
UserProfileImagePicker.SquareImageCropView( UserProfileImagePicker.SquareImageCropView(viewModel: viewModel, image: image)
image: image
)
#else #else
AssertionFailureView("not implemented") AssertionFailureView("not implemented")
#endif #endif
@ -32,7 +47,7 @@ final class UserProfileImageCoordinator: NavigationCoordinatable {
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
#if os(iOS) #if os(iOS)
UserProfileImagePicker() UserProfileImagePicker(viewModel: viewModel)
#else #else
AssertionFailureView("not implemented") AssertionFailureView("not implemented")
#endif #endif

View File

@ -58,7 +58,7 @@ extension BaseItemDto {
maxWidth: maxWidth, maxWidth: maxWidth,
maxHeight: maxHeight, maxHeight: maxHeight,
itemID: seriesID ?? "", itemID: seriesID ?? "",
force: true requireTag: false
) )
} }
@ -70,7 +70,7 @@ extension BaseItemDto {
maxWidth: maxWidth, maxWidth: maxWidth,
maxHeight: maxHeight, maxHeight: maxHeight,
itemID: seriesID ?? "", itemID: seriesID ?? "",
force: true requireTag: false
) )
return ImageSource( return ImageSource(
@ -86,16 +86,14 @@ extension BaseItemDto {
maxWidth: CGFloat?, maxWidth: CGFloat?,
maxHeight: CGFloat?, maxHeight: CGFloat?,
itemID: String, itemID: String,
force: Bool = false requireTag: Bool = true
) -> URL? { ) -> URL? {
let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!)
let scaleHeight = maxHeight == nil ? nil : UIScreen.main.scale(maxHeight!) let scaleHeight = maxHeight == nil ? nil : UIScreen.main.scale(maxHeight!)
let tag = getImageTag(for: type) let tag = getImageTag(for: type)
if tag == nil && !force { guard tag != nil || !requireTag else { return nil }
return nil
}
// TODO: client passing for widget/shared group views? // TODO: client passing for widget/shared group views?
guard let client = Container.shared.currentUserSession()?.client else { return nil } guard let client = Container.shared.currentUserSession()?.client else { return nil }

View File

@ -21,48 +21,49 @@ extension DataCache {
extension DataCache.Swiftfin { extension DataCache.Swiftfin {
static let `default`: DataCache? = { static let posters: DataCache? = {
let dataCache = try? DataCache(name: "org.jellyfin.swiftfin") { name in
URL(string: name)?.pathAndQuery() ?? name 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 return dataCache
}() }()
/// The `DataCache` used for images that should have longer lifetimes, usable without a /// The `DataCache` used for server and user images that should be usable
/// connection, and not affected by other caching size limits. /// without an active connection.
/// static let local: DataCache? = {
/// Current 150 MB is more than necessary.
static let branding: DataCache? = {
guard let root = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { guard let root = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil 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 let dataCache = try? DataCache(path: path) { name in
// this adds some latency, but fine since guard let url = name.url else { return nil }
// this DataCache is special
if name.range(of: "Splashscreen") != nil {
// TODO: potential issue where url ends with `/`, if // Since multi-url servers are supported, key splashscreens with the server ID.
// not found, retry with `/` appended //
let prefix = name.trimmingSuffix("/Branding/Splashscreen?") // Additional latency from Core Data fetch is acceptable.
if url.path.contains("Splashscreen") {
// can assume that we are only requesting a server with // Account for hosting at a path
// the key same as the current url guard let prefixURL = url.absoluteString.trimmingSuffix("/Branding/Splashscreen?").url else { return nil }
guard let prefixURL = URL(string: prefix) else { return name }
// We can assume that the request is from the current server
let urlFilter: Where<ServerModel> = Where(\.$currentURL == prefixURL)
guard let server = try? SwiftfinStore.dataStack.fetchOne( guard let server = try? SwiftfinStore.dataStack.fetchOne(
From<ServerModel>() From<ServerModel>()
.where(\.$currentURL == prefixURL) .where(urlFilter)
) else { return name } ) else { return nil }
return "\(server.id)-splashscreen" return "\(server.id)-splashscreen".sha1
} else { } else {
return URL(string: name)?.pathAndQuery() ?? name return ImagePipeline.cacheKey(for: url)
} }
} }

View File

@ -10,20 +10,57 @@ import Foundation
import Nuke import Nuke
extension ImagePipeline { extension ImagePipeline {
enum Swiftfin {} 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 { extension ImagePipeline.Swiftfin {
/// The default `ImagePipeline` to use for images that should be used /// The default `ImagePipeline` to use for images that are typically posters
/// during normal usage with an active connection. /// or server user images that should be presentable with an active connection.
static let `default`: ImagePipeline = ImagePipeline { static let posters: ImagePipeline = ImagePipeline(delegate: SwiftfinImagePipelineDelegate()) {
$0.dataCache = DataCache.Swiftfin.default $0.dataCache = DataCache.Swiftfin.posters
} }
/// The `ImagePipeline` used for images that should have longer lifetimes and usable /// The `ImagePipeline` used for images that should have longer lifetimes and usable
/// without a connection, like user profile images and server splashscreens. /// without a connection, likes local user profile images and server splashscreens.
static let branding: ImagePipeline = ImagePipeline { static let local: ImagePipeline = ImagePipeline(delegate: SwiftfinImagePipelineDelegate()) {
$0.dataCache = DataCache.Swiftfin.branding $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)
} }
} }

View File

@ -7,6 +7,7 @@
// //
import Algorithms import Algorithms
import CryptoKit
import Foundation import Foundation
import SwiftUI import SwiftUI
@ -117,6 +118,23 @@ extension String {
return s 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 // TODO: remove after iOS 15 support removed
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat { func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {

View File

@ -68,7 +68,7 @@ extension URL {
} }
// doesn't have `?` but doesn't matter // doesn't have `?` but doesn't matter
func pathAndQuery() -> String? { var pathAndQuery: String? {
path + (query ?? "") path + (query ?? "")
} }
@ -80,4 +80,8 @@ extension URL {
return -1 return -1
} }
} }
var components: URLComponents? {
URLComponents(url: self, resolvingAgainstBaseURL: false)
}
} }

View File

@ -150,8 +150,9 @@ extension Notifications.Key {
// MARK: - User // MARK: - User
static var didChangeUserProfileImage: Key<Void> { /// - Payload: The ID of the user whose Profile Image changed.
Key("didChangeUserProfileImage") static var didChangeUserProfile: Key<String> {
Key("didChangeUserProfile")
} }
static var didAddServerUser: Key<UserDto> { static var didAddServerUser: Key<UserDto> {

View File

@ -906,6 +906,8 @@ internal enum L10n {
internal static let production = L10n.tr("Localizable", "production", fallback: "Production") internal static let production = L10n.tr("Localizable", "production", fallback: "Production")
/// Production Locations /// Production Locations
internal static let productionLocations = L10n.tr("Localizable", "productionLocations", fallback: "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 /// Profiles
internal static let profiles = L10n.tr("Localizable", "profiles", fallback: "Profiles") internal static let profiles = L10n.tr("Localizable", "profiles", fallback: "Profiles")
/// Programs /// Programs
@ -998,6 +1000,12 @@ internal enum L10n {
internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset") internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset")
/// Reset all settings back to defaults. /// Reset all settings back to defaults.
internal static let resetAllSettings = L10n.tr("Localizable", "resetAllSettings", fallback: "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 /// Reset User Settings
internal static let resetUserSettings = L10n.tr("Localizable", "resetUserSettings", fallback: "Reset User Settings") internal static let resetUserSettings = L10n.tr("Localizable", "resetUserSettings", fallback: "Reset User Settings")
/// Restart Server /// Restart Server
@ -1056,6 +1064,8 @@ internal enum L10n {
internal static let seeMore = L10n.tr("Localizable", "seeMore", fallback: "See More") internal static let seeMore = L10n.tr("Localizable", "seeMore", fallback: "See More")
/// Select All /// Select All
internal static let selectAll = L10n.tr("Localizable", "selectAll", fallback: "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 /// Series
internal static let series = L10n.tr("Localizable", "series", fallback: "Series") internal static let series = L10n.tr("Localizable", "series", fallback: "Series")
/// Series Backdrop /// Series Backdrop
@ -1338,6 +1348,8 @@ internal enum L10n {
internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported")
/// Video transcoding /// Video transcoding
internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "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 /// Weekday
internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday") internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday")
/// Weekend /// Weekend

View File

@ -152,7 +152,6 @@ extension UserState {
let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!)
let parameters = Paths.GetUserImageParameters( let parameters = Paths.GetUserImageParameters(
tag: data.primaryImageTag,
maxWidth: scaleWidth maxWidth: scaleWidth
) )
let request = Paths.getUserImage( let request = Paths.getUserImage(

View File

@ -24,7 +24,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl
enum Action: Equatable { enum Action: Equatable {
case cancel case cancel
case loadDetails case refresh
case loadLibraries(isHidden: Bool? = false) case loadLibraries(isHidden: Bool? = false)
case updatePolicy(UserPolicy) case updatePolicy(UserPolicy)
case updateConfiguration(UserConfiguration) case updateConfiguration(UserConfiguration)
@ -68,10 +68,22 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
// MARK: - Initialize // MARK: - Initializer
init(user: UserDto) { init(user: UserDto) {
self.user = user 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 // MARK: - Respond
@ -81,7 +93,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl
case .cancel: case .cancel:
return .initial return .initial
case .loadDetails: case .refresh:
userTaskCancellable?.cancel() userTaskCancellable?.cancel()
userTaskCancellable = Task { userTaskCancellable = Task {
@ -280,6 +292,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl
await MainActor.run { await MainActor.run {
self.user.name = username self.user.name = username
Notifications[.didChangeUserProfile].post(userID)
} }
} }
} }

View File

@ -8,6 +8,7 @@
import Combine import Combine
import Foundation import Foundation
import IdentifiedCollections
import JellyfinAPI import JellyfinAPI
import OrderedCollections import OrderedCollections
import SwiftUI import SwiftUI
@ -24,6 +25,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {
// MARK: Actions // MARK: Actions
enum Action: Equatable { enum Action: Equatable {
case refreshUser(String)
case getUsers(isHidden: Bool = false, isDisabled: Bool = false) case getUsers(isHidden: Bool = false, isDisabled: Bool = false)
case deleteUsers([String]) case deleteUsers([String])
case appendUser(UserDto) case appendUser(UserDto)
@ -49,8 +51,10 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {
@Published @Published
final var backgroundStates: OrderedSet<BackgroundState> = [] final var backgroundStates: OrderedSet<BackgroundState> = []
@Published @Published
final var users: [UserDto] = [] final var users: IdentifiedArrayOf<UserDto> = []
@Published @Published
final var state: State = .initial final var state: State = .initial
@ -63,10 +67,51 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {
private var userTask: AnyCancellable? private var userTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init() private var eventSubject: PassthroughSubject<Event, Never> = .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 // MARK: - Respond to Action
func respond(to action: Action) -> State { func respond(to action: Action) -> State {
switch action { 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): case let .getUsers(isHidden, isDisabled):
userTask?.cancel() userTask?.cancel()
backgroundStates.append(.gettingUsers) 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 // MARK: - Load Users
private func loadUsers(isHidden: Bool, isDisabled: Bool) async throws { private func loadUsers(isHidden: Bool, isDisabled: Bool) async throws {
@ -154,7 +214,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {
.sorted(using: \.name) .sorted(using: \.name)
await MainActor.run { 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 { await MainActor.run {
self.users = self.users.filter { self.users.removeAll(where: { userIdsToDelete.contains($0.id ?? "") })
!userIdsToDelete.contains($0.id ?? "")
}
} }
} }
@ -197,7 +255,7 @@ final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {
private func appendUser(user: UserDto) async { private func appendUser(user: UserDto) async {
await MainActor.run { await MainActor.run {
users.append(user) users.append(user)
users = users.sorted(using: \.name) users.sort(by: { $0.name ?? "" < $1.name ?? "" })
} }
} }
} }

View File

@ -154,6 +154,6 @@ final class MediaViewModel: ViewModel, Stateful {
let response = try await userSession.client.send(request) let response = try await userSession.client.send(request)
return (response.value.items ?? []) return (response.value.items ?? [])
.map { $0.imageSource(.backdrop, maxWidth: 500) } .map { $0.imageSource(.backdrop, maxWidth: 200) }
} }
} }

View File

@ -15,7 +15,6 @@ import JellyfinAPI
import UIKit import UIKit
// TODO: should probably break out into a `Settings` and `AppSettings` view models // 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 // - could clean up all settings view models
final class SettingsViewModel: ViewModel { 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) { func select(icon: any AppIcon) {
let previousAppIcon = currentAppIcon let previousAppIcon = currentAppIcon
currentAppIcon = icon currentAppIcon = icon

View File

@ -14,51 +14,77 @@ import UIKit
class UserProfileImageViewModel: ViewModel, Eventful, Stateful { class UserProfileImageViewModel: ViewModel, Eventful, Stateful {
// MARK: - Action
enum Action: Equatable { enum Action: Equatable {
case cancel case cancel
case delete
case upload(UIImage) case upload(UIImage)
} }
// MARK: - Event
enum Event: Hashable { enum Event: Hashable {
case error(JellyfinAPIError) case error(JellyfinAPIError)
case deleted
case uploaded case uploaded
} }
enum State: Hashable {
case initial
case uploading
}
@Published
var state: State = .initial
var events: AnyPublisher<Event, Never> { var events: AnyPublisher<Event, Never> {
eventSubject eventSubject
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
// MARK: - State
enum State: Hashable {
case initial
case deleting
case uploading
}
@Published
var state: State = .initial
// MARK: - Published Values
let user: UserDto
// MARK: - Task Variables
private var eventSubject: PassthroughSubject<Event, Never> = .init() private var eventSubject: PassthroughSubject<Event, Never> = .init()
private var uploadCancellable: AnyCancellable? private var uploadCancellable: AnyCancellable?
// MARK: - Initializer
init(user: UserDto) {
self.user = user
}
// MARK: - Respond to Action
func respond(to action: Action) -> State { func respond(to action: Action) -> State {
switch action { switch action {
case .cancel: case .cancel:
uploadCancellable?.cancel() uploadCancellable?.cancel()
return .initial return .initial
case let .upload(image):
case let .upload(image):
uploadCancellable = Task { uploadCancellable = Task {
do { do {
try await upload(image: image) await MainActor.run {
self.state = .uploading
}
try await upload(image)
await MainActor.run { await MainActor.run {
self.eventSubject.send(.uploaded) self.eventSubject.send(.uploaded)
self.state = .initial self.state = .initial
} }
} catch is CancellationError { } catch is CancellationError {
// cancel doesn't matter // Cancel doesn't matter
} catch { } catch {
await MainActor.run { await MainActor.run {
self.eventSubject.send(.error(.init(error.localizedDescription))) self.eventSubject.send(.error(.init(error.localizedDescription)))
@ -68,11 +94,41 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful {
} }
.asAnyCancellable() .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 contentType: String
let imageData: Data let imageData: Data
@ -89,7 +145,7 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful {
} }
var request = Paths.postUserImage( var request = Paths.postUserImage(
userID: userSession.user.id, userID: userID,
imageType: "Primary", imageType: "Primary",
imageData imageData
) )
@ -97,13 +153,46 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful {
let _ = try await userSession.client.send(request) let _ = try await userSession.client.send(request)
let currentUserRequest = Paths.getCurrentUser sweepProfileImageCache()
let response = try await userSession.client.send(currentUserRequest)
await MainActor.run { await MainActor.run {
userSession.user.data = response.value Notifications[.didChangeUserProfile].post(userID)
}
}
Notifications[.didChangeUserProfileImage].post() // 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)
} }
} }
} }

View File

@ -47,7 +47,7 @@ struct SwiftfinApp: App {
return mimeType.contains("svg") ? ImageDecoders.Empty() : nil return mimeType.contains("svg") ? ImageDecoders.Empty() : nil
} }
ImagePipeline.shared = .Swiftfin.default ImagePipeline.shared = .Swiftfin.posters
// UIKit // UIKit

View File

@ -69,17 +69,13 @@ extension SelectUserView {
ZStack { ZStack {
Color.clear Color.clear
ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) UserProfileImage(
.image { image in userID: user.id,
image source: user.profileImageSource(
.posterBorder(ratio: 1 / 2, of: \.width) client: server.client,
} maxWidth: 120
.placeholder { _ in )
personView )
}
.failure {
personView
}
} }
.aspectRatio(1, contentMode: .fill) .aspectRatio(1, contentMode: .fill)
} }

View File

@ -36,20 +36,6 @@ extension UserSignInView {
self.action = action 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 // MARK: - Person View
@ViewBuilder @ViewBuilder
@ -57,17 +43,13 @@ extension UserSignInView {
ZStack { ZStack {
Color.clear Color.clear
ImageView(user.profileImageSource(client: client, maxWidth: 120)) UserProfileImage(
.image { image in userID: user.id,
image source: user.profileImageSource(
.posterBorder(ratio: 0.5, of: \.width) client: client,
} maxWidth: 120
.placeholder { _ in )
fallbackPersonView )
}
.failure {
fallbackPersonView
}
} }
} }

View File

@ -98,6 +98,7 @@
4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; }; 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; };
4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; }; 4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; };
4E537A8D2D04410E00659A1A /* ServerUserLiveTVAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.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 */; }; 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; };
4E556AB12D036F6900733377 /* 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 */; }; 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 */; }; 4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BBF2CB34775007CBD5D /* HomeSection.swift */; };
4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */; }; 4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */; };
4E71D6892C80910900A0174D /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.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 */; }; 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; };
4E73E2A72C41CFD3002D2A78 /* 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 */; }; 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 = "<group>"; }; 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = "<group>"; };
4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDeviceAccessView.swift; sourceTree = "<group>"; }; 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDeviceAccessView.swift; sourceTree = "<group>"; };
4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserLiveTVAccessView.swift; sourceTree = "<group>"; }; 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserLiveTVAccessView.swift; sourceTree = "<group>"; };
4E5508722D13AFE3002A5345 /* UserProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImage.swift; sourceTree = "<group>"; };
4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = "<group>"; }; 4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = "<group>"; };
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = "<group>"; }; 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = "<group>"; };
@ -1270,6 +1275,7 @@
4E699BBF2CB34775007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = "<group>"; }; 4E699BBF2CB34775007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = "<group>"; };
4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionDetailView.swift; sourceTree = "<group>"; }; 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionDetailView.swift; sourceTree = "<group>"; };
4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = "<group>"; }; 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = "<group>"; };
4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileHeroImage.swift; sourceTree = "<group>"; };
4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = "<group>"; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = "<group>"; };
4E75B34A2D164AC100D16531 /* PurgeUnusedStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeUnusedStrings.swift; sourceTree = "<group>"; }; 4E75B34A2D164AC100D16531 /* PurgeUnusedStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeUnusedStrings.swift; sourceTree = "<group>"; };
4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; };
@ -2462,6 +2468,15 @@
path = ActiveSessionDetailView; path = ActiveSessionDetailView;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4E7315722D14752400EA2A95 /* UserProfileImage */ = {
isa = PBXGroup;
children = (
4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */,
4E5508722D13AFE3002A5345 /* UserProfileImage.swift */,
);
path = UserProfileImage;
sourceTree = "<group>";
};
4E75B34D2D16583900D16531 /* Translations */ = { 4E75B34D2D16583900D16531 /* Translations */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -3831,6 +3846,7 @@
E10B1EAF2BD9769500A92EAF /* SelectUserView */, E10B1EAF2BD9769500A92EAF /* SelectUserView */,
E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */, E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */,
E1E5D54A2783E26100692DFE /* SettingsView */, E1E5D54A2783E26100692DFE /* SettingsView */,
4E49DEDF2CE55F7F00352DCD /* UserProfileImagePicker */,
E1171A1A28A2215800FA1AF5 /* UserSignInView */, E1171A1A28A2215800FA1AF5 /* UserSignInView */,
E193D5452719418B00900D82 /* VideoPlayer */, E193D5452719418B00900D82 /* VideoPlayer */,
); );
@ -3901,7 +3917,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */,
4E49DEDF2CE55F7F00352DCD /* UserProfileImagePicker */,
E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */,
E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */,
); );
@ -4417,6 +4432,7 @@
E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */, E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */,
E1A1528928FD22F600600579 /* TextPairView.swift */, E1A1528928FD22F600600579 /* TextPairView.swift */,
E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */,
4E7315722D14752400EA2A95 /* UserProfileImage */,
E1B5784028F8AFCB00D42911 /* WrappedView.swift */, E1B5784028F8AFCB00D42911 /* WrappedView.swift */,
); );
path = Components; path = Components;
@ -5158,6 +5174,7 @@
E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */,
E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */,
E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */, E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */,
4E7315762D1485CC00EA2A95 /* UserProfileHeroImage.swift in Sources */,
E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */, E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */,
E190704C2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */, E190704C2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */,
@ -5359,6 +5376,7 @@
E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */,
4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */, 4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */,
4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */, 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */,
4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */,
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */,
4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */,
E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */,
@ -5788,6 +5806,7 @@
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */,
4E5508732D13AFED002A5345 /* UserProfileImage.swift in Sources */,
E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */,
@ -5802,6 +5821,7 @@
E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */,
C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */,
4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */, 4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */,
4E7315742D14772700EA2A95 /* UserProfileHeroImage.swift in Sources */,
E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */,
E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */,
E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */,

View File

@ -54,7 +54,7 @@ struct SwiftfinApp: App {
return mimeType.contains("svg") ? ImageDecoders.Empty() : nil return mimeType.contains("svg") ? ImageDecoders.Empty() : nil
} }
ImagePipeline.shared = .Swiftfin.default ImagePipeline.shared = .Swiftfin.posters
// UIKit // UIKit

View File

@ -13,10 +13,14 @@ import SwiftUI
// TODO: expose `ImageView.image` modifier for image aspect fill/fit // TODO: expose `ImageView.image` modifier for image aspect fill/fit
// TODO: allow `content` to trigger `onSelect`? // TODO: allow `content` to trigger `onSelect`?
// - not in button label to avoid context menu visual oddities // - 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? // TODO: why don't shadows work with failure image views?
// - due to `Color`? // - 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<Item: Poster>: View { struct PosterButton<Item: Poster>: View {
private var item: Item private var item: Item
@ -29,9 +33,9 @@ struct PosterButton<Item: Poster>: View {
private func imageView(from item: Item) -> ImageView { private func imageView(from item: Item) -> ImageView {
switch type { switch type {
case .landscape: case .landscape:
ImageView(item.landscapeImageSources(maxWidth: 500)) ImageView(item.landscapeImageSources(maxWidth: landscapeMaxWidth))
case .portrait: case .portrait:
ImageView(item.portraitImageSources(maxWidth: 200)) ImageView(item.portraitImageSources(maxWidth: portraitMaxWidth))
} }
} }

View File

@ -31,29 +31,17 @@ struct SettingsBarButton: View {
ZStack { ZStack {
Color.clear Color.clear
RedrawOnNotificationView(.didChangeUserProfileImage) { UserProfileImage(
ImageView(user.profileImageSource( userID: user.id,
source: user.profileImageSource(
client: server.client, client: server.client,
maxWidth: 120 maxWidth: 120
)) ),
.pipeline(.Swiftfin.branding) pipeline: .Swiftfin.local
.image { image in ) {
image Color.clear
.posterBorder(ratio: 1 / 2, of: \.width)
.onAppear {
isUserImage = true
}
}
.placeholder { _ in
Color.clear
}
.onDisappear {
isUserImage = false
}
} }
} }
.aspectRatio(contentMode: .fill)
.clipShape(.circle)
} }
} }
.accessibilityLabel(L10n.settings) .accessibilityLabel(L10n.settings)

View File

@ -15,11 +15,11 @@ extension APIKeysView {
struct APIKeysRow: View { struct APIKeysRow: View {
// MARK: - Actions // MARK: - API Key Variables
let apiKey: AuthenticationInfo let apiKey: AuthenticationInfo
// MARK: - Actions // MARK: - API Key Actions
let onSelect: () -> Void let onSelect: () -> Void
let onDelete: () -> Void let onDelete: () -> Void

View File

@ -11,41 +11,82 @@ import JellyfinAPI
import SwiftUI import SwiftUI
struct ServerUserDetailsView: View { struct ServerUserDetailsView: View {
@EnvironmentObject
private var router: AdminDashboardCoordinator.Router // MARK: - Current Date
@CurrentDate @CurrentDate
private var currentDate: Date private var currentDate: Date
// MARK: - State, Observed, & Environment Objects
@EnvironmentObject
private var router: AdminDashboardCoordinator.Router
@StateObject @StateObject
private var viewModel: ServerUserAdminViewModel 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 // MARK: - Initializer
init(user: UserDto) { 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 // MARK: - Body
var body: some View { var body: some View {
List { List {
// TODO: Replace with Update Profile Picture & Username UserProfileHeroImage(
AdminDashboardView.UserSection(
user: viewModel.user, 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 { 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 { if let userId = viewModel.user.id {
ChevronButton(L10n.password) ChevronButton(L10n.password)
.onSelect { .onSelect {
router.route(to: \.resetUserPassword, userId) router.route(to: \.resetUserPassword, userId)
} }
} }
}
Section(L10n.advanced) {
ChevronButton(L10n.permissions) ChevronButton(L10n.permissions)
.onSelect { .onSelect {
router.route(to: \.userPermissions, viewModel) router.route(to: \.userPermissions, viewModel)
@ -77,7 +118,7 @@ struct ServerUserDetailsView: View {
.onSelect { .onSelect {
router.route(to: \.userAllowedTags, viewModel) router.route(to: \.userAllowedTags, viewModel)
} }
// TODO: Block items - blockedTags // TODO: Block items - blockedTags
ChevronButton("Block items") ChevronButton("Block items")
.onSelect { .onSelect {
router.route(to: \.userBlockedTags, viewModel) router.route(to: \.userBlockedTags, viewModel)
@ -90,7 +131,17 @@ struct ServerUserDetailsView: View {
} }
.navigationTitle(L10n.user) .navigationTitle(L10n.user)
.onAppear { .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)
} }
} }

View File

@ -75,24 +75,20 @@ extension ServerUsersView {
@ViewBuilder @ViewBuilder
private var userImage: some View { private var userImage: some View {
ZStack { ZStack {
ImageView(user.profileImageSource(client: userSession!.client)) UserProfileImage(
.pipeline(.Swiftfin.branding) userID: user.id,
.placeholder { _ in source: user.profileImageSource(
SystemImageContentView(systemName: "person.fill", ratio: 0.5) client: userSession!.client,
} maxWidth: 60
.failure { )
SystemImageContentView(systemName: "person.fill", ratio: 0.5) )
} .grayscale(userActive ? 0.0 : 1.0)
.grayscale(userActive ? 0.0 : 1.0)
if isEditing { if isEditing {
Color.black Color.black
.opacity(isSelected ? 0 : 0.5) .opacity(isSelected ? 0 : 0.5)
} }
} }
.clipShape(.circle)
.aspectRatio(1, contentMode: .fill)
.posterShadow()
.frame(width: 60, height: 60) .frame(width: 60, height: 60)
} }

View File

@ -59,7 +59,7 @@ extension SeriesEpisodeSelector {
ZStack { ZStack {
Color.clear Color.clear
ImageView(episode.imageSource(.primary, maxWidth: 500)) ImageView(episode.imageSource(.primary, maxWidth: 250))
.failure { .failure {
SystemImageContentView(systemName: episode.systemImage) SystemImageContentView(systemName: episode.systemImage)
} }

View File

@ -55,9 +55,9 @@ extension MediaView {
} }
if case let MediaViewModel.MediaType.collectionFolder(item) = mediaType { 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 { } else if case let MediaViewModel.MediaType.liveTV(item) = mediaType {
self.imageSources = [item.imageSource(.primary, maxWidth: 500)] self.imageSources = [item.imageSource(.primary, maxWidth: 200)]
} }
} }
} }

View File

@ -10,6 +10,9 @@ import Defaults
import JellyfinAPI import JellyfinAPI
import SwiftUI import SwiftUI
private let landscapeMaxWidth: CGFloat = 110
private let portraitMaxWidth: CGFloat = 60
extension PagingLibraryView { extension PagingLibraryView {
struct LibraryRow: View { struct LibraryRow: View {
@ -21,9 +24,9 @@ extension PagingLibraryView {
private func imageView(from element: Element) -> ImageView { private func imageView(from element: Element) -> ImageView {
switch posterType { switch posterType {
case .landscape: case .landscape:
ImageView(element.landscapeImageSources(maxWidth: 110)) ImageView(element.landscapeImageSources(maxWidth: landscapeMaxWidth))
case .portrait: case .portrait:
ImageView(element.portraitImageSources(maxWidth: 60)) ImageView(element.portraitImageSources(maxWidth: portraitMaxWidth))
} }
} }
@ -96,7 +99,7 @@ extension PagingLibraryView {
} }
} }
.posterStyle(posterType) .posterStyle(posterType)
.frame(width: posterType == .landscape ? 110 : 60) .frame(width: posterType == .landscape ? landscapeMaxWidth : portraitMaxWidth)
.posterShadow() .posterShadow()
.padding(.vertical, 8) .padding(.vertical, 8)
} }

View File

@ -50,25 +50,6 @@ extension SelectUserView {
return isSelected ? .primary : .secondary 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 { var body: some View {
Button { Button {
action() action()
@ -77,21 +58,15 @@ extension SelectUserView {
ZStack { ZStack {
Color.clear Color.clear
ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) UserProfileImage(
.pipeline(.Swiftfin.branding) userID: user.id,
.image { image in source: user.profileImageSource(
image client: server.client,
.posterBorder(ratio: 1 / 2, of: \.width) maxWidth: 120
} ),
.placeholder { _ in pipeline: .Swiftfin.local
personView )
}
.failure {
personView
}
} }
.aspectRatio(contentMode: .fill)
.clipShape(.circle)
.overlay { .overlay {
if isEditing { if isEditing {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {

View File

@ -74,18 +74,14 @@ extension SelectUserView {
ZStack { ZStack {
Color.clear Color.clear
ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) UserProfileImage(
.pipeline(.Swiftfin.branding) userID: user.id,
.image { image in source: user.profileImageSource(
image client: server.client,
.posterBorder(ratio: 1 / 2, of: \.width) maxWidth: 120
} ),
.placeholder { _ in pipeline: .Swiftfin.local
personView )
}
.failure {
personView
}
if isEditing { if isEditing {
Color.black Color.black

View File

@ -447,7 +447,7 @@ struct SelectUserView: View {
Color.clear Color.clear
ImageView(splashScreenImageSources) ImageView(splashScreenImageSources)
.pipeline(.Swiftfin.branding) .pipeline(.Swiftfin.local)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.id(splashScreenImageSources) .id(splashScreenImageSources)
.transition(.opacity) .transition(.opacity)

View File

@ -20,20 +20,6 @@ extension SettingsView {
private let user: UserDto private let user: UserDto
private let action: (() -> Void)? 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 { var body: some View {
Button { Button {
guard let action else { return } guard let action else { return }
@ -44,10 +30,14 @@ extension SettingsView {
// `.aspectRatio(contentMode: .fill)` on `imageView` alone // `.aspectRatio(contentMode: .fill)` on `imageView` alone
// causes a crash on some iOS versions // causes a crash on some iOS versions
ZStack { 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) .frame(width: 50, height: 50)
Text(user.name ?? L10n.unknown) Text(user.name ?? L10n.unknown)

View File

@ -102,7 +102,7 @@ struct SettingsView: View {
Section { Section {
ColorPicker(L10n.accentColor, selection: $accentColor, supportsOpacity: false) ColorPicker(L10n.accentColor, selection: $accentColor, supportsOpacity: false)
} footer: { } footer: {
Text(L10n.accentColorDescription) Text(L10n.viewsMayRequireRestart)
} }
ChevronButton(L10n.logs) ChevronButton(L10n.logs)

View File

@ -8,79 +8,39 @@
import Defaults import Defaults
import Factory import Factory
import JellyfinAPI
import SwiftUI import SwiftUI
struct UserProfileSettingsView: View { struct UserProfileSettingsView: View {
@Default(.accentColor)
private var accentColor
@EnvironmentObject @EnvironmentObject
private var router: SettingsCoordinator.Router private var router: SettingsCoordinator.Router
@ObservedObject @ObservedObject
var viewModel: SettingsViewModel private var viewModel: SettingsViewModel
@StateObject
private var profileImageViewModel: UserProfileImageViewModel
@State @State
private var isPresentingConfirmReset: Bool = false private var isPresentingConfirmReset: Bool = false
@State
private var isPresentingProfileImageOptions: Bool = false
@ViewBuilder init(viewModel: SettingsViewModel) {
private var imageView: some View { self.viewModel = viewModel
RedrawOnNotificationView(.didChangeUserProfileImage) { self._profileImageViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: viewModel.userSession.user.data))
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)
}
}
} }
var body: some View { var body: some View {
List { List {
Section { UserProfileHeroImage(
VStack(alignment: .center) { user: profileImageViewModel.user,
Button { source: viewModel.userSession.user.profileImageSource(
isPresentingProfileImageOptions = true client: viewModel.userSession.client,
} label: { maxWidth: 150
ZStack(alignment: .bottomTrailing) { )
// `.aspectRatio(contentMode: .fill)` on `imageView` alone ) {
// causes a crash on some iOS versions router.route(to: \.photoPicker, profileImageViewModel)
ZStack { } onDelete: {
imageView profileImageViewModel.send(.delete)
}
.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)
} }
Section { Section {
@ -105,15 +65,19 @@ struct UserProfileSettingsView: View {
Section { Section {
// TODO: move under future "Storage" tab // TODO: move under future "Storage" tab
// when downloads implemented // when downloads implemented
Button("Reset Settings") { Button(L10n.resetSettings) {
isPresentingConfirmReset = true isPresentingConfirmReset = true
} }
.foregroundStyle(.red) .foregroundStyle(.red)
} footer: { } 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) { Button(L10n.reset, role: .destructive) {
do { do {
try viewModel.userSession.user.deleteSettings() try viewModel.userSession.user.deleteSettings()
@ -122,21 +86,7 @@ struct UserProfileSettingsView: View {
} }
} }
} message: { } message: {
Text("Are you sure you want to reset all user settings?") Text(L10n.resetSettingsMessage)
}
.confirmationDialog(
"Profile Image",
isPresented: $isPresentingProfileImageOptions,
titleVisibility: .visible
) {
Button("Select Image") {
router.route(to: \.photoPicker, viewModel)
}
Button(L10n.delete, role: .destructive) {
viewModel.deleteCurrentUserProfileImage()
}
} }
} }
} }

View File

@ -19,14 +19,20 @@ extension UserProfileImagePicker {
struct PhotoPicker: UIViewControllerRepresentable { struct PhotoPicker: UIViewControllerRepresentable {
// MARK: - Photo Picker Actions
var onCancel: () -> Void var onCancel: () -> Void
var onSelectedImage: (UIImage) -> Void var onSelectedImage: (UIImage) -> Void
// MARK: - Initializer
init(onCancel: @escaping () -> Void, onSelectedImage: @escaping (UIImage) -> Void) { init(onCancel: @escaping () -> Void, onSelectedImage: @escaping (UIImage) -> Void) {
self.onCancel = onCancel self.onCancel = onCancel
self.onSelectedImage = onSelectedImage self.onSelectedImage = onSelectedImage
} }
// MARK: - UIView Controller
func makeUIViewController(context: Context) -> PHPickerViewController { func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared()) var configuration = PHPickerConfiguration(photoLibrary: .shared())
@ -45,12 +51,18 @@ extension UserProfileImagePicker {
return picker return picker
} }
// MARK: - Update UIView Controller
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
// MARK: - Make Coordinator
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator() Coordinator()
} }
// MARK: - Coordinator
class Coordinator: PHPickerViewControllerDelegate { class Coordinator: PHPickerViewControllerDelegate {
var onCancel: (() -> Void)? var onCancel: (() -> Void)?

View File

@ -19,15 +19,16 @@ extension UserProfileImagePicker {
@Default(.accentColor) @Default(.accentColor)
private var accentColor private var accentColor
// MARK: - State & Environment Objects // MARK: - State, Observed, & Environment Objects
@EnvironmentObject @EnvironmentObject
private var router: UserProfileImageCoordinator.Router private var router: UserProfileImageCoordinator.Router
@StateObject @StateObject
private var proxy: _SquareImageCropView.Proxy = .init() private var proxy: _SquareImageCropView.Proxy = .init()
@StateObject
private var viewModel = UserProfileImageViewModel() @ObservedObject
var viewModel: UserProfileImageViewModel
// MARK: - Image Variable // MARK: - Image Variable
@ -98,6 +99,8 @@ extension UserProfileImagePicker {
switch event { switch event {
case let .error(eventError): case let .error(eventError):
error = eventError error = eventError
case .deleted:
break
case .uploaded: case .uploaded:
router.dismissCoordinator() router.dismissCoordinator()
} }

View File

@ -10,9 +10,16 @@ import SwiftUI
struct UserProfileImagePicker: View { struct UserProfileImagePicker: View {
// MARK: - Observed, & Environment Objects
@EnvironmentObject @EnvironmentObject
private var router: UserProfileImageCoordinator.Router private var router: UserProfileImageCoordinator.Router
@ObservedObject
var viewModel: UserProfileImageViewModel
// MARK: - Body
var body: some View { var body: some View {
PhotoPicker { PhotoPicker {
router.dismissCoordinator() router.dismissCoordinator()

View File

@ -30,25 +30,6 @@ extension UserSignInView {
self.action = action 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 { var body: some View {
Button { Button {
action() action()
@ -57,22 +38,14 @@ extension UserSignInView {
ZStack { ZStack {
Color.clear Color.clear
ImageView(user.profileImageSource(client: client, maxWidth: 120)) UserProfileImage(
.pipeline(.Swiftfin.branding) userID: user.id,
.image { image in source: user.profileImageSource(
image client: client,
.posterBorder(ratio: 0.5, of: \.width) maxWidth: 120
} )
.placeholder { _ in )
personView
}
.failure {
personView
}
} }
.aspectRatio(1, contentMode: .fill)
.posterShadow()
.clipShape(.circle)
.frame(width: 50, height: 50) .frame(width: 50, height: 50)
Text(user.name ?? .emptyDash) Text(user.name ?? .emptyDash)

View File

@ -1291,6 +1291,9 @@
/// Production Locations /// Production Locations
"productionLocations" = "Production Locations"; "productionLocations" = "Production Locations";
/// Profile Image
"profileImage" = "Profile Image";
/// Profiles /// Profiles
"profiles" = "Profiles"; "profiles" = "Profiles";
@ -1423,6 +1426,15 @@
/// Reset all settings back to defaults. /// Reset all settings back to defaults.
"resetAllSettings" = "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 /// Reset User Settings
"resetUserSettings" = "Reset User Settings"; "resetUserSettings" = "Reset User Settings";
@ -1507,6 +1519,9 @@
/// Select All /// Select All
"selectAll" = "Select All"; "selectAll" = "Select All";
/// Select Image
"selectImage" = "Select Image";
/// Series /// Series
"series" = "Series"; "series" = "Series";
@ -1915,6 +1930,9 @@
/// Video transcoding /// Video transcoding
"videoTranscoding" = "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" = "Weekday"; "weekday" = "Weekday";