User Profile Image Selection (#1061)

This commit is contained in:
Ethan Pippin 2024-05-21 22:45:48 -06:00 committed by GitHub
parent 8d6167c00b
commit b2a31dbc3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 948 additions and 154 deletions

View File

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

View File

@ -11,18 +11,6 @@ import Nuke
import NukeUI import NukeUI
import SwiftUI 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. // 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 // 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 // TODO: `LazyImage` uses a transaction for view swapping, which will fade out old views
@ -37,6 +25,7 @@ struct ImageView: View {
private var sources: [ImageSource] private var sources: [ImageSource]
private var image: (Image) -> any View private var image: (Image) -> any View
private var pipeline: ImagePipeline
private var placeholder: ((ImageSource) -> any View)? private var placeholder: ((ImageSource) -> any View)?
private var failure: () -> any View private var failure: () -> any View
@ -70,7 +59,7 @@ struct ImageView: View {
} }
} }
} }
.pipeline(imagePipeline) .pipeline(pipeline)
} else { } else {
failure() failure()
.eraseToAnyView() .eraseToAnyView()
@ -81,43 +70,29 @@ struct ImageView: View {
extension ImageView { extension ImageView {
init(_ source: ImageSource) { init(_ source: ImageSource) {
self.init( self.init([source].compacted(using: \.url))
sources: [source].compacted(using: \.url),
image: { $0 },
placeholder: nil,
failure: { EmptyView() }
)
} }
init(_ sources: [ImageSource]) { init(_ sources: [ImageSource]) {
self.init( self.init(
sources: sources.compacted(using: \.url), sources: sources.compacted(using: \.url),
image: { $0 }, image: { $0 },
pipeline: .shared,
placeholder: nil, placeholder: nil,
failure: { EmptyView() } failure: { EmptyView() }
) )
} }
init(_ source: URL?) { init(_ source: URL?) {
self.init( self.init([ImageSource(url: source)])
sources: [ImageSource(url: source)],
image: { $0 },
placeholder: nil,
failure: { EmptyView() }
)
} }
init(_ sources: [URL?]) { init(_ sources: [URL?]) {
let imageSources = sources let imageSources = sources
.compactMap { $0 } .compacted()
.map { ImageSource(url: $0) } .map { ImageSource(url: $0) }
self.init( self.init(imageSources)
sources: imageSources,
image: { $0 },
placeholder: nil,
failure: { EmptyView() }
)
} }
} }
@ -129,6 +104,10 @@ extension ImageView {
copy(modifying: \.image, with: content) copy(modifying: \.image, with: content)
} }
func pipeline(_ pipeline: ImagePipeline) -> Self {
copy(modifying: \.pipeline, with: pipeline)
}
func placeholder(@ViewBuilder _ content: @escaping (ImageSource) -> any View) -> Self { func placeholder(@ViewBuilder _ content: @escaping (ImageSource) -> any View) -> Self {
copy(modifying: \.placeholder, with: content) copy(modifying: \.placeholder, with: content)
} }

View File

@ -74,8 +74,6 @@ final class MainCoordinator: NavigationCoordinatable {
// TODO: move these to the App instead? // TODO: move these to the App instead?
ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB
// Notification setup for state // Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))

View File

@ -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 // Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))

View File

@ -28,6 +28,8 @@ final class SettingsCoordinator: NavigationCoordinatable {
var resetUserPassword = makeResetUserPassword var resetUserPassword = makeResetUserPassword
@Route(.push) @Route(.push)
var localSecurity = makeLocalSecurity var localSecurity = makeLocalSecurity
@Route(.modal)
var photoPicker = makePhotoPicker
@Route(.push) @Route(.push)
var userProfile = makeUserProfileSettings var userProfile = makeUserProfileSettings
@ -86,6 +88,10 @@ final class SettingsCoordinator: NavigationCoordinatable {
UserLocalSecurityView() UserLocalSecurityView()
} }
func makePhotoPicker(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
NavigationViewCoordinator(UserProfileImageCoordinator())
}
@ViewBuilder @ViewBuilder
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View { func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
UserProfileSettingsView(viewModel: viewModel) UserProfileSettingsView(viewModel: viewModel)

View File

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

View File

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

View File

@ -13,8 +13,7 @@ extension UserDto {
func profileImageSource( func profileImageSource(
client: JellyfinClient, client: JellyfinClient,
maxWidth: CGFloat? = nil, maxWidth: CGFloat? = nil
maxHeight: CGFloat? = nil
) -> ImageSource { ) -> ImageSource {
UserState( UserState(
id: id ?? "", id: id ?? "",
@ -23,8 +22,7 @@ extension UserDto {
) )
.profileImageSource( .profileImageSource(
client: client, client: client,
maxWidth: maxWidth, maxWidth: maxWidth
maxHeight: maxHeight
) )
} }
} }

View File

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

View File

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

View File

@ -34,6 +34,10 @@ extension UIApplication {
} }
func setAppearance(_ newAppearance: UIUserInterfaceStyle) { func setAppearance(_ newAppearance: UIUserInterfaceStyle) {
keyWindow?.overrideUserInterfaceStyle = newAppearance guard let keyWindow else { return }
UIView.transition(with: keyWindow, duration: 0.2, options: .transitionCrossDissolve) {
keyWindow.overrideUserInterfaceStyle = newAppearance
}
} }
} }

View File

