[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:
parent
2f13093cc0
commit
23beb088da
|
@ -13,10 +13,16 @@ struct RedrawOnNotificationView<Content: View, P>: View {
|
|||
@State
|
||||
private var id = 0
|
||||
|
||||
private let filter: (P) -> Bool
|
||||
private let key: Notifications.Key<P>
|
||||
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.content = content
|
||||
}
|
||||
|
@ -24,7 +30,8 @@ struct RedrawOnNotificationView<Content: View, P>: View {
|
|||
var body: some View {
|
||||
content()
|
||||
.id(id)
|
||||
.onNotification(key) { _ in
|
||||
.onNotification(key) { p in
|
||||
guard filter(p) else { return }
|
||||
id += 1
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,6 +60,7 @@ struct ImageView: View {
|
|||
}
|
||||
}
|
||||
.pipeline(pipeline)
|
||||
.onDisappear(.lowerPriority)
|
||||
} else {
|
||||
failure()
|
||||
.eraseToAnyView()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<UserProfileImageCoordinator> {
|
||||
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
func makeAddServerUser() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddServerUserView()
|
||||
|
|
|
@ -123,8 +123,8 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
|||
UserLocalSecurityView()
|
||||
}
|
||||
|
||||
func makePhotoPicker(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
|
||||
NavigationViewCoordinator(UserProfileImageCoordinator())
|
||||
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
|
||||
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
||||
|
||||
// TODO: potential issue where url ends with `/`, if
|
||||
// not found, retry with `/` appended
|
||||
let prefix = name.trimmingSuffix("/Branding/Splashscreen?")
|
||||
// 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") {
|
||||
|
||||
// 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 }
|
||||
// Account for hosting at a path
|
||||
guard let prefixURL = url.absoluteString.trimmingSuffix("/Branding/Splashscreen?").url else { return nil }
|
||||
|
||||
// We can assume that the request is from the current server
|
||||
let urlFilter: Where<ServerModel> = Where(\.$currentURL == prefixURL)
|
||||
guard let server = try? SwiftfinStore.dataStack.fetchOne(
|
||||
From<ServerModel>()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -150,8 +150,9 @@ extension Notifications.Key {
|
|||
|
||||
// MARK: - User
|
||||
|
||||
static var didChangeUserProfileImage: Key<Void> {
|
||||
Key("didChangeUserProfileImage")
|
||||
/// - Payload: The ID of the user whose Profile Image changed.
|
||||
static var didChangeUserProfile: Key<String> {
|
||||
Key("didChangeUserProfile")
|
||||
}
|
||||
|
||||
static var didAddServerUser: Key<UserDto> {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<BackgroundState> = []
|
||||
|
||||
@Published
|
||||
final var users: [UserDto] = []
|
||||
final var users: IdentifiedArrayOf<UserDto> = []
|
||||
|
||||
@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<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
|
||||
|
||||
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 ?? "" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
enum State: Hashable {
|
||||
case initial
|
||||
case uploading
|
||||
}
|
||||
|
||||
@Published
|
||||
var state: State = .initial
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.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 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ struct SwiftfinApp: App {
|
|||
return mimeType.contains("svg") ? ImageDecoders.Empty() : nil
|
||||
}
|
||||
|
||||
ImagePipeline.shared = .Swiftfin.default
|
||||
ImagePipeline.shared = .Swiftfin.posters
|
||||
|
||||
// UIKit
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1270,6 +1275,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -2462,6 +2468,15 @@
|
|||
path = ActiveSessionDetailView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E7315722D14752400EA2A95 /* UserProfileImage */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E7315732D14770E00EA2A95 /* UserProfileHeroImage.swift */,
|
||||
4E5508722D13AFE3002A5345 /* UserProfileImage.swift */,
|
||||
);
|
||||
path = UserProfileImage;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -54,7 +54,7 @@ struct SwiftfinApp: App {
|
|||
return mimeType.contains("svg") ? ImageDecoders.Empty() : nil
|
||||
}
|
||||
|
||||
ImagePipeline.shared = .Swiftfin.default
|
||||
ImagePipeline.shared = .Swiftfin.posters
|
||||
|
||||
// UIKit
|
||||
|
||||
|
|
|
@ -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<Item: Poster>: View {
|
||||
|
||||
private var item: Item
|
||||
|
@ -29,9 +33,9 @@ struct PosterButton<Item: Poster>: 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,31 +31,19 @@ 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
|
||||
),
|
||||
pipeline: .Swiftfin.local
|
||||
) {
|
||||
Color.clear
|
||||
}
|
||||
.onDisappear {
|
||||
isUserImage = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.clipShape(.circle)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(L10n.settings)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,14 +75,13 @@ 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)
|
||||
}
|
||||
UserProfileImage(
|
||||
userID: user.id,
|
||||
source: user.profileImageSource(
|
||||
client: userSession!.client,
|
||||
maxWidth: 60
|
||||
)
|
||||
)
|
||||
.grayscale(userActive ? 0.0 : 1.0)
|
||||
|
||||
if isEditing {
|
||||
|
@ -90,9 +89,6 @@ extension ServerUsersView {
|
|||
.opacity(isSelected ? 0 : 0.5)
|
||||
}
|
||||
}
|
||||
.clipShape(.circle)
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.posterShadow()
|
||||
.frame(width: 60, height: 60)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
UserProfileImage(
|
||||
userID: user.id,
|
||||
source: user.profileImageSource(
|
||||
client: server.client,
|
||||
maxWidth: 120
|
||||
),
|
||||
pipeline: .Swiftfin.local
|
||||
)
|
||||
}
|
||||
.placeholder { _ in
|
||||
personView
|
||||
}
|
||||
.failure {
|
||||
personView
|
||||
}
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.clipShape(.circle)
|
||||
.overlay {
|
||||
if isEditing {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -447,7 +447,7 @@ struct SelectUserView: View {
|
|||
Color.clear
|
||||
|
||||
ImageView(splashScreenImageSources)
|
||||
.pipeline(.Swiftfin.branding)
|
||||
.pipeline(.Swiftfin.local)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.id(splashScreenImageSources)
|
||||
.transition(.opacity)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)?
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
|
@ -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)
|
||||
UserProfileImage(
|
||||
userID: user.id,
|
||||
source: user.profileImageSource(
|
||||
client: client,
|
||||
maxWidth: 120
|
||||
)
|
||||
)
|
||||
}
|
||||
.placeholder { _ in
|
||||
personView
|
||||
}
|
||||
.failure {
|
||||
personView
|
||||
}
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.posterShadow()
|
||||
.clipShape(.circle)
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
Text(user.name ?? .emptyDash)
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
Loading…
Reference in New Issue