Skip to content

Commit

Permalink
User Profile Image Selection (#1061)
Browse files Browse the repository at this point in the history
  • Loading branch information
LePips authored May 22, 2024
1 parent 8d6167c commit b2a31db
Show file tree
Hide file tree
Showing 37 changed files with 949 additions and 155 deletions.
36 changes: 36 additions & 0 deletions RedrawOnNotificationView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import SwiftUI

struct RedrawOnNotificationView<Content: View>: View {

@State
private var id = 0

private let name: NSNotification.Name
private let content: () -> Content

init(name: NSNotification.Name, @ViewBuilder content: @escaping () -> Content) {
self.name = name
self.content = content
}

init(_ swiftfinNotification: Notifications.Key, @ViewBuilder content: @escaping () -> Content) {
self.name = swiftfinNotification.underlyingNotification.name
self.content = content
}

var body: some View {
content()
.id(id)
.onNotification(name) { _ in
id += 1
}
}
}
43 changes: 11 additions & 32 deletions Shared/Components/ImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,6 @@ import Nuke
import NukeUI
import SwiftUI

private let imagePipeline = {

ImageDecoderRegistry.shared.register { context in
guard let mimeType = context.urlResponse?.mimeType else { return nil }
return mimeType.contains("svg") ? ImageDecoders.Empty() : nil
}

return ImagePipeline(configuration: .withDataCache)
}()

// TODO: Binding inits?
// - instead of removing first source on failure, just safe index into sources
// TODO: currently SVGs are only supported for logos, which are only used in a few places.
// make it so when displaying an SVG there is a unified `image` caller modifier
// TODO: `LazyImage` uses a transaction for view swapping, which will fade out old views
Expand All @@ -37,6 +25,7 @@ struct ImageView: View {
private var sources: [ImageSource]

private var image: (Image) -> any View
private var pipeline: ImagePipeline
private var placeholder: ((ImageSource) -> any View)?
private var failure: () -> any View

Expand Down Expand Up @@ -70,7 +59,7 @@ struct ImageView: View {
}
}
}
.pipeline(imagePipeline)
.pipeline(pipeline)
} else {
failure()
.eraseToAnyView()
Expand All @@ -81,43 +70,29 @@ struct ImageView: View {
extension ImageView {

init(_ source: ImageSource) {
self.init(
sources: [source].compacted(using: \.url),
image: { $0 },
placeholder: nil,
failure: { EmptyView() }
)
self.init([source].compacted(using: \.url))
}

init(_ sources: [ImageSource]) {
self.init(
sources: sources.compacted(using: \.url),
image: { $0 },
pipeline: .shared,
placeholder: nil,
failure: { EmptyView() }
)
}

init(_ source: URL?) {
self.init(
sources: [ImageSource(url: source)],
image: { $0 },
placeholder: nil,
failure: { EmptyView() }
)
self.init([ImageSource(url: source)])
}

init(_ sources: [URL?]) {
let imageSources = sources
.compactMap { $0 }
.compacted()
.map { ImageSource(url: $0) }

self.init(
sources: imageSources,
image: { $0 },
placeholder: nil,
failure: { EmptyView() }
)
self.init(imageSources)
}
}

Expand All @@ -129,6 +104,10 @@ extension ImageView {
copy(modifying: \.image, with: content)
}

func pipeline(_ pipeline: ImagePipeline) -> Self {
copy(modifying: \.pipeline, with: pipeline)
}

func placeholder(@ViewBuilder _ content: @escaping (ImageSource) -> any View) -> Self {
copy(modifying: \.placeholder, with: content)
}
Expand Down
2 changes: 0 additions & 2 deletions Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ final class MainCoordinator: NavigationCoordinatable {

// TODO: move these to the App instead?

ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB

// Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
Expand Down
4 changes: 0 additions & 4 deletions Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,6 @@ final class MainCoordinator: NavigationCoordinatable {
}
}

ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory

UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label]

// Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
Expand Down
6 changes: 6 additions & 0 deletions Shared/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ final class SettingsCoordinator: NavigationCoordinatable {
var resetUserPassword = makeResetUserPassword
@Route(.push)
var localSecurity = makeLocalSecurity
@Route(.modal)
var photoPicker = makePhotoPicker
@Route(.push)
var userProfile = makeUserProfileSettings

Expand Down Expand Up @@ -86,6 +88,10 @@ final class SettingsCoordinator: NavigationCoordinatable {
UserLocalSecurityView()
}

func makePhotoPicker(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
NavigationViewCoordinator(UserProfileImageCoordinator())
}

@ViewBuilder
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
UserProfileSettingsView(viewModel: viewModel)
Expand Down
40 changes: 40 additions & 0 deletions Shared/Coordinators/UserProfileImageCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Stinsen
import SwiftUI

final class UserProfileImageCoordinator: NavigationCoordinatable {

let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start)

@Root
var start = makeStart

@Route(.push)
var cropImage = makeCropImage

func makeCropImage(image: UIImage) -> some View {
#if os(iOS)
UserProfileImagePicker.SquareImageCropView(
image: image
)
#else
AssertionFailureView("not implemented")
#endif
}

@ViewBuilder
func makeStart() -> some View {
#if os(iOS)
UserProfileImagePicker()
#else
AssertionFailureView("not implemented")
#endif
}
}
16 changes: 16 additions & 0 deletions Shared/Extensions/Hashable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Foundation

extension Hashable {

var hashString: String {
"\(hashValue)"
}
}
6 changes: 2 additions & 4 deletions Shared/Extensions/JellyfinAPI/UserDto.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ extension UserDto {

func profileImageSource(
client: JellyfinClient,
maxWidth: CGFloat? = nil,
maxHeight: CGFloat? = nil
maxWidth: CGFloat? = nil
) -> ImageSource {
UserState(
id: id ?? "",
Expand All @@ -23,8 +22,7 @@ extension UserDto {
)
.profileImageSource(
client: client,
maxWidth: maxWidth,
maxHeight: maxHeight
maxWidth: maxWidth
)
}
}
71 changes: 71 additions & 0 deletions Shared/Extensions/Nuke/DataCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import CoreStore
import Foundation
import Nuke

// TODO: when `Storage` is implemented, could allow limits on sizes

// Note: For better support with multi-url servers, ignore the
// host and only use path + query which has ids and tags

extension DataCache {
enum Swiftfin {}
}

extension DataCache.Swiftfin {

static let `default`: DataCache? = {
let dataCache = try? DataCache(name: "org.jellyfin.swiftfin") { name in
URL(string: name)?.pathAndQuery() ?? name
}

dataCache?.sizeLimit = 1024 * 1024 * 500 // 500 MB

return dataCache
}()

/// The `DataCache` used for images that should have longer lifetimes, usable without a
/// connection, and not affected by other caching size limits.
///
/// Current 150 MB is more than necessary.
static let branding: DataCache? = {
guard let root = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}

let path = root.appendingPathComponent("Cache/org.jellyfin.swiftfin.branding", isDirectory: true)

let dataCache = try? DataCache(path: path) { name in

// this adds some latency, but fine since
// this DataCache is special
if name.range(of: "Splashscreen") != nil {

// TODO: potential issue where url ends with `/`, if
// not found, retry with `/` appended
let prefix = name.trimmingSuffix("/Branding/Splashscreen?")

// can assume that we are only requesting a server with
// the key same as the current url
guard let prefixURL = URL(string: prefix) else { return name }
guard let server = try? SwiftfinStore.dataStack.fetchOne(
From<ServerModel>()
.where(\.$currentURL == prefixURL)
) else { return name }

return "\(server.id)-splashscreen"
} else {
return URL(string: name)?.pathAndQuery() ?? name
}
}

return dataCache
}()
}
29 changes: 29 additions & 0 deletions Shared/Extensions/Nuke/ImagePipeline.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Foundation
import Nuke

extension ImagePipeline {
enum Swiftfin {}
}

extension ImagePipeline.Swiftfin {

/// The default `ImagePipeline` to use for images that should be used
/// during normal usage with an active connection.
static let `default`: ImagePipeline = ImagePipeline {
$0.dataCache = DataCache.Swiftfin.default
}

/// The `ImagePipeline` used for images that should have longer lifetimes and usable
/// without a connection, like user profile images and server splashscreens.
static let branding: ImagePipeline = ImagePipeline {
$0.dataCache = DataCache.Swiftfin.branding
}
}
6 changes: 5 additions & 1 deletion Shared/Extensions/UIApplication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ extension UIApplication {
}

func setAppearance(_ newAppearance: UIUserInterfaceStyle) {
keyWindow?.overrideUserInterfaceStyle = newAppearance
guard let keyWindow else { return }

UIView.transition(with: keyWindow, duration: 0.2, options: .transitionCrossDissolve) {
keyWindow.overrideUserInterfaceStyle = newAppearance
}
}
}
5 changes: 5 additions & 0 deletions Shared/Extensions/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ extension URL {
}
}

// doesn't have `?` but doesn't matter
func pathAndQuery() -> String? {
path + (query ?? "")
}

var sizeOnDisk: Int {
do {
guard let size = try directoryTotalAllocatedSize(includingSubfolders: true) else { return -1 }
Expand Down
Loading

0 comments on commit b2a31db

Please sign in to comment.