@ -59,6 +59,11 @@ extension URL {
} }
} }
// doesn't have `?` but doesn't matter
func pathAndQuery() -> String? {
path + (query ?? "")
}
var sizeOnDisk: Int { var sizeOnDisk: Int {
do { do {
guard let size = try directoryTotalAllocatedSize(includingSubfolders: true) else { return -1 } guard let size = try directoryTotalAllocatedSize(includingSubfolders: true) else { return -1 }

View File

@ -17,26 +17,34 @@ import Pulse
enum LogManager { enum LogManager {
static let service = Factory<Logger>(scope: .singleton) { static let service = Factory<Logger>(scope: .singleton) {
.init(label: "Swiftfin") Logger(label: "org.jellyfin.swiftfin")
} }
// TODO: make rules for logging sessions and redacting
static let pulseNetworkLogger = Factory<NetworkLogger>(scope: .singleton) { static let pulseNetworkLogger = Factory<NetworkLogger>(scope: .singleton) {
var configuration = NetworkLogger.Configuration() var configuration = NetworkLogger.Configuration()
configuration.willHandleEvent = { event -> LoggerStore.Event? in
switch event {
case let .networkTaskCreated(networkTask):
if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil {
return nil
}
case let .networkTaskCompleted(networkTask):
if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil {
return nil
}
default: ()
}
return event // TODO: this used to be necessary to stop the mass of image requests
} // clogging the logs, however don't seem necessary anymore?
// Find out how to get images to be logged and have an option to
// turn it on, via SuperUser.
// configuration.willHandleEvent = { event -> LoggerStore.Event? in
// switch event {
// case let .networkTaskCreated(networkTask):
// if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil {
// return nil
// }
// case let .networkTaskCompleted(networkTask):
// if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil {
// return nil
// }
// default: ()
// }
//
// return event
// }
return NetworkLogger(configuration: configuration) return NetworkLogger(configuration: configuration)
} }

View File

@ -90,7 +90,7 @@ extension Defaults.Keys {
static let selectUserUseSplashscreen: Key<Bool> = AppKey("selectUserUseSplashscreen", default: true) static let selectUserUseSplashscreen: Key<Bool> = AppKey("selectUserUseSplashscreen", default: true)
static let signOutOnBackground: Key<Bool> = AppKey("signOutOnBackground", default: true) static let signOutOnBackground: Key<Bool> = AppKey("signOutOnBackground", default: true)
static let signOutOnClose: Key<Bool> = AppKey("signOutOnClose", default: true) static let signOutOnClose: Key<Bool> = AppKey("signOutOnClose", default: false)
} }
// MARK: User // MARK: User

View File

@ -82,4 +82,6 @@ extension Notifications.Key {
static let didConnectToServer = NotificationKey("didConnectToServer") static let didConnectToServer = NotificationKey("didConnectToServer")
static let didDeleteServer = NotificationKey("didDeleteServer") static let didDeleteServer = NotificationKey("didDeleteServer")
static let didChangeUserProfileImage = NotificationKey("didChangeUserProfileImage")
} }

View File

@ -41,12 +41,12 @@ final class UserSession {
fileprivate extension Container.Scope { fileprivate extension Container.Scope {
static let userSessionScope = Cached() // static let userSessionScope = .
} }
extension UserSession { extension UserSession {
static let current = Factory<UserSession?>(scope: .userSessionScope) { static let current = Factory<UserSession?>(scope: .cached) {
if let lastUserID = Defaults[.lastSignedInUserID], if let lastUserID = Defaults[.lastSignedInUserID],
let user = try? SwiftfinStore.dataStack.fetchOne( let user = try? SwiftfinStore.dataStack.fetchOne(

View File

@ -140,15 +140,17 @@ extension UserState {
return response.value return response.value
} }
// we will always crop to a square, so just use width
func profileImageSource( func profileImageSource(
client: JellyfinClient, client: JellyfinClient,
maxWidth: CGFloat? = nil, maxWidth: CGFloat? = nil
maxHeight: CGFloat? = nil
) -> ImageSource { ) -> ImageSource {
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 parameters = Paths.GetUserImageParameters(maxWidth: scaleWidth, maxHeight: scaleHeight) let parameters = Paths.GetUserImageParameters(
tag: data.primaryImageTag,
maxWidth: scaleWidth
)
let request = Paths.getUserImage( let request = Paths.getUserImage(
userID: id, userID: id,
imageType: "Primary", imageType: "Primary",

View File

@ -182,13 +182,9 @@ final class ConnectToServerViewModel: ViewModel, Eventful, Stateful {
if url.scheme != response.scheme || if url.scheme != response.scheme ||
url.host != response.host url.host != response.host
{ {
var newURL = response.absoluteString.trimmingSuffix(Paths.getPublicSystemInfo.url?.absoluteString ?? "") let newURL = response.absoluteString.trimmingSuffix(
Paths.getPublicSystemInfo.url?.absoluteString ?? ""
// if ended in a "/" )
if url.absoluteString.last == "/" {
newURL.append("/")
}
return URL(string: newURL) ?? url return URL(string: newURL) ?? url
} }

View File

@ -11,8 +11,13 @@ import Defaults
import Factory import Factory
import Files import Files
import Foundation import Foundation
import JellyfinAPI
import UIKit 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 { final class SettingsViewModel: ViewModel {
@Published @Published
@ -57,6 +62,25 @@ 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

@ -0,0 +1,109 @@
//
// 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 Combine
import Foundation
import JellyfinAPI
import Nuke
import UIKit
class UserProfileImageViewModel: ViewModel, Eventful, Stateful {
enum Action: Equatable {
case cancel
case upload(UIImage)
}
enum Event: Hashable {
case error(JellyfinAPIError)
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()
}
private var eventSubject: PassthroughSubject<Event, Never> = .init()
private var uploadCancellable: AnyCancellable?
func respond(to action: Action) -> State {
switch action {
case .cancel:
uploadCancellable?.cancel()
return .initial
case let .upload(image):
uploadCancellable = Task {
do {
try await upload(image: image)
await MainActor.run {
self.eventSubject.send(.uploaded)
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 .uploading
}
}
private func upload(image: UIImage) async throws {
let contentType: String
let imageData: Data
if let pngData = image.pngData()?.base64EncodedData() {
contentType = "image/png"
imageData = pngData
} else if let jpgData = image.jpegData(compressionQuality: 1)?.base64EncodedData() {
contentType = "image/jpeg"
imageData = jpgData
} else {
logger.error("Unable to convert given profile image to png/jpg")
throw JellyfinAPIError("An internal error occurred")
}
var request = Paths.postUserImage(
userID: userSession.user.id,
imageType: "Primary",
imageData
)
request.headers = ["Content-Type": contentType]
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()
}
}
}

View File

@ -10,6 +10,7 @@ import CoreStore
import Defaults import Defaults
import Factory import Factory
import Logging import Logging
import Nuke
import Pulse import Pulse
import PulseLogHandler import PulseLogHandler
import SwiftUI import SwiftUI
@ -19,6 +20,11 @@ struct SwiftfinApp: App {
init() { init() {
// CoreStore
CoreStoreDefaults.dataStack = SwiftfinStore.dataStack
CoreStoreDefaults.logger = SwiftfinCorestoreLogger()
// Logging // Logging
LoggingSystem.bootstrap { label in LoggingSystem.bootstrap { label in
@ -31,8 +37,21 @@ struct SwiftfinApp: App {
return MultiplexLogHandler(loggers) return MultiplexLogHandler(loggers)
} }
CoreStoreDefaults.dataStack = SwiftfinStore.dataStack // Nuke
CoreStoreDefaults.logger = SwiftfinCorestoreLogger()
ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB
ImageCache.shared.ttl = 300 // 5 min
ImageDecoderRegistry.shared.register { context in
guard let mimeType = context.urlResponse?.mimeType else { return nil }
return mimeType.contains("svg") ? ImageDecoders.Empty() : nil
}
ImagePipeline.shared = .Swiftfin.default
// UIKit
UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label]
} }
var body: some Scene { var body: some Scene {

View File

@ -277,11 +277,15 @@
E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */; }; E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */; };
E11BDF972B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; E11BDF972B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; };
E11BDF982B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; E11BDF982B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; };
E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */; };
E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */; };
E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; };
E11CEB8B28998552003E74C7 /* View-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* View-iOS.swift */; }; E11CEB8B28998552003E74C7 /* View-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* View-iOS.swift */; };
E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; }; E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; };
E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */; }; E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */; };
E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */; }; E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */; };
E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E0E8B2BF7E76F007676DD /* DataCache.swift */; };
E11E0E8D2BF7E76F007676DD /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E0E8B2BF7E76F007676DD /* DataCache.swift */; };
E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D842902933F87500D1041A /* ItemFields.swift */; }; E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D842902933F87500D1041A /* ItemFields.swift */; };
E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */; }; E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */; };
E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */; }; E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */; };
@ -388,12 +392,24 @@
E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; };
E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; }; E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; };
E14E9DF22BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; }; E14E9DF22BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; };
E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA15D2BF6F72900DE757A /* PhotoPicker.swift */; };
E14EA1602BF6FF8900DE757A /* UserProfileImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA15F2BF6FF8900DE757A /* UserProfileImagePicker.swift */; };
E14EA1652BF70A8E00DE757A /* Mantis in Frameworks */ = {isa = PBXBuildFile; productRef = E14EA1642BF70A8E00DE757A /* Mantis */; };
E14EA1672BF70F9C00DE757A /* SquareImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA1662BF70F9C00DE757A /* SquareImageCropView.swift */; };
E14EA1692BF7330A00DE757A /* UserProfileImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */; };
E14EA16A2BF7333B00DE757A /* UserProfileImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */; };
E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; }; E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; };
E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; }; E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; };
E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; }; E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; };
E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; }; E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; };
E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; }; E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; };
E14EDECD2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; }; E14EDECD2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; };
E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0B92BFD44F500944FFA /* ImagePipeline.swift */; };
E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0B92BFD44F500944FFA /* ImagePipeline.swift */; };
E150C0BD2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */; };
E150C0BE2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */; };
E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E150C0C02BFD62FD00944FFA /* JellyfinAPI */; };
E150C0C32BFD6DA200944FFA /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E150C0C22BFD6DA200944FFA /* JellyfinAPI */; };
E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E15210552946DF1B00375CC2 /* PulseLogHandler */; }; E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E15210552946DF1B00375CC2 /* PulseLogHandler */; };
E15210582946DF1B00375CC2 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E15210572946DF1B00375CC2 /* PulseUI */; }; E15210582946DF1B00375CC2 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E15210572946DF1B00375CC2 /* PulseUI */; };
E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; };
@ -531,8 +547,6 @@
E1763A642BF3C9AA004DF6AB /* ListRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */; }; E1763A642BF3C9AA004DF6AB /* ListRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */; };
E1763A662BF3CA83004DF6AB /* FullScreenMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A652BF3CA83004DF6AB /* FullScreenMenu.swift */; }; E1763A662BF3CA83004DF6AB /* FullScreenMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A652BF3CA83004DF6AB /* FullScreenMenu.swift */; };
E1763A6A2BF3D177004DF6AB /* PublicUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A692BF3D177004DF6AB /* PublicUserRow.swift */; }; E1763A6A2BF3D177004DF6AB /* PublicUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A692BF3D177004DF6AB /* PublicUserRow.swift */; };
E1763A6D2BF3DE17004DF6AB /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */; };
E1763A6F2BF3DE23004DF6AB /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */; };
E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; }; E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; };
E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; }; E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; };
E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */; }; E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */; };
@ -555,6 +569,8 @@
E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; }; E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; };
E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55828C125E900311DFE /* StudiosHStack.swift */; }; E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55828C125E900311DFE /* StudiosHStack.swift */; };
E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55A28C1266400311DFE /* GenresHStack.swift */; }; E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55A28C1266400311DFE /* GenresHStack.swift */; };
E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1803EA02BFBD6CF0039F90E /* Hashable.swift */; };
E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1803EA02BFBD6CF0039F90E /* Hashable.swift */; };
E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; }; E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; };
E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E18443CA2A037773002DDDC8 /* UDPBroadcast */; }; E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E18443CA2A037773002DDDC8 /* UDPBroadcast */; };
E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */; }; E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */; };
@ -1110,10 +1126,12 @@
E11BDF762B8513B40045C54A /* ItemGenre.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemGenre.swift; sourceTree = "<group>"; }; E11BDF762B8513B40045C54A /* ItemGenre.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemGenre.swift; sourceTree = "<group>"; };
E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedCaseIterable.swift; sourceTree = "<group>"; }; E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedCaseIterable.swift; sourceTree = "<group>"; };
E11BDF962B865F550045C54A /* ItemTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTag.swift; sourceTree = "<group>"; }; E11BDF962B865F550045C54A /* ItemTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTag.swift; sourceTree = "<group>"; };
E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageCoordinator.swift; sourceTree = "<group>"; };
E11CEB8A28998552003E74C7 /* View-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View-iOS.swift"; sourceTree = "<group>"; }; E11CEB8A28998552003E74C7 /* View-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View-iOS.swift"; sourceTree = "<group>"; };
E11CEB8C28999B4A003E74C7 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = "<group>"; }; E11CEB8C28999B4A003E74C7 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = "<group>"; };
E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = "<group>"; }; E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = "<group>"; };
E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemContentView.swift; sourceTree = "<group>"; }; E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemContentView.swift; sourceTree = "<group>"; };
E11E0E8B2BF7E76F007676DD /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = "<group>"; };
E122A9122788EAAD0060FA63 /* MediaStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStream.swift; sourceTree = "<group>"; }; E122A9122788EAAD0060FA63 /* MediaStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStream.swift; sourceTree = "<group>"; };
E12376AD2A33D680001F5B44 /* AboutView+Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AboutView+Card.swift"; sourceTree = "<group>"; }; E12376AD2A33D680001F5B44 /* AboutView+Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AboutView+Card.swift"; sourceTree = "<group>"; };
E12376AF2A33D6AE001F5B44 /* AboutViewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewCard.swift; sourceTree = "<group>"; }; E12376AF2A33D6AE001F5B44 /* AboutViewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewCard.swift; sourceTree = "<group>"; };
@ -1182,9 +1200,15 @@
E149CCAC2BE6ECC8008B9331 /* Storable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storable.swift; sourceTree = "<group>"; }; E149CCAC2BE6ECC8008B9331 /* Storable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storable.swift; sourceTree = "<group>"; };
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; }; E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLetter.swift; sourceTree = "<group>"; }; E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLetter.swift; sourceTree = "<group>"; };
E14EA15D2BF6F72900DE757A /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = "<group>"; };
E14EA15F2BF6FF8900DE757A /* UserProfileImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImagePicker.swift; sourceTree = "<group>"; };
E14EA1662BF70F9C00DE757A /* SquareImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquareImageCropView.swift; sourceTree = "<group>"; };
E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageViewModel.swift; sourceTree = "<group>"; };
E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyItemFilter.swift; sourceTree = "<group>"; }; E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyItemFilter.swift; sourceTree = "<group>"; };
E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterType.swift; sourceTree = "<group>"; }; E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterType.swift; sourceTree = "<group>"; };
E14EDECB2B8FB709000F00A4 /* ItemYear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemYear.swift; sourceTree = "<group>"; }; E14EDECB2B8FB709000F00A4 /* ItemYear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemYear.swift; sourceTree = "<group>"; };
E150C0B92BFD44F500944FFA /* ImagePipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipeline.swift; sourceTree = "<group>"; };
E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnNotificationView.swift; sourceTree = SOURCE_ROOT; };
E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedLightAppIcon.swift; sourceTree = "<group>"; }; E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedLightAppIcon.swift; sourceTree = "<group>"; };
E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = "<group>"; }; E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = "<group>"; };
E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = "<group>"; }; E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = "<group>"; };
@ -1263,6 +1287,7 @@
E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; }; E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; };
E17FB55828C125E900311DFE /* StudiosHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudiosHStack.swift; sourceTree = "<group>"; }; E17FB55828C125E900311DFE /* StudiosHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudiosHStack.swift; sourceTree = "<group>"; };
E17FB55A28C1266400311DFE /* GenresHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenresHStack.swift; sourceTree = "<group>"; }; E17FB55A28C1266400311DFE /* GenresHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenresHStack.swift; sourceTree = "<group>"; };
E1803EA02BFBD6CF0039F90E /* Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hashable.swift; sourceTree = "<group>"; };
E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; }; E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; };
E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = "<group>"; }; E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = "<group>"; };
E185920928CEF23A00326F80 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = "<group>"; }; E185920928CEF23A00326F80 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = "<group>"; };
@ -1527,7 +1552,7 @@
62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */,
E132D3CF2BD217AA0058A2DF /* CollectionVGrid in Frameworks */, E132D3CF2BD217AA0058A2DF /* CollectionVGrid in Frameworks */,
E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */, E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */,
E1763A6F2BF3DE23004DF6AB /* JellyfinAPI in Frameworks */, E150C0C32BFD6DA200944FFA /* JellyfinAPI in Frameworks */,
E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */, E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */,
62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */, 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */,
E13AF3B628A0C598009093AB /* Nuke in Frameworks */, E13AF3B628A0C598009093AB /* Nuke in Frameworks */,
@ -1547,7 +1572,6 @@
E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, E1002B682793CFBA00E47059 /* Algorithms in Frameworks */,
E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */, E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */,
62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */, 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */,
E1763A6D2BF3DE17004DF6AB /* JellyfinAPI in Frameworks */,
E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, E15210582946DF1B00375CC2 /* PulseUI in Frameworks */,
E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */, E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */,
62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */,
@ -1557,6 +1581,7 @@
E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */, E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */,
E10706102942F57D00646DAF /* Pulse in Frameworks */, E10706102942F57D00646DAF /* Pulse in Frameworks */,
E192608328D2D0DB002314B4 /* Factory in Frameworks */, E192608328D2D0DB002314B4 /* Factory in Frameworks */,
E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */,
E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */, E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */,
E1523F822B132C350062821A /* CollectionHStack in Frameworks */, E1523F822B132C350062821A /* CollectionHStack in Frameworks */,
E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */, E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */,
@ -1564,6 +1589,7 @@
62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */,
62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */, 62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */,
E1575E3C293C6B15001665B1 /* Files in Frameworks */, E1575E3C293C6B15001665B1 /* Files in Frameworks */,
E14EA1652BF70A8E00DE757A /* Mantis in Frameworks */,
62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */, 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */,
62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */,
E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */, E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */,
@ -1643,6 +1669,7 @@
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */,
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */,
E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */, E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */,
E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */,
E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */,
BD0BA2292AD6501300306A8D /* VideoPlayerManager */, BD0BA2292AD6501300306A8D /* VideoPlayerManager */,
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */,
@ -2054,10 +2081,12 @@
E133328729538D8D00EE76AB /* Files.swift */, E133328729538D8D00EE76AB /* Files.swift */,
E11CEB8C28999B4A003E74C7 /* Font.swift */, E11CEB8C28999B4A003E74C7 /* Font.swift */,
E10432F52BE4426F006FF9DD /* FormatStyle.swift */, E10432F52BE4426F006FF9DD /* FormatStyle.swift */,
E1803EA02BFBD6CF0039F90E /* Hashable.swift */,
E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */,
E139CC1E28EC83E400688DE2 /* Int.swift */, E139CC1E28EC83E400688DE2 /* Int.swift */,
E1AD105226D96D5F003E4A08 /* JellyfinAPI */, E1AD105226D96D5F003E4A08 /* JellyfinAPI */,
E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */, E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */,
E150C0B82BFD44E900944FFA /* Nuke */,
E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */,
E1B5861129E32EEF00E45D6E /* Sequence.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */,
E145EB442BE0AD4E003BF6F3 /* Set.swift */, E145EB442BE0AD4E003BF6F3 /* Set.swift */,
@ -2105,8 +2134,9 @@
E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */, E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */,
E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */, E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */,
6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */, 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */,
6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */,
E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */, E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */,
6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */,
E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */,
E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */,
E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */, E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */,
E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */, E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */,
@ -2616,6 +2646,24 @@
path = StoredValue; path = StoredValue;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E14EA1612BF6FF8D00DE757A /* UserProfileImagePicker */ = {
isa = PBXGroup;
children = (
E14EA1622BF7008A00DE757A /* Components */,
E14EA15F2BF6FF8900DE757A /* UserProfileImagePicker.swift */,
);
path = UserProfileImagePicker;
sourceTree = "<group>";
};
E14EA1622BF7008A00DE757A /* Components */ = {
isa = PBXGroup;
children = (
E14EA15D2BF6F72900DE757A /* PhotoPicker.swift */,
E14EA1662BF70F9C00DE757A /* SquareImageCropView.swift */,
);
path = Components;
sourceTree = "<group>";
};
E14EDECA2B8FB66F000F00A4 /* ItemFilter */ = { E14EDECA2B8FB66F000F00A4 /* ItemFilter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2643,12 +2691,22 @@
path = ItemView; path = ItemView;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E150C0B82BFD44E900944FFA /* Nuke */ = {
isa = PBXGroup;
children = (
E11E0E8B2BF7E76F007676DD /* DataCache.swift */,
E150C0B92BFD44F500944FFA /* ImagePipeline.swift */,
);
path = Nuke;
sourceTree = "<group>";
};
E1545BD62BDC559500D9578F /* UserProfileSettingsView */ = { E1545BD62BDC559500D9578F /* UserProfileSettingsView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */,
E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */, E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */,
E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */,
E14EA1612BF6FF8D00DE757A /* UserProfileImagePicker */,
E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */,
); );
path = UserProfileSettingsView; path = UserProfileSettingsView;
@ -3114,6 +3172,7 @@
E1D37F472B9C648E00343D2B /* MaxHeightText.swift */, E1D37F472B9C648E00343D2B /* MaxHeightText.swift */,
E1DC983F296DEBA500982F06 /* PosterIndicators */, E1DC983F296DEBA500982F06 /* PosterIndicators */,
E1FE69A628C29B720021BC93 /* ProgressBar.swift */, E1FE69A628C29B720021BC93 /* ProgressBar.swift */,
E150C0BC2BFD45BD00944FFA /* RedrawOnNotificationView.swift */,
E187A60129AB28F0008387E6 /* RotateContentView.swift */, E187A60129AB28F0008387E6 /* RotateContentView.swift */,
E18E01FF288749200022598C /* RowDivider.swift */, E18E01FF288749200022598C /* RowDivider.swift */,
E1E1643D28BB074000323B0A /* SelectorView.swift */, E1E1643D28BB074000323B0A /* SelectorView.swift */,
@ -3487,7 +3546,7 @@
E1153DD12BBB649C00424D36 /* SVGKit */, E1153DD12BBB649C00424D36 /* SVGKit */,
E132D3CE2BD217AA0058A2DF /* CollectionVGrid */, E132D3CE2BD217AA0058A2DF /* CollectionVGrid */,
E19D41B12BF2BFA50082B8B2 /* KeychainSwift */, E19D41B12BF2BFA50082B8B2 /* KeychainSwift */,
E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */, E150C0C22BFD6DA200944FFA /* JellyfinAPI */,
); );
productName = "JellyfinPlayer tvOS"; productName = "JellyfinPlayer tvOS";
productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */;
@ -3542,7 +3601,8 @@
E132D3C72BD200C10058A2DF /* CollectionVGrid */, E132D3C72BD200C10058A2DF /* CollectionVGrid */,
E132D3CC2BD2179C0058A2DF /* CollectionVGrid */, E132D3CC2BD2179C0058A2DF /* CollectionVGrid */,
E145EB4A2BE16849003BF6F3 /* KeychainSwift */, E145EB4A2BE16849003BF6F3 /* KeychainSwift */,
E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */, E14EA1642BF70A8E00DE757A /* Mantis */,
E150C0C02BFD62FD00944FFA /* JellyfinAPI */,
); );
productName = JellyfinPlayer; productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
@ -3614,7 +3674,8 @@
E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */, E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */,
E132D3CB2BD2179C0058A2DF /* XCRemoteSwiftPackageReference "CollectionVGrid" */, E132D3CB2BD2179C0058A2DF /* XCRemoteSwiftPackageReference "CollectionVGrid" */,
E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */, E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */,
E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */,
E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */,
); );
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -3834,6 +3895,7 @@
C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
E1575E95293E7B1E001665B1 /* Font.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */,
E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */, E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */,
E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */, E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */,
@ -3854,6 +3916,7 @@
E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */,
E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */, E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */,
E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */, E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */,
E14EA16A2BF7333B00DE757A /* UserProfileImageViewModel.swift in Sources */,
E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */, E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */,
E12376B12A33DB33001F5B44 /* MediaSourceInfoCoordinator.swift in Sources */, E12376B12A33DB33001F5B44 /* MediaSourceInfoCoordinator.swift in Sources */,
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */,
@ -3866,6 +3929,7 @@
E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */, E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */,
E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */,
E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */, E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */,
E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */,
E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */,
E1575E93293E7B1E001665B1 /* Double.swift in Sources */, E1575E93293E7B1E001665B1 /* Double.swift in Sources */,
E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */, E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */,
@ -3893,6 +3957,8 @@
62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */,
62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */,
E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */,
E11E0E8D2BF7E76F007676DD /* DataCache.swift in Sources */,
E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */, E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */,
E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */,
E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */,
@ -3934,6 +4000,7 @@
E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */, E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */,
E19D41B32BF2BFEF0082B8B2 /* URLSessionConfiguration.swift in Sources */, E19D41B32BF2BFEF0082B8B2 /* URLSessionConfiguration.swift in Sources */,
E10B1ECE2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */, E10B1ECE2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */,
E150C0BE2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */,
E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */,
E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */,
@ -4137,6 +4204,7 @@
E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */, E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */,
E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */,
E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */,
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */,
E11895AF2893840F0042947B /* NavigationBarOffsetView.swift in Sources */, E11895AF2893840F0042947B /* NavigationBarOffsetView.swift in Sources */,
@ -4161,6 +4229,7 @@
E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */, E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */,
E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */, E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */,
E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */,
E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */,
E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */,
E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */,
E18E01FA288747580022598C /* AboutAppView.swift in Sources */, E18E01FA288747580022598C /* AboutAppView.swift in Sources */,
@ -4257,6 +4326,7 @@
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */,
E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */,
E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */,
@ -4354,6 +4424,7 @@
E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */,
E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */,
E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
E14EA1602BF6FF8900DE757A /* UserProfileImagePicker.swift in Sources */,
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */,
E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */,
E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */,
@ -4392,11 +4463,14 @@
E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */,
E12376AE2A33D680001F5B44 /* AboutView+Card.swift in Sources */, E12376AE2A33D680001F5B44 /* AboutView+Card.swift in Sources */,
E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */,
E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */,
E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */, E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */,
E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */, E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */,
BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */, BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */,
E14EA1672BF70F9C00DE757A /* SquareImageCropView.swift in Sources */,
E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */, E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */,
E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */,
E14EA1692BF7330A00DE757A /* UserProfileImageViewModel.swift in Sources */,
E18ACA952A15A3E100BB4F35 /* (null) in Sources */, E18ACA952A15A3E100BB4F35 /* (null) in Sources */,
E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */, E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */,
E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */,
@ -4414,6 +4488,7 @@
E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */, E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */,
E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */, E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */,
62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
E150C0BD2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */,
E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */,
E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */, E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */,
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
@ -4429,6 +4504,7 @@
E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */, E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */,
E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */,
4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */,
E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */,
E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */,
E1D8429329340B8300D1041A /* Utilities.swift in Sources */, E1D8429329340B8300D1041A /* Utilities.swift in Sources */,
E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */, E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */,
@ -5031,6 +5107,22 @@
minimumVersion = 24.0.0; minimumVersion = 24.0.0;
}; };
}; };
E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/guoyingtao/Mantis";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 0.3.0;
};
};
E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */ = { E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Pulse"; repositoryURL = "https://github.com/kean/Pulse";
@ -5047,14 +5139,6 @@
minimumVersion = 4.0.0; minimumVersion = 4.0.0;
}; };
}; };
E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.3.4;
};
};
E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */ = { E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/LePips/VLCUI"; repositoryURL = "https://github.com/LePips/VLCUI";
@ -5263,6 +5347,21 @@
package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */; package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift; productName = KeychainSwift;
}; };
E14EA1642BF70A8E00DE757A /* Mantis */ = {
isa = XCSwiftPackageProductDependency;
package = E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */;
productName = Mantis;
};
E150C0C02BFD62FD00944FFA /* JellyfinAPI */ = {
isa = XCSwiftPackageProductDependency;
package = E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */;
productName = JellyfinAPI;
};
E150C0C22BFD6DA200944FFA /* JellyfinAPI */ = {
isa = XCSwiftPackageProductDependency;
package = E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */;
productName = JellyfinAPI;
};
E15210532946DF1B00375CC2 /* Pulse */ = { E15210532946DF1B00375CC2 /* Pulse */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */;
@ -5310,16 +5409,6 @@
package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = SwiftUIIntrospect; productName = SwiftUIIntrospect;
}; };
E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */ = {
isa = XCSwiftPackageProductDependency;
package = E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */;
productName = JellyfinAPI;
};
E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */ = {
isa = XCSwiftPackageProductDependency;
package = E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */;
productName = JellyfinAPI;
};
E18443CA2A037773002DDDC8 /* UDPBroadcast */ = { E18443CA2A037773002DDDC8 /* UDPBroadcast */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */; package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */;

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "68a42015b2d42a14d418b6e13427443de55c970d5b3764bbc969e1b3f8c3a78b", "originHash" : "323b2ad9aaa9c000faf264d68272f0e9fab1349d9f910a0b95ee6aea10460f31",
"pins" : [ "pins" : [
{ {
"identity" : "blurhashkit", "identity" : "blurhashkit",
@ -34,7 +34,7 @@
"location" : "https://github.com/LePips/CollectionVGrid", "location" : "https://github.com/LePips/CollectionVGrid",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "7204e5f717ea571efb4600ecb71c2412e0dec921" "revision" : "b50b5241df5fc1d71e5a09f6a87731c67c2a79e5"
} }
}, },
{ {
@ -109,13 +109,22 @@
"version" : "24.0.0" "version" : "24.0.0"
} }
}, },
{
"identity" : "mantis",
"kind" : "remoteSourceControl",
"location" : "https://github.com/guoyingtao/Mantis",
"state" : {
"revision" : "ccc498ea429774d948a0a8aacacde207f7ffff48",
"version" : "2.21.0"
}
},
{ {
"identity" : "nuke", "identity" : "nuke",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke", "location" : "https://github.com/kean/Nuke",
"state" : { "state" : {
"revision" : "8e431251dea0081b6ab154dab61a6ec74e4b6577", "revision" : "7395c7a9dcd390bbcfad17a731d8d529602702c6",
"version" : "12.6.0" "version" : "12.7.0"
} }
}, },
{ {
@ -123,8 +132,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Pulse", "location" : "https://github.com/kean/Pulse",
"state" : { "state" : {
"revision" : "4f34c4f91cda623a7627e6d5e35dbbbb514b8daa", "revision" : "d1e39ffaaa8b8becff80cb193c93a78e32077af8",
"version" : "4.1.1" "version" : "4.2.0"
} }
}, },
{ {
@ -195,8 +204,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect", "location" : "https://github.com/siteline/SwiftUI-Introspect",
"state" : { "state" : {
"revision" : "7dc5b287f8040e4ad5038739850b758e78f77808", "revision" : "0cd2a5a5895306bc21d54a2254302d24a9a571e4",
"version" : "1.1.4" "version" : "1.1.3"
} }
}, },
{ {

View File

@ -10,6 +10,7 @@ import CoreStore
import Defaults import Defaults
import Factory import Factory
import Logging import Logging
import Nuke
import PreferencesView import PreferencesView
import Pulse import Pulse
import PulseLogHandler import PulseLogHandler
@ -26,6 +27,11 @@ struct SwiftfinApp: App {
init() { init() {
// CoreStore
CoreStoreDefaults.dataStack = SwiftfinStore.dataStack
CoreStoreDefaults.logger = SwiftfinCorestoreLogger()
// Logging // Logging
LoggingSystem.bootstrap { label in LoggingSystem.bootstrap { label in
@ -38,14 +44,27 @@ struct SwiftfinApp: App {
return MultiplexLogHandler(loggers) return MultiplexLogHandler(loggers)
} }
CoreStoreDefaults.dataStack = SwiftfinStore.dataStack // Nuke
CoreStoreDefaults.logger = SwiftfinCorestoreLogger()
ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB
ImageCache.shared.ttl = 300 // 5 min
ImageDecoderRegistry.shared.register { context in
guard let mimeType = context.urlResponse?.mimeType else { return nil }
return mimeType.contains("svg") ? ImageDecoders.Empty() : nil
}
ImagePipeline.shared = .Swiftfin.default
// UIKit
UIScrollView.appearance().keyboardDismissMode = .onDrag UIScrollView.appearance().keyboardDismissMode = .onDrag
// Sometimes the tab bar won't appear properly on push, always have material background // Sometimes the tab bar won't appear properly on push, always have material background
UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified) UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified)
// Swiftfin
// don't keep last user id // don't keep last user id
if Defaults[.signOutOnClose] { if Defaults[.signOutOnClose] {
Defaults[.lastSignedInUserID] = nil Defaults[.lastSignedInUserID] = nil

View File

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

View File

@ -175,7 +175,7 @@ struct ConnectToServerView: View {
router.popLast() router.popLast()
} }
} message: { server in } message: { server in
Text("\(server.name) is already connected.") L10n.serverAlreadyExistsPrompt(server.name).text
} }
} }
} }

View File

@ -79,7 +79,7 @@ extension SelectUserView {
if serverSelection == .all { if serverSelection == .all {
Menu { Menu {
Text("Select server") Text("Select Server")
ForEach(servers) { server in ForEach(servers) { server in
Button { Button {

View File

@ -84,7 +84,7 @@ extension SelectUserView {
if serverSelection == .all { if serverSelection == .all {
Menu { Menu {
Text("Select server") Text("Select Server")
ForEach(servers) { server in ForEach(servers) { server in
Button { Button {

View File

@ -77,6 +77,7 @@ extension SelectUserView {
Color.clear Color.clear
ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) ImageView(user.profileImageSource(client: server.client, maxWidth: 120))
.pipeline(.Swiftfin.branding)
.image { image in .image { image in
image image
.posterBorder(ratio: 1 / 2, of: \.width) .posterBorder(ratio: 1 / 2, of: \.width)
@ -88,7 +89,7 @@ extension SelectUserView {
personView personView
} }
} }
.aspectRatio(1, contentMode: .fill) .aspectRatio(contentMode: .fill)
.clipShape(.circle) .clipShape(.circle)
.overlay { .overlay {
if isEditing { if isEditing {

View File

@ -77,6 +77,7 @@ extension SelectUserView {
Color.clear Color.clear
ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) ImageView(user.profileImageSource(client: server.client, maxWidth: 120))
.pipeline(.Swiftfin.branding)
.image { image in .image { image in
image image
.posterBorder(ratio: 1 / 2, of: \.width) .posterBorder(ratio: 1 / 2, of: \.width)
@ -93,7 +94,7 @@ extension SelectUserView {
.opacity(isSelected ? 0 : 0.5) .opacity(isSelected ? 0 : 0.5)
} }
} }
.aspectRatio(1, contentMode: .fill) .aspectRatio(contentMode: .fill)
.clipShape(.circle) .clipShape(.circle)
} }

View File

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

View File

@ -20,13 +20,16 @@ extension SettingsView {
@ViewBuilder @ViewBuilder
private var imageView: some View { private var imageView: some View {
ImageView(userSession.user.profileImageSource(client: userSession.client, maxWidth: 120, maxHeight: 120)) RedrawOnNotificationView(.didChangeUserProfileImage) {
.placeholder { _ in ImageView(userSession.user.profileImageSource(client: userSession.client, maxWidth: 120))
SystemImageContentView(systemName: "person.fill", ratio: 0.5) .pipeline(.Swiftfin.branding)
} .placeholder { _ in
.failure { SystemImageContentView(systemName: "person.fill", ratio: 0.5)
SystemImageContentView(systemName: "person.fill", ratio: 0.5) }
} .failure {
SystemImageContentView(systemName: "person.fill", ratio: 0.5)
}
}
} }
var body: some View { var body: some View {
@ -34,6 +37,10 @@ extension SettingsView {
action() action()
} label: { } label: {
HStack { HStack {
// TODO: check properly with non-uniform images and look for workaround
// Note: for an unknown reason, using a non uniform aspect ratio will cause a
// "view origin is invalid" crash within SwiftUI
imageView imageView
.aspectRatio(1, contentMode: .fill) .aspectRatio(1, contentMode: .fill)
.clipShape(.circle) .clipShape(.circle)

View File

@ -0,0 +1,78 @@
//
// 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 PhotosUI
import SwiftUI
// TODO: polish: find way to deselect image on appear
// - from popping from cropping
// TODO: polish: when image is picked, instead of loading it here
// which takes ~1-2s, show some kind of loading indicator
// on this view or push to another view that will go to crop
extension UserProfileImagePicker {
struct PhotoPicker: UIViewControllerRepresentable {
var onCancel: () -> Void
var onSelectedImage: (UIImage) -> Void
init(onCancel: @escaping () -> Void, onSelectedImage: @escaping (UIImage) -> Void) {
self.onCancel = onCancel
self.onSelectedImage = onSelectedImage
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .all(of: [.images, .not(.livePhotos)])
configuration.preferredAssetRepresentationMode = .current
configuration.selection = .ordered
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
context.coordinator.onCancel = onCancel
context.coordinator.onSelectedImage = onSelectedImage
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: PHPickerViewControllerDelegate {
var onCancel: (() -> Void)?
var onSelectedImage: ((UIImage) -> Void)?
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let image = results.first else {
onCancel?()
return
}
let itemProvider = image.itemProvider
if itemProvider.canLoadObject(ofClass: UIImage.self) {
itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
if let image = image as? UIImage {
self.onSelectedImage?(image)
}
}
}
}
}
}
}

View File

@ -0,0 +1,193 @@
//
// 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 Mantis
import SwiftUI
extension UserProfileImagePicker {
struct SquareImageCropView: View {
@Default(.accentColor)
private var accentColor
@EnvironmentObject
private var router: UserProfileImageCoordinator.Router
@State
private var error: Error? = nil
@State
private var isPresentingError: Bool = false
@StateObject
private var proxy: _SquareImageCropView.Proxy = .init()
@StateObject
private var viewModel = UserProfileImageViewModel()
let image: UIImage
var body: some View {
_SquareImageCropView(initialImage: image, proxy: proxy) {
viewModel.send(.upload($0))
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.interactiveDismissDisabled(viewModel.state == .uploading)
.navigationBarBackButtonHidden(viewModel.state == .uploading)
.topBarTrailing {
if viewModel.state == .initial {
Button("Rotate", systemImage: "rotate.right") {
proxy.rotate()
}
.foregroundStyle(.gray)
}
if viewModel.state == .uploading {
Button(L10n.cancel) {
viewModel.send(.cancel)
}
.foregroundStyle(.red)
} else {
Button {
proxy.crop()
} label: {
Text("Save")
.foregroundStyle(accentColor.overlayColor)
.font(.headline)
.padding(.vertical, 5)
.padding(.horizontal, 10)
.background {
accentColor
}
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
.toolbar {
ToolbarItem(placement: .principal) {
if viewModel.state == .uploading {
ProgressView()
} else {
Button("Reset") {
proxy.reset()
}
.foregroundStyle(.yellow)
.disabled(viewModel.state == .uploading)
}
}
}
.ignoresSafeArea()
.background {
Color.black
}
.onReceive(viewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
isPresentingError = true
case .uploaded:
router.dismissCoordinator()
}
}
.alert(
L10n.error.text,
isPresented: $isPresentingError,
presenting: error
) { _ in
Button(L10n.dismiss, role: .destructive)
} message: { error in
Text(error.localizedDescription)
}
}
}
struct _SquareImageCropView: UIViewControllerRepresentable {
class Proxy: ObservableObject {
weak var cropViewController: CropViewController?
func crop() {
cropViewController?.crop()
}
func reset() {
cropViewController?.didSelectReset()
}
func rotate() {
cropViewController?.didSelectClockwiseRotate()
}
}
let initialImage: UIImage
let proxy: Proxy
let onImageCropped: (UIImage) -> Void
func makeUIViewController(context: Context) -> some UIViewController {
var config = Mantis.Config()
config.cropViewConfig.backgroundColor = .black.withAlphaComponent(0.9)
config.cropViewConfig.cropShapeType = .square
config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 1)
config.showAttachedCropToolbar = false
let cropViewController = Mantis.cropViewController(
image: initialImage,
config: config
)
cropViewController.delegate = context.coordinator
context.coordinator.onImageCropped = onImageCropped
proxy.cropViewController = cropViewController
return cropViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: CropViewControllerDelegate {
var onImageCropped: ((UIImage) -> Void)?
func cropViewControllerDidCrop(
_ cropViewController: CropViewController,
cropped: UIImage,
transformation: Transformation,
cropInfo: CropInfo
) {
onImageCropped?(cropped)
}
func cropViewControllerDidCancel(
_ cropViewController: CropViewController,
original: UIImage
) {}
func cropViewControllerDidFailToCrop(
_ cropViewController: CropViewController,
original: UIImage
) {}
func cropViewControllerDidBeginResize(
_ cropViewController: CropViewController
) {}
func cropViewControllerDidEndResize(
_ cropViewController: Mantis.CropViewController,
original: UIImage,
cropInfo: Mantis.CropInfo
) {}
}
}
}

View File

@ -0,0 +1,23 @@
//
// 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 UserProfileImagePicker: View {
@EnvironmentObject
private var router: UserProfileImageCoordinator.Router
var body: some View {
PhotoPicker {
router.dismissCoordinator()
} onSelectedImage: { image in
router.route(to: \.cropImage, image)
}
}
}

View File

@ -6,11 +6,15 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors // Copyright (c) 2024 Jellyfin & Jellyfin Contributors
// //
import Defaults
import Factory import Factory
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
@ -19,21 +23,28 @@ struct UserProfileSettingsView: View {
@State @State
private var isPresentingConfirmReset: Bool = false private var isPresentingConfirmReset: Bool = false
@State
private var isPresentingProfileImageOptions: Bool = false
@ViewBuilder @ViewBuilder
private var imageView: some View { private var imageView: some View {
ImageView( RedrawOnNotificationView(name: .init("didChangeUserProfileImage")) {
viewModel.userSession.user.profileImageSource( ImageView(
client: viewModel.userSession.client, viewModel.userSession.user.profileImageSource(
maxWidth: 120, client: viewModel.userSession.client,
maxHeight: 120 maxWidth: 120
)
) )
) .pipeline(.Swiftfin.branding)
.placeholder { _ in .image { image in
SystemImageContentView(systemName: "person.fill", ratio: 0.5) image.posterBorder(ratio: 1 / 2, of: \.width)
} }
.failure { .placeholder { _ in
SystemImageContentView(systemName: "person.fill", ratio: 0.5) SystemImageContentView(systemName: "person.fill", ratio: 0.5)
}
.failure {
SystemImageContentView(systemName: "person.fill", ratio: 0.5)
}
} }
} }
@ -42,18 +53,21 @@ struct UserProfileSettingsView: View {
Section { Section {
VStack(alignment: .center) { VStack(alignment: .center) {
Button { Button {
// TODO: photo picker isPresentingProfileImageOptions = true
} label: { } label: {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
imageView imageView
.frame(width: 150, height: 150) .aspectRatio(contentMode: .fill)
.clipShape(.circle) .clipShape(.circle)
.frame(width: 150, height: 150)
.shadow(radius: 5) .shadow(radius: 5)
// TODO: uncomment when photo picker implemented Image(systemName: "pencil.circle.fill")
// Image(systemName: "pencil.circle.fill") .resizable()
// .resizable() .frame(width: 30, height: 30)
// .frame(width: 30, height: 30) .shadow(radius: 10)
.symbolRenderingMode(.palette)
.foregroundStyle(accentColor.overlayColor, accentColor)
} }
} }
@ -106,5 +120,19 @@ struct UserProfileSettingsView: View {
} message: { } message: {
Text("Are you sure you want to reset all user settings?") 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("Delete", role: .destructive) {
viewModel.deleteCurrentUserProfileImage()
}
}
} }
} }

View File

@ -57,6 +57,7 @@ extension UserSignInView {
Color.clear Color.clear
ImageView(user.profileImageSource(client: client, maxWidth: 120)) ImageView(user.profileImageSource(client: client, maxWidth: 120))
.pipeline(.Swiftfin.branding)
.image { image in .image { image in
image image
.posterBorder(ratio: 0.5, of: \.width) .posterBorder(ratio: 0.5, of: \.width)