User/Server Sign In Redesign (#1045)

This commit is contained in:
Ethan Pippin 2024-05-14 23:42:41 -06:00 committed by GitHub
parent e9baf2dd4f
commit 74b8b286c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
198 changed files with 9106 additions and 3622 deletions

View File

@ -0,0 +1,92 @@
//
// 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 BulletedList<Content: View>: View {
private var content: () -> Content
private var bullet: (Int) -> any View
var body: some View {
_VariadicView.Tree(BulletedListLayout(bullet: bullet)) {
content()
}
}
}
extension BulletedList {
init(@ViewBuilder _ content: @escaping () -> Content) {
self.init(
content: content,
bullet: { _ in
ZStack {
Text(" ")
Circle()
.frame(width: 8)
.padding(.trailing, 5)
}
}
)
}
func bullet(@ViewBuilder _ content: @escaping (Int) -> any View) -> Self {
copy(modifying: \.bullet, with: content)
}
}
extension BulletedList {
struct BulletedListLayout: _VariadicView_UnaryViewRoot {
var bullet: (Int) -> any View
@ViewBuilder
func body(children: _VariadicView.Children) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(zip(children.indices, children)), id: \.0) { child in
BulletedListItem(
bullet: AnyView(bullet(child.0)),
child: child.1
)
}
}
}
}
struct BulletedListItem<BulletContent: View, Bullet: View>: View {
@State
private var bulletSize: CGSize = .zero
@State
private var childSize: CGSize = .zero
let bullet: Bullet
let child: BulletContent
private var _bullet: some View {
bullet
.trackingSize($bulletSize)
}
// TODO: this can cause clipping issues with text since
// with .offset, find fix
var body: some View {
ZStack {
child
.trackingSize($childSize)
.overlay(alignment: .topLeading) {
_bullet
.offset(x: -bulletSize.width)
}
}
}
}
}

View File

@ -19,7 +19,8 @@ struct WatchedIndicator: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.resizable() .resizable()
.frame(width: size, height: size) .frame(width: size, height: size)
.paletteOverlayRendering(color: .white) .symbolRenderingMode(.palette)
.foregroundStyle(.white, Color.jellyfinPurple)
.padding(3) .padding(3)
} }
} }

View File

@ -47,9 +47,12 @@ struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
if selection.contains(element) { if selection.contains(element) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .backport
.frame(width: 20, height: 20) .fontWeight(.bold)
.paletteOverlayRendering() .aspectRatio(1, contentMode: .fit)
.frame(width: 24, height: 24)
.symbolRenderingMode(.palette)
.foregroundStyle(accentColor.overlayColor, accentColor)
} }
} }
} }

View File

@ -8,8 +8,39 @@
import SwiftUI import SwiftUI
// TODO: is the background color setting really the best way? // TODO: bottom view can probably just be cleaned up and change
// usages to use local background views
struct RelativeSystemImageView: View {
@State
private var contentSize: CGSize = .zero
private let systemName: String
private let ratio: CGFloat
init(
systemName: String,
ratio: CGFloat = 0.5
) {
self.systemName = systemName
self.ratio = ratio
}
var body: some View {
AlternateLayoutView {
Color.clear
.trackingSize($contentSize)
} content: {
Image(systemName: systemName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: contentSize.width * ratio, height: contentSize.height * ratio)
}
}
}
// TODO: cleanup and become the failure view for poster buttons
struct SystemImageContentView: View { struct SystemImageContentView: View {
@State @State
@ -18,17 +49,15 @@ struct SystemImageContentView: View {
private var labelSize: CGSize = .zero private var labelSize: CGSize = .zero
private var backgroundColor: Color private var backgroundColor: Color
private var heightRatio: CGFloat private var ratio: CGFloat
private let systemName: String private let systemName: String
private let title: String? private let title: String?
private var widthRatio: CGFloat
init(title: String? = nil, systemName: String?) { init(title: String? = nil, systemName: String?, ratio: CGFloat = 0.3) {
self.backgroundColor = Color.secondarySystemFill self.backgroundColor = Color.secondarySystemFill
self.heightRatio = 3 self.ratio = ratio
self.systemName = systemName ?? "circle" self.systemName = systemName ?? "circle"
self.title = title self.title = title
self.widthRatio = 3.5
} }
private var imageView: some View { private var imageView: some View {
@ -36,8 +65,7 @@ struct SystemImageContentView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.accessibilityHidden(true) .frame(width: contentSize.width * ratio, height: contentSize.height * ratio)
.frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio)
} }
@ViewBuilder @ViewBuilder
@ -47,7 +75,7 @@ struct SystemImageContentView: View {
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.font(.footnote.weight(.regular)) .font(.footnote.weight(.regular))
.foregroundColor(.secondary) .foregroundStyle(.secondary)
.trackingSize($labelSize) .trackingSize($labelSize)
} }
} }
@ -55,7 +83,6 @@ struct SystemImageContentView: View {
var body: some View { var body: some View {
ZStack { ZStack {
backgroundColor backgroundColor
.opacity(0.5)
imageView imageView
.frame(width: contentSize.width) .frame(width: contentSize.width)
@ -71,12 +98,7 @@ struct SystemImageContentView: View {
extension SystemImageContentView { extension SystemImageContentView {
func background(color: Color = Color.secondarySystemFill) -> Self { func background(color: Color) -> Self {
copy(modifying: \.backgroundColor, with: color) copy(modifying: \.backgroundColor, with: color)
} }
func imageFrameRatio(width: CGFloat = 3.5, height: CGFloat = 3) -> Self {
copy(modifying: \.heightRatio, with: height)
.copy(modifying: \.widthRatio, with: width)
}
} }

View File

@ -8,6 +8,10 @@
import SwiftUI import SwiftUI
// TODO: mainly used as a view to hold views for states
// but doesn't work with animations/transitions.
// Look at alternative with just ZStack and remove
struct WrappedView<Content: View>: View { struct WrappedView<Content: View>: View {
@ViewBuilder @ViewBuilder

View File

@ -10,9 +10,9 @@ import PulseUI
import Stinsen import Stinsen
import SwiftUI import SwiftUI
final class BasicAppSettingsCoordinator: NavigationCoordinatable { final class AppSettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start) let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@ -31,20 +31,16 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
var log = makeLog var log = makeLog
#endif #endif
private let viewModel: SettingsViewModel init() {}
init() {
viewModel = .init()
}
#if os(iOS) #if os(iOS)
@ViewBuilder @ViewBuilder
func makeAbout() -> some View { func makeAbout(viewModel: SettingsViewModel) -> some View {
AboutAppView(viewModel: viewModel) AboutAppView(viewModel: viewModel)
} }
@ViewBuilder @ViewBuilder
func makeAppIconSelector() -> some View { func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
AppIconSelectorView(viewModel: viewModel) AppIconSelectorView(viewModel: viewModel)
} }
#endif #endif
@ -56,6 +52,6 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
BasicAppSettingsView(viewModel: viewModel) AppSettingsView()
} }
} }

View File

@ -1,30 +0,0 @@
//
// 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 Stinsen
import SwiftUI
final class ConnectToServerCoodinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ConnectToServerCoodinator.start)
@Root
var start = makeStart
@Route(.push)
var userSignIn = makeUserSignIn
func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator {
UserSignInCoordinator(viewModel: .init(server: server))
}
@ViewBuilder
func makeStart() -> some View {
ConnectToServerView(viewModel: ConnectToServerViewModel())
}
}

View File

@ -32,7 +32,7 @@ final class FilterCoordinator: NavigationCoordinatable {
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
#if os(tvOS) #if os(tvOS)
Text(verbatim: .emptyDash) AssertionFailureView("Not implemented")
#else #else
FilterView(viewModel: parameters.viewModel, type: parameters.type) FilterView(viewModel: parameters.viewModel, type: parameters.type)
#endif #endif

View File

@ -17,8 +17,6 @@ final class HomeCoordinator: NavigationCoordinatable {
@Root @Root
var start = makeStart var start = makeStart
@Route(.modal)
var settings = makeSettings
#if os(tvOS) #if os(tvOS)
@Route(.modal) @Route(.modal)
@ -32,10 +30,6 @@ final class HomeCoordinator: NavigationCoordinatable {
var library = makeLibrary var library = makeLibrary
#endif #endif
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
NavigationViewCoordinator(SettingsCoordinator())
}
#if os(tvOS) #if os(tvOS)
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> { func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item)) NavigationViewCoordinator(ItemCoordinator(item: item))

View File

@ -57,7 +57,13 @@ final class ItemCoordinator: NavigationCoordinatable {
} }
func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator<BaseItemPerson> { func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator<BaseItemPerson> {
let viewModel = PagingLibraryViewModel(title: L10n.castAndCrew, people) let id: String? = itemDto.id == nil ? nil : "castAndCrew-\(itemDto.id!)"
let viewModel = PagingLibraryViewModel(
title: L10n.castAndCrew,
id: id,
people
)
return LibraryCoordinator(viewModel: viewModel) return LibraryCoordinator(viewModel: viewModel)
} }

View File

@ -14,7 +14,10 @@ import JellyfinAPI
import Nuke import Nuke
import Stinsen import Stinsen
import SwiftUI import SwiftUI
import WidgetKit
// TODO: could possibly clean up
// - only go to loading if migrations necessary
// - account for other migrations (Defaults)
final class MainCoordinator: NavigationCoordinatable { final class MainCoordinator: NavigationCoordinatable {
@ -23,30 +26,55 @@ final class MainCoordinator: NavigationCoordinatable {
var stack: Stinsen.NavigationStack<MainCoordinator> var stack: Stinsen.NavigationStack<MainCoordinator>
@Root
var loading = makeLoading
@Root @Root
var mainTab = makeMainTab var mainTab = makeMainTab
@Root @Root
var serverList = makeServerList var selectUser = makeSelectUser
@Route(.fullScreen) @Root
var videoPlayer = makeVideoPlayer var serverCheck = makeServerCheck
@Route(.fullScreen) @Route(.fullScreen)
var liveVideoPlayer = makeLiveVideoPlayer var liveVideoPlayer = makeLiveVideoPlayer
@Route(.modal)
private var cancellables = Set<AnyCancellable>() var settings = makeSettings
@Route(.fullScreen)
var videoPlayer = makeVideoPlayer
init() { init() {
if Container.userSession().authenticated { stack = NavigationStack(initial: \.loading)
stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else { Task {
stack = NavigationStack(initial: \MainCoordinator.serverList) do {
try await SwiftfinStore.setupDataStack()
if UserSession.current() != nil, !Defaults[.signOutOnClose] {
await MainActor.run {
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.serverCheck)
}
}
} else {
await MainActor.run {
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.selectUser)
}
}
}
} catch {
await MainActor.run {
logger.critical("\(error.localizedDescription)")
Notifications[.didFailMigration].post()
}
}
} }
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory // TODO: move these to the App instead?
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
WidgetCenter.shared.reloadAllTimelines() ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB
UIScrollView.appearance().keyboardDismissMode = .onDrag
// Notification setup for state // Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
@ -55,16 +83,24 @@ final class MainCoordinator: NavigationCoordinatable {
Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:))) Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:)))
} }
private func didFinishMigration() {}
@objc @objc
func didSignIn() { func didSignIn() {
logger.info("Signed in") logger.info("Signed in")
root(\.mainTab)
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.serverCheck)
}
} }
@objc @objc
func didSignOut() { func didSignOut() {
logger.info("Signed out") logger.info("Signed out")
root(\.serverList)
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.selectUser)
}
} }
@objc @objc
@ -84,18 +120,34 @@ final class MainCoordinator: NavigationCoordinatable {
@objc @objc
func didChangeCurrentServerURL(_ notification: Notification) { func didChangeCurrentServerURL(_ notification: Notification) {
guard Container.userSession().authenticated else { return } guard UserSession.current() != nil else { return }
Container.userSession.reset() UserSession.current.reset()
Notifications[.didSignIn].post() Notifications[.didSignIn].post()
} }
func makeLoading() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
AppLoadingView()
}
}
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
NavigationViewCoordinator(SettingsCoordinator())
}
func makeMainTab() -> MainTabCoordinator { func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator() MainTabCoordinator()
} }
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> { func makeSelectUser() -> NavigationViewCoordinator<SelectUserCoordinator> {
NavigationViewCoordinator(ServerListCoordinator()) NavigationViewCoordinator(SelectUserCoordinator())
}
func makeServerCheck() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerCheckView()
}
} }
func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator { func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator {

View File

@ -12,6 +12,10 @@ import Nuke
import Stinsen import Stinsen
import SwiftUI import SwiftUI
// TODO: clean up like iOS
// - move some things to App
// TODO: server check flow
final class MainCoordinator: NavigationCoordinatable { final class MainCoordinator: NavigationCoordinatable {
@Injected(LogManager.service) @Injected(LogManager.service)
@ -19,21 +23,44 @@ final class MainCoordinator: NavigationCoordinatable {
var stack: Stinsen.NavigationStack<MainCoordinator> var stack: Stinsen.NavigationStack<MainCoordinator>
@Root
var loading = makeLoading
@Root @Root
var mainTab = makeMainTab var mainTab = makeMainTab
@Root @Root
var serverList = makeServerList var selectUser = makeSelectUser
init() { init() {
if Container.userSession().authenticated { stack = NavigationStack(initial: \.loading)
stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else { Task {
stack = NavigationStack(initial: \MainCoordinator.serverList) do {
try await SwiftfinStore.setupDataStack()
if UserSession.current() != nil {
await MainActor.run {
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.mainTab)
}
}
} else {
await MainActor.run {
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.selectUser)
}
}
}
} catch {
await MainActor.run {
logger.critical("\(error.localizedDescription)")
Notifications[.didFailMigration].post()
}
}
} }
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label]
@ -44,21 +71,33 @@ final class MainCoordinator: NavigationCoordinatable {
@objc @objc
func didSignIn() { func didSignIn() {
logger.info("Received `didSignIn` from NSNotificationCenter.") logger.info("Signed in")
root(\.mainTab)
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.mainTab)
}
} }
@objc @objc
func didSignOut() { func didSignOut() {
logger.info("Received `didSignOut` from NSNotificationCenter.") logger.info("Signed out")
root(\.serverList)
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.selectUser)
}
}
func makeLoading() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
AppLoadingView()
}
} }
func makeMainTab() -> MainTabCoordinator { func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator() MainTabCoordinator()
} }
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> { func makeSelectUser() -> NavigationViewCoordinator<SelectUserCoordinator> {
NavigationViewCoordinator(ServerListCoordinator()) NavigationViewCoordinator(SelectUserCoordinator())
} }
} }

View File

@ -1,32 +0,0 @@
//
// 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 Stinsen
import SwiftUI
final class QuickConnectCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \QuickConnectCoordinator.start)
@Root
var start = makeStart
private let viewModel: UserSignInViewModel
init(viewModel: UserSignInViewModel) {
self.viewModel = viewModel
}
@ViewBuilder
func makeStart() -> some View {
QuickConnectView(viewModel: viewModel.quickConnectViewModel, signIn: { authSecret in
self.viewModel.send(.signInWithQuickConnect(authSecret: authSecret))
})
}
}

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 Foundation
import Stinsen
import SwiftUI
final class SelectUserCoordinator: NavigationCoordinatable {
struct SelectServerParameters {
let selection: Binding<SelectUserServerSelection>
let viewModel: SelectUserViewModel
}
let stack = NavigationStack(initial: \SelectUserCoordinator.start)
@Root
var start = makeStart
@Route(.modal)
var advancedSettings = makeAdvancedSettings
@Route(.modal)
var connectToServer = makeConnectToServer
@Route(.modal)
var editServer = makeEditServer
@Route(.modal)
var userSignIn = makeUserSignIn
#if os(tvOS)
@Route(.fullScreen)
var selectServer = makeSelectServer
#endif
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
NavigationViewCoordinator(AppSettingsCoordinator())
}
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ConnectToServerView()
}
}
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
.environment(\.isEditing, true)
#if os(iOS)
.navigationBarCloseButton {
self.popLast()
}
#endif
}
}
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
NavigationViewCoordinator(UserSignInCoordinator(server: server))
}
#if os(tvOS)
func makeSelectServer(parameters: SelectServerParameters) -> some View {
SelectServerView(
selection: parameters.selection,
viewModel: parameters.viewModel
)
}
#endif
@ViewBuilder
func makeStart() -> some View {
SelectUserView()
}
}

View File

@ -1,43 +0,0 @@
//
// 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 PulseUI
import Stinsen
import SwiftUI
final class ServerListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ServerListCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var connectToServer = makeConnectToServer
@Route(.push)
var userList = makeUserList
@Route(.modal)
var basicAppSettings = makeBasicAppSettings
func makeConnectToServer() -> ConnectToServerCoodinator {
ConnectToServerCoodinator()
}
func makeUserList(server: ServerState) -> UserListCoordinator {
UserListCoordinator(server: server)
}
func makeBasicAppSettings() -> NavigationViewCoordinator<BasicAppSettingsCoordinator> {
NavigationViewCoordinator(BasicAppSettingsCoordinator())
}
@ViewBuilder
func makeStart() -> some View {
ServerListView(viewModel: ServerListViewModel())
}
}

View File

@ -19,15 +19,17 @@ final class SettingsCoordinator: NavigationCoordinatable {
#if os(iOS) #if os(iOS)
@Route(.push) @Route(.push)
var about = makeAbout
@Route(.push)
var appIconSelector = makeAppIconSelector
@Route(.push)
var log = makeLog var log = makeLog
@Route(.push) @Route(.push)
var nativePlayerSettings = makeNativePlayerSettings var nativePlayerSettings = makeNativePlayerSettings
@Route(.push) @Route(.push)
var quickConnect = makeQuickConnectSettings var quickConnect = makeQuickConnectAuthorize
@Route(.push)
var resetUserPassword = makeResetUserPassword
@Route(.push)
var localSecurity = makeLocalSecurity
@Route(.push)
var userProfile = makeUserProfileSettings
@Route(.push) @Route(.push)
var customizeViewsSettings = makeCustomizeViewsSettings var customizeViewsSettings = makeCustomizeViewsSettings
@ -63,31 +65,30 @@ final class SettingsCoordinator: NavigationCoordinatable {
var videoPlayerSettings = makeVideoPlayerSettings var videoPlayerSettings = makeVideoPlayerSettings
#endif #endif
private let viewModel: SettingsViewModel
init() {
viewModel = .init()
}
#if os(iOS) #if os(iOS)
@ViewBuilder
func makeAbout() -> some View {
AboutAppView(viewModel: viewModel)
}
@ViewBuilder
func makeAppIconSelector() -> some View {
AppIconSelectorView(viewModel: viewModel)
}
@ViewBuilder @ViewBuilder
func makeNativePlayerSettings() -> some View { func makeNativePlayerSettings() -> some View {
NativeVideoPlayerSettingsView() NativeVideoPlayerSettingsView()
} }
@ViewBuilder @ViewBuilder
func makeQuickConnectSettings() -> some View { func makeQuickConnectAuthorize() -> some View {
QuickConnectSettingsView(viewModel: .init()) QuickConnectAuthorizeView()
}
@ViewBuilder
func makeResetUserPassword() -> some View {
ResetUserPasswordView()
}
@ViewBuilder
func makeLocalSecurity() -> some View {
UserLocalSecurityView()
}
@ViewBuilder
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
UserProfileSettingsView(viewModel: viewModel)
} }
@ViewBuilder @ViewBuilder
@ -107,7 +108,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
@ViewBuilder @ViewBuilder
func makeServerDetail(server: ServerState) -> some View { func makeServerDetail(server: ServerState) -> some View {
ServerDetailView(server: server) EditServerView(server: server)
} }
#if DEBUG #if DEBUG
@ -145,19 +146,15 @@ final class SettingsCoordinator: NavigationCoordinatable {
} }
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator( NavigationViewCoordinator {
BasicNavigationViewCoordinator { IndicatorSettingsView()
IndicatorSettingsView() }
}
)
} }
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator( NavigationViewCoordinator {
BasicNavigationViewCoordinator { EditServerView(server: server)
ServerDetailView(server: server) }
}
)
} }
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> { func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
@ -172,6 +169,6 @@ final class SettingsCoordinator: NavigationCoordinatable {
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
SettingsView(viewModel: viewModel) SettingsView()
} }
} }

View File

@ -1,42 +0,0 @@
//
// 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 Stinsen
import SwiftUI
final class UserListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \UserListCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var userSignIn = makeUserSignIn
@Route(.push)
var serverDetail = makeServerDetail
let serverState: ServerState
init(server: ServerState) {
self.serverState = server
}
func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator {
UserSignInCoordinator(viewModel: .init(server: server))
}
func makeServerDetail(server: SwiftfinStore.State.Server) -> some View {
ServerDetailView(server: server)
}
@ViewBuilder
func makeStart() -> some View {
UserListView(server: serverState)
}
}

View File

@ -7,34 +7,55 @@
// //
import Foundation import Foundation
import JellyfinAPI
import Stinsen import Stinsen
import SwiftUI import SwiftUI
final class UserSignInCoordinator: NavigationCoordinatable { final class UserSignInCoordinator: NavigationCoordinatable {
struct SecurityParameters {
let pinHint: Binding<String>
let signInPolicy: Binding<UserAccessPolicy>
}
let stack = NavigationStack(initial: \UserSignInCoordinator.start) let stack = NavigationStack(initial: \UserSignInCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
#if os(iOS)
@Route(.modal) @Route(.modal)
var quickConnect = makeQuickConnect var quickConnect = makeQuickConnect
#if os(iOS)
@Route(.modal)
var security = makeSecurity
#endif #endif
let viewModel: UserSignInViewModel private let server: ServerState
init(viewModel: UserSignInViewModel) { init(server: ServerState) {
self.viewModel = viewModel self.server = server
}
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
QuickConnectView(quickConnect: quickConnect)
}
} }
#if os(iOS) #if os(iOS)
func makeQuickConnect() -> NavigationViewCoordinator<QuickConnectCoordinator> { func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(QuickConnectCoordinator(viewModel: viewModel)) NavigationViewCoordinator {
UserSignInView.SecurityView(
pinHint: parameters.pinHint,
signInPolicy: parameters.signInPolicy
)
}
} }
#endif #endif
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
UserSignInView(viewModel: viewModel) UserSignInView(server: server)
} }
} }

View File

@ -9,6 +9,8 @@
import Foundation import Foundation
import JellyfinAPI import JellyfinAPI
// This is only kept as reference until more strongly-typed errors are implemented.
// enum NetworkError: Error { // enum NetworkError: Error {
// //
// /// For the case that the ErrorResponse object has a code of -1 // /// For the case that the ErrorResponse object has a code of -1

View File

@ -30,10 +30,6 @@ extension Array {
try filter(predicate).count try filter(predicate).count
} }
func oneSatisfies(_ predicate: (Element) throws -> Bool) rethrows -> Bool {
try contains(where: predicate)
}
func prepending(_ element: Element) -> [Element] { func prepending(_ element: Element) -> [Element] {
[element] + self [element] + self
} }

View File

@ -8,6 +8,9 @@
import SwiftUI import SwiftUI
// TODO: add all other missing colors from UIColor and fix usages
// - move row dividers to divider color
extension Color { extension Color {
static let jellyfinPurple = Color(uiColor: .jellyfinPurple) static let jellyfinPurple = Color(uiColor: .jellyfinPurple)
@ -26,9 +29,13 @@ extension Color {
static let secondarySystemFill = Color(UIColor.gray) static let secondarySystemFill = Color(UIColor.gray)
static let tertiarySystemFill = Color(UIColor.black) static let tertiarySystemFill = Color(UIColor.black)
static let lightGray = Color(UIColor.lightGray) static let lightGray = Color(UIColor.lightGray)
#else #else
static let systemFill = Color(UIColor.systemFill)
static let systemBackground = Color(UIColor.systemBackground) static let systemBackground = Color(UIColor.systemBackground)
static let secondarySystemBackground = Color(UIColor.secondarySystemBackground)
static let tertiarySystemBackground = Color(UIColor.tertiarySystemBackground)
static let systemFill = Color(UIColor.systemFill)
static let secondarySystemFill = Color(UIColor.secondarySystemFill) static let secondarySystemFill = Color(UIColor.secondarySystemFill)
static let tertiarySystemFill = Color(UIColor.tertiarySystemFill) static let tertiarySystemFill = Color(UIColor.tertiarySystemFill)
#endif #endif

View File

@ -15,7 +15,7 @@ extension EdgeInsets {
/// typically the edges of the View's scene /// typically the edges of the View's scene
static let edgePadding: CGFloat = { static let edgePadding: CGFloat = {
#if os(tvOS) #if os(tvOS)
50 44
#else #else
if UIDevice.isPad { if UIDevice.isPad {
24 24

View File

@ -11,6 +11,10 @@ import SwiftUI
extension EnvironmentValues { extension EnvironmentValues {
struct AccentColor: EnvironmentKey {
static let defaultValue: Binding<Color> = .constant(Color.jellyfinPurple)
}
struct AudioOffsetKey: EnvironmentKey { struct AudioOffsetKey: EnvironmentKey {
static let defaultValue: Binding<Int> = .constant(0) static let defaultValue: Binding<Int> = .constant(0)
} }
@ -23,10 +27,18 @@ extension EnvironmentValues {
static let defaultValue: Binding<VideoPlayer.OverlayType> = .constant(.main) static let defaultValue: Binding<VideoPlayer.OverlayType> = .constant(.main)
} }
struct IsEditingKey: EnvironmentKey {
static let defaultValue: Bool = false
}
struct IsScrubbingKey: EnvironmentKey { struct IsScrubbingKey: EnvironmentKey {
static let defaultValue: Binding<Bool> = .constant(false) static let defaultValue: Binding<Bool> = .constant(false)
} }
struct IsSelectedKey: EnvironmentKey {
static let defaultValue: Bool = false
}
struct PlaybackSpeedKey: EnvironmentKey { struct PlaybackSpeedKey: EnvironmentKey {
static let defaultValue: Binding<Double> = .constant(1) static let defaultValue: Binding<Double> = .constant(1)
} }

View File

@ -10,6 +10,11 @@ import SwiftUI
extension EnvironmentValues { extension EnvironmentValues {
var accentColor: Binding<Color> {
get { self[AccentColor.self] }
set { self[AccentColor.self] = newValue }
}
var audioOffset: Binding<Int> { var audioOffset: Binding<Int> {
get { self[AudioOffsetKey.self] } get { self[AudioOffsetKey.self] }
set { self[AudioOffsetKey.self] = newValue } set { self[AudioOffsetKey.self] = newValue }
@ -25,6 +30,11 @@ extension EnvironmentValues {
set { self[CurrentOverlayTypeKey.self] = newValue } set { self[CurrentOverlayTypeKey.self] = newValue }
} }
var isEditing: Bool {
get { self[IsEditingKey.self] }
set { self[IsEditingKey.self] = newValue }
}
var isPresentingOverlay: Binding<Bool> { var isPresentingOverlay: Binding<Bool> {
get { self[IsPresentingOverlayKey.self] } get { self[IsPresentingOverlayKey.self] }
set { self[IsPresentingOverlayKey.self] = newValue } set { self[IsPresentingOverlayKey.self] = newValue }
@ -35,6 +45,11 @@ extension EnvironmentValues {
set { self[IsScrubbingKey.self] = newValue } set { self[IsScrubbingKey.self] = newValue }
} }
var isSelected: Bool {
get { self[IsSelectedKey.self] }
set { self[IsSelectedKey.self] = newValue }
}
var playbackSpeed: Binding<Double> { var playbackSpeed: Binding<Double> {
get { self[PlaybackSpeedKey.self] } get { self[PlaybackSpeedKey.self] }
set { self[PlaybackSpeedKey.self] = newValue } set { self[PlaybackSpeedKey.self] = newValue }

View File

@ -0,0 +1,24 @@
//
// 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 HourMinuteFormatStyle: FormatStyle {
func format(_ value: TimeInterval) -> String {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.allowedUnits = [.hour, .minute]
return formatter.string(from: value) ?? .emptyDash
}
}
extension FormatStyle where Self == HourMinuteFormatStyle {
static var hourMinute: HourMinuteFormatStyle { HourMinuteFormatStyle() }
}

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
// TODO: remove and just use overlay + offset
extension HorizontalAlignment { extension HorizontalAlignment {
struct VideoPlayerTitleAlignment: AlignmentID { struct VideoPlayerTitleAlignment: AlignmentID {
@ -17,12 +18,4 @@ extension HorizontalAlignment {
} }
static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self) static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self)
struct LibraryRowContentAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[HorizontalAlignment.leading]
}
}
static let LeadingLibraryRowContentAlignmentGuide = HorizontalAlignment(LibraryRowContentAlignment.self)
} }

View File

@ -97,7 +97,9 @@ extension BaseItemDto {
return nil return nil
} }
let client = Container.userSession().client // TODO: client passing for widget/shared group views?
guard let client = UserSession.current()?.client else { return nil }
let parameters = Paths.GetItemImageParameters( let parameters = Paths.GetItemImageParameters(
maxWidth: scaleWidth, maxWidth: scaleWidth,
maxHeight: scaleHeight, maxHeight: scaleHeight,

View File

@ -21,7 +21,7 @@ extension BaseItemDto {
let tempOverkillBitrate = 360_000_000 let tempOverkillBitrate = 360_000_000
let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate) let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate)
let userSession = Container.userSession() let userSession = UserSession.current()!
let playbackInfo = PlaybackInfoDto(deviceProfile: profile) let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters( let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
@ -56,7 +56,7 @@ extension BaseItemDto {
profile.directPlayProfiles = [DirectPlayProfile(type: .video)] profile.directPlayProfiles = [DirectPlayProfile(type: .video)]
} }
let userSession = Container.userSession.callAsFunction() let userSession = UserSession.current()!
let playbackInfo = PlaybackInfoDto(deviceProfile: profile) let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters( let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(

View File

@ -85,20 +85,6 @@ extension BaseItemDto {
return formatter.string(from: .init(remainingSeconds)) return formatter.string(from: .init(remainingSeconds))
} }
func getLiveStartTimeString(formatter: DateFormatter) -> String {
if let startDate = self.startDate {
return formatter.string(from: startDate)
}
return " "
}
func getLiveEndTimeString(formatter: DateFormatter) -> String {
if let endDate = self.endDate {
return formatter.string(from: endDate)
}
return " "
}
var programDuration: TimeInterval? { var programDuration: TimeInterval? {
guard let startDate, let endDate else { return nil } guard let startDate, let endDate else { return nil }
return endDate.timeIntervalSince(startDate) return endDate.timeIntervalSince(startDate)
@ -174,7 +160,10 @@ extension BaseItemDto {
} }
var hasRatings: Bool { var hasRatings: Bool {
[criticRating, communityRating].oneSatisfies { $0 != nil } [
criticRating,
communityRating,
].contains { $0 != nil }
} }
// MARK: Chapter Images // MARK: Chapter Images
@ -204,8 +193,8 @@ extension BaseItemDto {
parameters: parameters parameters: parameters
) )
let imageURL = Container let imageURL = UserSession
.userSession() .current()!
.client .client
.fullURL(with: request) .fullURL(with: request)

View File

@ -23,11 +23,12 @@ extension BaseItemPerson: Poster {
func portraitImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] { func portraitImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] {
guard let client = UserSession.current()?.client else { return [] }
// TODO: figure out what to do about screen scaling with .main being deprecated // TODO: figure out what to do about screen scaling with .main being deprecated
// - maxWidth assume already scaled? // - maxWidth assume already scaled?
let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!)
let client = Container.userSession().client
let imageRequestParameters = Paths.GetItemImageParameters( let imageRequestParameters = Paths.GetItemImageParameters(
maxWidth: scaleWidth ?? Int(maxWidth), maxWidth: scaleWidth ?? Int(maxWidth),
tag: primaryImageTag tag: primaryImageTag

View File

@ -12,7 +12,7 @@ import UIKit
extension BaseItemPerson: Displayable { extension BaseItemPerson: Displayable {
var displayTitle: String { var displayTitle: String {
self.name ?? .emptyDash name ?? .emptyDash
} }
} }

View File

@ -9,6 +9,7 @@
import Foundation import Foundation
import Get import Get
import JellyfinAPI import JellyfinAPI
import UIKit
extension JellyfinClient { extension JellyfinClient {
@ -26,8 +27,30 @@ extension JellyfinClient {
/// Appends the path to the current configuration `URL`, assuming that the path begins with a leading `/`. /// Appends the path to the current configuration `URL`, assuming that the path begins with a leading `/`.
/// Returns `nil` if the new `URL` is malformed. /// Returns `nil` if the new `URL` is malformed.
func fullURL(with path: String) -> URL? { func fullURL(with path: String) -> URL? {
guard let fullPath = URL(string: configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path) let fullPath = configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path
else { return nil } return URL(string: fullPath)
return fullPath }
}
extension JellyfinClient.Configuration {
static func swiftfinConfiguration(url: URL) -> Self {
let client = "Swiftfin \(UIDevice.platform)"
let deviceName = UIDevice.current.name
.folding(options: .diacriticInsensitive, locale: .current)
.unicodeScalars
.filter { CharacterSet.urlQueryAllowed.contains($0) }
.description
let deviceID = "\(UIDevice.platform)_\(UIDevice.vendorUUIDString)"
let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.1"
return .init(
url: url,
client: client,
deviceName: deviceName,
deviceID: deviceID,
version: version
)
} }
} }

View File

@ -18,7 +18,7 @@ extension MediaSourceInfo {
func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel { func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
let userSession = Container.userSession() let userSession: UserSession! = UserSession.current()
let playbackURL: URL let playbackURL: URL
let streamType: StreamType let streamType: StreamType
@ -67,7 +67,8 @@ extension MediaSourceInfo {
} }
func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel { func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
let userSession = Container.userSession.callAsFunction()
let userSession: UserSession! = UserSession.current()
let playbackURL: URL let playbackURL: URL
let streamType: StreamType let streamType: StreamType

View File

@ -16,8 +16,8 @@ extension MediaStream {
static var none: MediaStream = .init(displayTitle: L10n.none, index: -1) static var none: MediaStream = .init(displayTitle: L10n.none, index: -1)
var asPlaybackChild: VLCVideoPlayer.PlaybackChild? { var asPlaybackChild: VLCVideoPlayer.PlaybackChild? {
guard let deliveryURL else { return nil } guard let deliveryURL, let client = UserSession.current()?.client else { return nil }
let client = Container.userSession().client
let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/") let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/")
guard let fullURL = client.fullURL(with: deliveryPath) else { return nil } guard let fullURL = client.fullURL(with: deliveryPath) else { return nil }
@ -250,22 +250,22 @@ extension [MediaStream] {
} }
var has4KVideo: Bool { var has4KVideo: Bool {
oneSatisfies { $0.is4kVideo } contains { $0.is4kVideo }
} }
var has51AudioChannelLayout: Bool { var has51AudioChannelLayout: Bool {
oneSatisfies { $0.is51AudioChannelLayout } contains { $0.is51AudioChannelLayout }
} }
var has71AudioChannelLayout: Bool { var has71AudioChannelLayout: Bool {
oneSatisfies { $0.is71AudioChannelLayout } contains { $0.is71AudioChannelLayout }
} }
var hasHDVideo: Bool { var hasHDVideo: Bool {
oneSatisfies { $0.isHDVideo } contains { $0.isHDVideo }
} }
var hasSubtitles: Bool { var hasSubtitles: Bool {
oneSatisfies { $0.type == .subtitle } contains { $0.type == .subtitle }
} }
} }

View File

@ -6,26 +6,25 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors // Copyright (c) 2024 Jellyfin & Jellyfin Contributors
// //
import Factory
import Foundation import Foundation
import Get
import JellyfinAPI import JellyfinAPI
import UIKit
extension UserDto { extension UserDto {
func profileImageSource(client: JellyfinClient, maxWidth: CGFloat, maxHeight: CGFloat) -> ImageSource { func profileImageSource(
let scaleWidth = UIScreen.main.scale(maxWidth) client: JellyfinClient,
let scaleHeight = UIScreen.main.scale(maxHeight) maxWidth: CGFloat? = nil,
maxHeight: CGFloat? = nil
let request = Paths.getUserImage( ) -> ImageSource {
userID: id ?? "", UserState(
imageType: "Primary", id: id ?? "",
parameters: .init(maxWidth: scaleWidth, maxHeight: scaleHeight) serverID: "",
username: ""
)
.profileImageSource(
client: client,
maxWidth: maxWidth,
maxHeight: maxHeight
) )
let profileImageURL = client.fullURL(with: request)
return ImageSource(url: profileImageURL)
} }
} }

View File

@ -9,13 +9,6 @@
import Stinsen import Stinsen
import SwiftUI import SwiftUI
extension NavigationCoordinatable {
func inNavigationViewCoordinator() -> NavigationViewCoordinator<Self> {
NavigationViewCoordinator(self)
}
}
extension NavigationViewCoordinator<BasicNavigationViewCoordinator> { extension NavigationViewCoordinator<BasicNavigationViewCoordinator> {
convenience init<Content: View>(@ViewBuilder content: @escaping () -> Content) { convenience init<Content: View>(@ViewBuilder content: @escaping () -> Content) {

View File

@ -48,6 +48,10 @@ extension Sequence {
func subtracting<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] { func subtracting<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
filter { !other.contains($0[keyPath: keyPath]) } filter { !other.contains($0[keyPath: keyPath]) }
} }
func zipped<Value>(map mapToOther: (Element) throws -> Value) rethrows -> [(Element, Value)] {
try map { try ($0, mapToOther($0)) }
}
} }
extension Sequence where Element: Equatable { extension Sequence where Element: Equatable {

View File

@ -7,20 +7,14 @@
// //
import Foundation import Foundation
import JellyfinAPI
// TODO: remove extension Set {
struct ErrorMessage: Hashable, Identifiable {
let code: Int? mutating func toggle(value: Element) {
let message: String if contains(value) {
remove(value)
var id: Int { } else {
hashValue insert(value)
} }
init(message: String, code: Int? = nil) {
self.code = code
self.message = message
} }
} }

View File

@ -118,5 +118,6 @@ extension String {
extension CharacterSet { extension CharacterSet {
// Character that appears on tvOS with voice input
static var objectReplacement: CharacterSet = .init(charactersIn: "\u{fffc}") static var objectReplacement: CharacterSet = .init(charactersIn: "\u{fffc}")
} }

View File

@ -49,11 +49,15 @@ extension UIDevice {
} }
static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) { static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) {
#if os(iOS)
UINotificationFeedbackGenerator().notificationOccurred(type) UINotificationFeedbackGenerator().notificationOccurred(type)
#endif
} }
static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) { static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) {
#if os(iOS)
UIImpactFeedbackGenerator(style: type).impactOccurred() UIImpactFeedbackGenerator(style: type).impactOccurred()
#endif
} }
#endif #endif
} }

View File

@ -0,0 +1,18 @@
//
// 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 URLSessionConfiguration {
/// A session configuration object built upon the default
/// configuration with values for Swiftfin.
static let swiftfin: URLSessionConfiguration = {
.default.mutating(\.timeoutIntervalForRequest, with: 20)
}()
}

View File

@ -15,6 +15,16 @@ struct Backport<Content> {
extension Backport where Content: View { extension Backport where Content: View {
/// Note: has no effect on iOS/tvOS 15
@ViewBuilder
func fontWeight(_ weight: Font.Weight?) -> some View {
if #available(iOS 16, tvOS 16, *) {
content.fontWeight(weight)
} else {
content
}
}
@ViewBuilder @ViewBuilder
func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View { func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View {
if #available(iOS 16, tvOS 16, *) { if #available(iOS 16, tvOS 16, *) {
@ -30,6 +40,17 @@ extension Backport where Content: View {
} }
} }
@ViewBuilder
func scrollDisabled(_ disabled: Bool) -> some View {
if #available(iOS 16, tvOS 16, *) {
content.scrollDisabled(disabled)
} else {
content.introspect(.scrollView, on: .iOS(.v15), .tvOS(.v15)) { scrollView in
scrollView.isScrollEnabled = !disabled
}
}
}
#if os(iOS) #if os(iOS)
// TODO: - remove comment when migrated away from Stinsen // TODO: - remove comment when migrated away from Stinsen
@ -62,3 +83,16 @@ extension Backport where Content: View {
} }
#endif #endif
} }
// MARK: ButtonBorderShape
extension ButtonBorderShape {
static let circleBackport: ButtonBorderShape = {
if #available(iOS 17, tvOS 16.4, *) {
return ButtonBorderShape.circle
} else {
return ButtonBorderShape.roundedRectangle
}
}()
}

View File

@ -11,12 +11,12 @@ import SwiftUI
struct OnReceiveNotificationModifier: ViewModifier { struct OnReceiveNotificationModifier: ViewModifier {
let notification: NSNotification.Name let notification: NSNotification.Name
let onReceive: () -> Void let onReceive: (Notification) -> Void
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.onReceive(NotificationCenter.default.publisher(for: notification)) { _ in .onReceive(NotificationCenter.default.publisher(for: notification)) {
onReceive() onReceive($0)
} }
} }
} }

View File

@ -21,3 +21,23 @@ struct OnSizeChangedModifier<Wrapped: View>: ViewModifier {
.trackingSize($size) .trackingSize($size)
} }
} }
struct EnvironmentModifier<Wrapped: View, Value>: ViewModifier {
@Environment
var environmentValue: Value
@ViewBuilder
var wrapped: (Value) -> Wrapped
init(_ keyPath: KeyPath<EnvironmentValues, Value>, @ViewBuilder wrapped: @escaping (Value) -> Wrapped) {
self._environmentValue = Environment(keyPath)
self.wrapped = wrapped
}
func body(content: Content) -> some View {
wrapped(environmentValue)
// wrapped(content)
}
}

View File

@ -1,28 +0,0 @@
//
// 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 SwiftUI
struct PaletteOverlayRenderingModifier: ViewModifier {
@Default(.accentColor)
private var accentColor
let color: Color?
private var _color: Color {
color ?? accentColor
}
func body(content: Content) -> some View {
content
.symbolRenderingMode(.palette)
.foregroundStyle(_color.overlayColor, _color)
}
}

View File

@ -0,0 +1,27 @@
//
// 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 ScrollIfLargerThanModifier: ViewModifier {
@State
private var contentSize: CGSize = .zero
let height: CGFloat
func body(content: Content) -> some View {
ScrollView {
content
.trackingSize($contentSize)
}
.backport
.scrollDisabled(contentSize.height < height)
.frame(maxHeight: contentSize.height >= height ? .infinity : contentSize.height)
}
}

View File

@ -19,7 +19,7 @@ struct ScrollViewOffsetModifier: ViewModifier {
} }
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.introspect(.scrollView, on: .iOS(.v15), .iOS(.v16), .iOS(.v17)) { scrollView in content.introspect(.scrollView, on: .iOS(.v15), .iOS(.v16), .iOS(.v17), .tvOS(.v15), .tvOS(.v16), .tvOS(.v17)) { scrollView in
scrollView.delegate = scrollViewDelegate scrollView.delegate = scrollViewDelegate
} }
} }

View File

@ -196,8 +196,6 @@ extension View {
} }
} }
// TODO: have width/height tracked binding
func onSizeChanged(perform action: @escaping (CGSize) -> Void) -> some View { func onSizeChanged(perform action: @escaping (CGSize) -> Void) -> some View {
onSizeChanged { size, _ in onSizeChanged { size, _ in
action(size) action(size)
@ -246,15 +244,6 @@ extension View {
} }
} }
/// Applies the `.palette` symbol rendering mode and a foreground style
/// where the primary style is the passed `Color`'s `overlayColor` and the
/// secondary style is the passed `Color`.
///
/// If `color == nil`, then `accentColor` from the environment is used.
func paletteOverlayRendering(color: Color? = nil) -> some View {
modifier(PaletteOverlayRenderingModifier(color: color))
}
@ViewBuilder @ViewBuilder
func navigationBarHidden() -> some View { func navigationBarHidden() -> some View {
if #available(iOS 16, tvOS 16, *) { if #available(iOS 16, tvOS 16, *) {
@ -321,7 +310,7 @@ extension View {
} }
} }
func onNotification(_ name: NSNotification.Name, perform action: @escaping () -> Void) -> some View { func onNotification(_ name: NSNotification.Name, perform action: @escaping (Notification) -> Void) -> some View {
modifier( modifier(
OnReceiveNotificationModifier( OnReceiveNotificationModifier(
notification: name, notification: name,
@ -330,13 +319,50 @@ extension View {
) )
} }
func onNotification(_ swiftfinNotification: Notifications.Key, perform action: @escaping (Notification) -> Void) -> some View {
modifier(
OnReceiveNotificationModifier(
notification: swiftfinNotification.underlyingNotification.name,
onReceive: action
)
)
}
func scroll(ifLargerThan height: CGFloat) -> some View {
modifier(ScrollIfLargerThanModifier(height: height))
}
// MARK: debug
// Useful modifiers during development for layout
#if DEBUG #if DEBUG
// Useful modifier during development func debugBackground<S: ShapeStyle>(_ fill: S = .red.opacity(0.5)) -> some View {
func debugBackground(_ color: Color = Color.red, opacity: CGFloat = 0.5) -> some View {
background { background {
color Rectangle()
.opacity(opacity) .fill(fill)
} }
} }
func debugVLine<S: ShapeStyle>(_ fill: S) -> some View {
overlay {
Rectangle()
.fill(fill)
.frame(width: 4)
}
}
func debugHLine<S: ShapeStyle>(_ fill: S) -> some View {
overlay {
Rectangle()
.fill(fill)
.frame(height: 4)
}
}
func debugCross<S: ShapeStyle>(_ fill: S = .red) -> some View {
debugVLine(fill)
.debugHLine(fill)
}
#endif #endif
} }

View File

@ -99,32 +99,35 @@ extension CaseIterablePicker {
// MARK: Label // MARK: Label
extension CaseIterablePicker where Element: SystemImageable { // TODO: I didn't entirely like the forced label design that this
// uses, decide whether to actually keep
init(title: String, selection: Binding<Element?>) { // extension CaseIterablePicker where Element: SystemImageable {
self.init( //
selection: selection, // init(title: String, selection: Binding<Element?>) {
label: { Label($0.displayTitle, systemImage: $0.systemImage) }, // self.init(
title: title, // selection: selection,
hasNone: true, // label: { Label($0.displayTitle, systemImage: $0.systemImage) },
noneStyle: .text // title: title,
) // hasNone: true,
} // noneStyle: .text
// )
init(title: String, selection: Binding<Element>) { // }
let binding = Binding<Element?> { //
selection.wrappedValue // init(title: String, selection: Binding<Element>) {
} set: { newValue, _ in // let binding = Binding<Element?> {
precondition(newValue != nil, "Should not have nil new value with non-optional binding") // selection.wrappedValue
selection.wrappedValue = newValue! // } set: { newValue, _ in
} // precondition(newValue != nil, "Should not have nil new value with non-optional binding")
// selection.wrappedValue = newValue!
self.init( // }
selection: binding, //
label: { Label($0.displayTitle, systemImage: $0.systemImage) }, // self.init(
title: title, // selection: binding,
hasNone: false, // label: { Label($0.displayTitle, systemImage: $0.systemImage) },
noneStyle: .text // title: title,
) // hasNone: false,
} // noneStyle: .text
} // )
// }
// }

View File

@ -19,7 +19,6 @@ enum ItemFilterType: String, CaseIterable, Defaults.Serializable {
case traits case traits
case years case years
// TODO: rename to something indicating plurality instead of concrete type?
var selectorType: SelectorType { var selectorType: SelectorType {
switch self { switch self {
case .genres, .tags, .traits, .years: case .genres, .tags, .traits, .years:

View File

@ -10,7 +10,7 @@ import Defaults
import Foundation import Foundation
import UIKit import UIKit
enum LibraryDisplayType: String, CaseIterable, Displayable, Defaults.Serializable, SystemImageable { enum LibraryDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable {
case grid case grid
case list case list

View File

@ -13,6 +13,11 @@ import JellyfinAPI
struct TitledLibraryParent: LibraryParent { struct TitledLibraryParent: LibraryParent {
let displayTitle: String let displayTitle: String
let id: String? = nil let id: String?
let libraryType: BaseItemKind? = nil let libraryType: BaseItemKind? = nil
init(displayTitle: String, id: String? = nil) {
self.displayTitle = displayTitle
self.id = id
}
} }

View File

@ -9,7 +9,7 @@
import Defaults import Defaults
import SwiftUI import SwiftUI
enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable { enum PosterDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable {
case landscape case landscape
case portrait case portrait
@ -23,4 +23,13 @@ enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable
"Portrait" "Portrait"
} }
} }
var systemImage: String {
switch self {
case .landscape:
"rectangle.fill"
case .portrait:
"rectangle.portrait.fill"
}
}
} }

View File

@ -0,0 +1,34 @@
//
// 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 Foundation
enum SelectUserServerSelection: RawRepresentable, Codable, Defaults.Serializable, Equatable, Hashable {
case all
case server(id: String)
var rawValue: String {
switch self {
case .all:
"swiftfin-all"
case let .server(id):
id
}
}
init?(rawValue: String) {
switch rawValue {
case "swiftfin-all":
self = .all
default:
self = .server(id: rawValue)
}
}
}

View File

@ -16,6 +16,7 @@ import OrderedCollections
// parent class actions // parent class actions
// TODO: official way for a cleaner `respond` method so it doesn't have all Task // TODO: official way for a cleaner `respond` method so it doesn't have all Task
// construction and get bloated // construction and get bloated
// TODO: make Action: Hashable just for consistency
protocol Stateful: AnyObject { protocol Stateful: AnyObject {
@ -43,6 +44,11 @@ protocol Stateful: AnyObject {
extension Stateful { extension Stateful {
var lastAction: Action? {
get { nil }
set {}
}
@MainActor @MainActor
func send(_ action: Action) { func send(_ action: Action) {
state = respond(to: action) state = respond(to: action)

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 Defaults
import Foundation
/// A type that is able to be stored within:
///
/// - `Defaults`: UserDefaults
/// - `StoredValue`: AnyData
protocol Storable: Codable, Defaults.Serializable {}

View File

@ -0,0 +1,30 @@
//
// 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
// TODO: require remote sign in every time
// - actually found to be a bit difficult?
enum UserAccessPolicy: String, CaseIterable, Codable, Displayable {
case none
case requireDeviceAuthentication
case requirePin
var displayTitle: String {
switch self {
case .none:
"None"
case .requireDeviceAuthentication:
"Device Authentication"
case .requirePin:
"Pin"
}
}
}

View File

@ -6,6 +6,7 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors // Copyright (c) 2024 Jellyfin & Jellyfin Contributors
// //
import Combine
import Factory import Factory
import Foundation import Foundation
import UDPBroadcast import UDPBroadcast
@ -15,69 +16,45 @@ class ServerDiscovery {
@Injected(LogManager.service) @Injected(LogManager.service)
private var logger private var logger
struct ServerLookupResponse: Codable, Hashable, Identifiable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
private let address: String
let id: String
let name: String
var url: URL {
URL(string: self.address)!
}
var host: String {
let components = URLComponents(string: self.address)
if let host = components?.host {
return host
}
return self.address
}
var port: Int {
let components = URLComponents(string: self.address)
if let port = components?.port {
return port
}
return 7359
}
enum CodingKeys: String, CodingKey {
case address = "Address"
case id = "Id"
case name = "Name"
}
}
private var connection: UDPBroadcastConnection? private var connection: UDPBroadcastConnection?
init() {} init() {
connection = try? UDPBroadcastConnection(
port: 7359,
handler: handleServerResponse,
errorHandler: handleError
)
}
func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) { var discoveredServers: AnyPublisher<ServerResponse, Never> {
discoveredServersPublisher
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) { private var discoveredServersPublisher = PassthroughSubject<ServerResponse, Never>()
do {
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
logger.debug("Received JellyfinServer from \"\(response.name)\"")
completion(response)
} catch {
completion(nil)
}
}
func errorHandler(error: UDPBroadcastConnection.ConnectionError) { func broadcast() {
logger.error("Error handling response: \(error.localizedDescription)") try? connection?.sendBroadcast("Who is JellyfinServer?")
} }
func close() {
connection?.closeConnection()
discoveredServersPublisher.send(completion: .finished)
}
private func handleServerResponse(_ ipAddress: String, _ port: Int, data: Data) {
do { do {
self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) let response = try JSONDecoder().decode(ServerResponse.self, from: data)
try self.connection?.sendBroadcast("Who is JellyfinServer?") discoveredServersPublisher.send(response)
logger.debug("Discovery broadcast sent")
logger.debug("Found local server: \"\(response.name)\" at: \(response.url.absoluteString)")
} catch { } catch {
logger.error("Error sending discovery broadcast") logger.debug("Unable to decode local server response from: \(ipAddress):\(port)")
} }
} }
private func handleError(_ error: UDPBroadcastConnection.ConnectionError) {
logger.debug("Error handling response: \(error.localizedDescription)")
}
} }

View File

@ -0,0 +1,59 @@
//
// 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 ServerDiscovery {
struct ServerResponse: Codable, Hashable, Identifiable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
private let address: String
let id: String
let name: String
var url: URL {
URL(string: address)!
}
var host: String {
let components = URLComponents(string: address)
if let host = components?.host {
return host
}
return self.address
}
var port: Int {
let components = URLComponents(string: address)
if let port = components?.port {
return port
}
return 7359
}
var asServerState: ServerState {
.init(
urls: [url],
currentURL: url,
name: name,
id: id,
usersIDs: []
)
}
enum CodingKeys: String, CodingKey {
case address = "Address"
case id = "Id"
case name = "Name"
}
}
}

View File

@ -40,8 +40,8 @@ class DownloadTask: NSObject, ObservableObject {
@Injected(LogManager.service) @Injected(LogManager.service)
private var logger private var logger
@Injected(Container.userSession) @Injected(UserSession.current)
private var userSession private var userSession: UserSession!
@Published @Published
var state: State = .ready var state: State = .ready

View File

@ -0,0 +1,19 @@
//
// 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 Factory
import Foundation
import KeychainSwift
enum Keychain {
// TODO: take a look at all security options
static let service = Factory<KeychainSwift>(scope: .singleton) {
KeychainSwift()
}
}

View File

@ -1,126 +0,0 @@
//
// 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 CoreData
import CoreStore
import Defaults
import Factory
import Foundation
import JellyfinAPI
import Pulse
import UIKit
// TODO: cleanup
final class SwiftfinSession {
let client: JellyfinClient
let server: ServerState
let user: UserState
let authenticated: Bool
init(
server: ServerState,
user: UserState,
authenticated: Bool
) {
self.server = server
self.user = user
self.authenticated = authenticated
let client = JellyfinClient(
configuration: .swiftfinConfiguration(url: server.currentURL),
sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger()),
accessToken: user.accessToken
)
self.client = client
}
}
final class BasicServerSession {
let client: JellyfinClient
let server: ServerState
init(server: ServerState) {
self.server = server
let client = JellyfinClient(
configuration: .swiftfinConfiguration(url: server.currentURL),
sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger())
)
self.client = client
}
}
extension Container.Scope {
static var basicServerSessionScope = Shared()
static var userSessionScope = Cached()
}
extension Container {
static let basicServerSessionScope = ParameterFactory<ServerState, BasicServerSession>(scope: .basicServerSessionScope) {
.init(server: $0)
}
static let userSession = Factory<SwiftfinSession>(scope: .userSessionScope) {
if let lastUserID = Defaults[.lastServerUserID],
let user = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", lastUserID)]
)
{
guard let server = user.server,
let existingServer = SwiftfinStore.dataStack.fetchExisting(server)
else {
fatalError("No associated server for last user")
}
return .init(
server: server.state,
user: user.state,
authenticated: true
)
} else {
return .init(
server: .sample,
user: .sample,
authenticated: false
)
}
}
}
extension JellyfinClient.Configuration {
static func swiftfinConfiguration(url: URL) -> Self {
let client = "Swiftfin \(UIDevice.platform)"
let deviceName = UIDevice.current.name
.folding(options: .diacriticInsensitive, locale: .current)
.unicodeScalars
.filter { CharacterSet.urlQueryAllowed.contains($0) }
.description
let deviceID = "\(UIDevice.platform)_\(UIDevice.vendorUUIDString)"
let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.1"
return .init(
url: url,
client: client,
deviceName: deviceName,
deviceID: deviceID,
version: version
)
}
}

View File

@ -7,236 +7,229 @@
// //
import Defaults import Defaults
import Factory
import Foundation import Foundation
import SwiftUI import SwiftUI
import UIKit import UIKit
// TODO: Organize // TODO: organize
// TODO: all user settings could be moved to `StoredValues`?
// Note: Only use Defaults for basic single-value settings.
// For larger data types and collections, use `StoredValue` instead.
// MARK: Suites
extension UserDefaults { extension UserDefaults {
static let generalSuite = UserDefaults(suiteName: "swiftfinstore-general-defaults")!
static let universalSuite = UserDefaults(suiteName: "swiftfinstore-universal-defaults")! // MARK: App
/// Settings that should apply to the app
static let appSuite = UserDefaults(suiteName: "swiftfinApp")!
// MARK: Usser
// TODO: the Factory resolver cannot be used because it would cause freezes, but
// the Defaults value should always be in sync with the latest user and what
// views properly expect. However, this feels like a hack and should be changed?
static var currentUserSuite: UserDefaults {
userSuite(id: Defaults[.lastSignedInUserID] ?? "default")
}
static func userSuite(id: String) -> UserDefaults {
UserDefaults(suiteName: id)!
}
} }
private extension Defaults.Keys {
static func AppKey<Value: Defaults.Serializable>(_ name: String) -> Key<Value?> {
Key(name, suite: .appSuite)
}
static func AppKey<Value: Defaults.Serializable>(_ name: String, default: Value) -> Key<Value> {
Key(name, default: `default`, suite: .appSuite)
}
static func UserKey<Value: Defaults.Serializable>(_ name: String, default: Value) -> Key<Value> {
Key(name, default: `default`, suite: .currentUserSuite)
}
}
// MARK: App
extension Defaults.Keys { extension Defaults.Keys {
// Universal settings /// The _real_ accent color key to be used.
static let accentColor: Key<Color> = .init("accentColor", default: .jellyfinPurple, suite: .universalSuite) ///
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: .universalSuite) /// This is set externally whenever the app or user accent colors change,
static let hapticFeedback: Key<Bool> = .init("hapticFeedback", default: true, suite: .universalSuite) /// depending on the current app state.
static let lastServerUserID = Defaults.Key<String?>("lastServerUserID", suite: .universalSuite) static var accentColor: Key<Color> = AppKey("accentColor", default: .jellyfinPurple)
// TODO: Replace with a cache /// The _real_ appearance key to be used.
static let libraryFilterStore = Key<[String: ItemFilterCollection]>("libraryFilterStore", default: [:], suite: .generalSuite) ///
/// This is set externally whenever the app or user appearances change,
/// depending on the current app state.
static let appearance: Key<AppAppearance> = AppKey("appearance", default: .system)
/// The accent color default for non-user contexts.
/// Only use for `set`, use `accentColor` for `get`.
static let appAccentColor: Key<Color> = AppKey("appAccentColor", default: .jellyfinPurple)
/// The appearance default for non-user contexts.
/// /// Only use for `set`, use `appearance` for `get`.
static let appAppearance: Key<AppAppearance> = AppKey("appAppearance", default: .system)
static let backgroundSignOutInterval: Key<TimeInterval> = AppKey("backgroundSignOutInterval", default: 3600)
static let backgroundTimeStamp: Key<Date> = AppKey("backgroundTimeStamp", default: Date.now)
static let lastSignedInUserID: Key<String?> = AppKey("lastSignedInUserID")
static let selectUserDisplayType: Key<LibraryDisplayType> = AppKey("selectUserDisplayType", default: .grid)
static let selectUserServerSelection: Key<SelectUserServerSelection> = AppKey("selectUserServerSelection", default: .all)
static let selectUserAllServersSplashscreen: Key<SelectUserServerSelection> = AppKey("selectUserAllServersSplashscreen", default: .all)
static let selectUserUseSplashscreen: Key<Bool> = AppKey("selectUserUseSplashscreen", default: true)
static let signOutOnBackground: Key<Bool> = AppKey("signOutOnBackground", default: true)
static let signOutOnClose: Key<Bool> = AppKey("signOutOnClose", default: true)
}
// MARK: User
extension Defaults.Keys {
/// The accent color default for user contexts.
/// Only use for `set`, use `accentColor` for `get`.
static var userAccentColor: Key<Color> { UserKey("userAccentColor", default: .jellyfinPurple) }
/// The appearance default for user contexts.
/// /// Only use for `set`, use `appearance` for `get`.
static var userAppearance: Key<AppAppearance> { UserKey("userAppearance", default: .system) }
enum Customization { enum Customization {
static let itemViewType = Key<ItemViewType>("itemViewType", default: .compactLogo, suite: .generalSuite) static let itemViewType: Key<ItemViewType> = UserKey("itemViewType", default: .compactLogo)
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: .generalSuite) static let showPosterLabels: Key<Bool> = UserKey("showPosterLabels", default: true)
static let nextUpPosterType = Key<PosterDisplayType>("nextUpPosterType", default: .portrait, suite: .generalSuite) static let nextUpPosterType: Key<PosterDisplayType> = UserKey("nextUpPosterType", default: .portrait)
static let recentlyAddedPosterType = Key<PosterDisplayType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite) static let recentlyAddedPosterType: Key<PosterDisplayType> = UserKey("recentlyAddedPosterType", default: .portrait)
static let latestInLibraryPosterType = Key<PosterDisplayType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite) static let latestInLibraryPosterType: Key<PosterDisplayType> = UserKey("latestInLibraryPosterType", default: .portrait)
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: .generalSuite) static let shouldShowMissingSeasons: Key<Bool> = UserKey("shouldShowMissingSeasons", default: true)
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: .generalSuite) static let shouldShowMissingEpisodes: Key<Bool> = UserKey("shouldShowMissingEpisodes", default: true)
static let similarPosterType = Key<PosterDisplayType>("similarPosterType", default: .portrait, suite: .generalSuite) static let similarPosterType: Key<PosterDisplayType> = UserKey("similarPosterType", default: .portrait)
// TODO: have search poster type by types of items if applicable // TODO: have search poster type by types of items if applicable
static let searchPosterType = Key<PosterDisplayType>("searchPosterType", default: .portrait, suite: .generalSuite) static let searchPosterType: Key<PosterDisplayType> = UserKey("searchPosterType", default: .portrait)
enum CinematicItemViewType { enum CinematicItemViewType {
static let usePrimaryImage: Key<Bool> = .init("cinematicItemViewTypeUsePrimaryImage", default: false, suite: .generalSuite) static let usePrimaryImage: Key<Bool> = UserKey("cinematicItemViewTypeUsePrimaryImage", default: false)
} }
enum Episodes { enum Episodes {
static let useSeriesLandscapeBackdrop = Key<Bool>("useSeriesBackdrop", default: true, suite: .generalSuite) static let useSeriesLandscapeBackdrop: Key<Bool> = UserKey("useSeriesBackdrop", default: true)
} }
enum Indicators { enum Indicators {
static let showFavorited: Key<Bool> = .init("showFavoritedIndicator", default: true, suite: .generalSuite) static let showFavorited: Key<Bool> = UserKey("showFavoritedIndicator", default: true)
static let showProgress: Key<Bool> = .init("showProgressIndicator", default: true, suite: .generalSuite) static let showProgress: Key<Bool> = UserKey("showProgressIndicator", default: true)
static let showUnplayed: Key<Bool> = .init("showUnplayedIndicator", default: true, suite: .generalSuite) static let showUnplayed: Key<Bool> = UserKey("showUnplayedIndicator", default: true)
static let showPlayed: Key<Bool> = .init("showPlayedIndicator", default: true, suite: .generalSuite) static let showPlayed: Key<Bool> = UserKey("showPlayedIndicator", default: true)
} }
enum Library { enum Library {
static let cinematicBackground: Key<Bool> = .init( static let cinematicBackground: Key<Bool> = UserKey("Customization.Library.cinematicBackground", default: true)
"Customization.Library.cinematicBackground", static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey(
default: true,
suite: .generalSuite
)
static let enabledDrawerFilters: Key<[ItemFilterType]> = .init(
"Library.enabledDrawerFilters", "Library.enabledDrawerFilters",
default: ItemFilterType.allCases, default: ItemFilterType.allCases
suite: .generalSuite
)
static let viewType = Key<LibraryDisplayType>(
"libraryViewType",
default: .grid,
suite: .generalSuite
)
static let posterType = Key<PosterDisplayType>(
"libraryPosterType",
default: .portrait,
suite: .generalSuite
)
static let listColumnCount = Key<Int>(
"listColumnCount",
default: 1,
suite: .generalSuite
)
static let randomImage: Key<Bool> = .init(
"libraryRandomImage",
default: true,
suite: .generalSuite
)
static let showFavorites: Key<Bool> = .init(
"libraryShowFavorites",
default: true,
suite: .generalSuite
) )
static let displayType: Key<LibraryDisplayType> = UserKey("libraryViewType", default: .grid)
static let posterType: Key<PosterDisplayType> = UserKey("libraryPosterType", default: .portrait)
static let listColumnCount: Key<Int> = UserKey("listColumnCount", default: 1)
static let randomImage: Key<Bool> = UserKey("libraryRandomImage", default: true)
static let showFavorites: Key<Bool> = UserKey("libraryShowFavorites", default: true)
static let rememberLayout: Key<Bool> = UserKey("libraryRememberLayout", default: false)
static let rememberSort: Key<Bool> = UserKey("libraryRememberSort", default: false)
} }
enum Search { enum Search {
static let enabledDrawerFilters: Key<[ItemFilterType]> = .init( static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey(
"Search.enabledDrawerFilters", "Search.enabledDrawerFilters",
default: ItemFilterType.allCases, default: ItemFilterType.allCases
suite: .generalSuite
) )
} }
} }
enum VideoPlayer { enum VideoPlayer {
static let autoPlayEnabled: Key<Bool> = .init("autoPlayEnabled", default: true, suite: .generalSuite) static let autoPlayEnabled: Key<Bool> = UserKey("autoPlayEnabled", default: true)
static let barActionButtons: Key<[VideoPlayerActionButton]> = .init( static let barActionButtons: Key<[VideoPlayerActionButton]> = UserKey(
"barActionButtons", "barActionButtons",
default: VideoPlayerActionButton.defaultBarActionButtons, default: VideoPlayerActionButton.defaultBarActionButtons
suite: .generalSuite
) )
static let jumpBackwardLength: Key<VideoPlayerJumpLength> = .init( static let jumpBackwardLength: Key<VideoPlayerJumpLength> = UserKey("jumpBackwardLength", default: .fifteen)
"jumpBackwardLength", static let jumpForwardLength: Key<VideoPlayerJumpLength> = UserKey("jumpForwardLength", default: .fifteen)
default: .fifteen, static let menuActionButtons: Key<[VideoPlayerActionButton]> = UserKey(
suite: .generalSuite
)
static let jumpForwardLength: Key<VideoPlayerJumpLength> = .init(
"jumpForwardLength",
default: .fifteen,
suite: .generalSuite
)
static let menuActionButtons: Key<[VideoPlayerActionButton]> = .init(
"menuActionButtons", "menuActionButtons",
default: VideoPlayerActionButton.defaultMenuActionButtons, default: VideoPlayerActionButton.defaultMenuActionButtons
suite: .generalSuite
) )
static let resumeOffset: Key<Int> = .init("resumeOffset", default: 0, suite: .generalSuite) static let resumeOffset: Key<Int> = UserKey("resumeOffset", default: 0)
static let showJumpButtons: Key<Bool> = .init("showJumpButtons", default: true, suite: .generalSuite) static let showJumpButtons: Key<Bool> = UserKey("showJumpButtons", default: true)
static let videoPlayerType: Key<VideoPlayerType> = .init("videoPlayerType", default: .swiftfin, suite: .generalSuite) static let videoPlayerType: Key<VideoPlayerType> = UserKey("videoPlayerType", default: .swiftfin)
enum Gesture { enum Gesture {
static let horizontalPanGesture: Key<PanAction> = .init( static let horizontalPanGesture: Key<PanAction> = UserKey("videoPlayerHorizontalPanGesture", default: .none)
"videoPlayerHorizontalPanGesture", static let horizontalSwipeGesture: Key<SwipeAction> = UserKey("videoPlayerHorizontalSwipeGesture", default: .none)
default: .none, static let longPressGesture: Key<LongPressAction> = UserKey("videoPlayerLongPressGesture", default: .gestureLock)
suite: .generalSuite static let multiTapGesture: Key<MultiTapAction> = UserKey("videoPlayerMultiTapGesture", default: .none)
) static let doubleTouchGesture: Key<DoubleTouchAction> = UserKey("videoPlayerDoubleTouchGesture", default: .none)
static let horizontalSwipeGesture: Key<SwipeAction> = .init( static let pinchGesture: Key<PinchAction> = UserKey("videoPlayerSwipeGesture", default: .aspectFill)
"videoPlayerHorizontalSwipeGesture", static let verticalPanGestureLeft: Key<PanAction> = UserKey("videoPlayerVerticalPanGestureLeft", default: .none)
default: .none, static let verticalPanGestureRight: Key<PanAction> = UserKey("videoPlayerVerticalPanGestureRight", default: .none)
suite: .generalSuite
)
static let longPressGesture: Key<LongPressAction> = .init(
"videoPlayerLongPressGesture",
default: .gestureLock,
suite: .generalSuite
)
static let multiTapGesture: Key<MultiTapAction> = .init("videoPlayerMultiTapGesture", default: .none, suite: .generalSuite)
static let doubleTouchGesture: Key<DoubleTouchAction> = .init(
"videoPlayerDoubleTouchGesture",
default: .none,
suite: .generalSuite
)
static let pinchGesture: Key<PinchAction> = .init("videoPlayerSwipeGesture", default: .aspectFill, suite: .generalSuite)
static let verticalPanGestureLeft: Key<PanAction> = .init(
"videoPlayerVerticalPanGestureLeft",
default: .none,
suite: .generalSuite
)
static let verticalPanGestureRight: Key<PanAction> = .init(
"videoPlayerVerticalPanGestureRight",
default: .none,
suite: .generalSuite
)
} }
enum Overlay { enum Overlay {
static let chapterSlider: Key<Bool> = .init("chapterSlider", default: true, suite: .generalSuite) static let chapterSlider: Key<Bool> = UserKey("chapterSlider", default: true)
static let playbackButtonType: Key<PlaybackButtonType> = .init( static let playbackButtonType: Key<PlaybackButtonType> = UserKey("videoPlayerPlaybackButtonLocation", default: .large)
"videoPlayerPlaybackButtonLocation", static let sliderColor: Key<Color> = UserKey("sliderColor", default: Color.white)
default: .large, static let sliderType: Key<SliderType> = UserKey("sliderType", default: .capsule)
suite: .generalSuite
)
static let sliderColor: Key<Color> = .init("sliderColor", default: Color.white, suite: .generalSuite)
static let sliderType: Key<SliderType> = .init("sliderType", default: .capsule, suite: .generalSuite)
// Timestamp // Timestamp
static let trailingTimestampType: Key<TrailingTimestampType> = .init( static let trailingTimestampType: Key<TrailingTimestampType> = UserKey("trailingTimestamp", default: .timeLeft)
"trailingTimestamp", static let showCurrentTimeWhileScrubbing: Key<Bool> = UserKey("showCurrentTimeWhileScrubbing", default: true)
default: .timeLeft, static let timestampType: Key<TimestampType> = UserKey("timestampType", default: .split)
suite: .generalSuite
)
static let showCurrentTimeWhileScrubbing: Key<Bool> = .init(
"showCurrentTimeWhileScrubbing",
default: true,
suite: .generalSuite
)
static let timestampType: Key<TimestampType> = .init("timestampType", default: .split, suite: .generalSuite)
} }
enum Subtitle { enum Subtitle {
static let subtitleColor: Key<Color> = .init( static let subtitleColor: Key<Color> = UserKey("subtitleColor", default: .white)
"subtitleColor", static let subtitleFontName: Key<String> = UserKey("subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName)
default: .white, static let subtitleSize: Key<Int> = UserKey("subtitleSize", default: 16)
suite: .generalSuite
)
static let subtitleFontName: Key<String> = .init(
"subtitleFontName",
default: UIFont.systemFont(ofSize: 14).fontName,
suite: .generalSuite
)
static let subtitleSize: Key<Int> = .init("subtitleSize", default: 16, suite: .generalSuite)
} }
enum Transition { enum Transition {
static let pauseOnBackground: Key<Bool> = .init("pauseOnBackground", default: false, suite: .generalSuite) static let pauseOnBackground: Key<Bool> = UserKey("pauseOnBackground", default: false)
static let playOnActive: Key<Bool> = .init("playOnActive", default: false, suite: .generalSuite) static let playOnActive: Key<Bool> = UserKey("playOnActive", default: false)
} }
} }
// Experimental settings // Experimental settings
enum Experimental { enum Experimental {
static let downloads: Key<Bool> = .init("experimentalDownloads", default: false, suite: .generalSuite) static let downloads: Key<Bool> = UserKey("experimentalDownloads", default: false)
static let syncSubtitleStateWithAdjacent = Key<Bool>( static let forceDirectPlay: Key<Bool> = UserKey("forceDirectPlay", default: false)
"experimentalSyncSubtitleState", static let liveTVForceDirectPlay: Key<Bool> = UserKey("liveTVForceDirectPlay", default: false)
default: false,
suite: .generalSuite
)
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: .generalSuite)
static let liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: .generalSuite)
} }
// tvos specific // tvos specific
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: .generalSuite) static let downActionShowsMenu: Key<Bool> = UserKey("downActionShowsMenu", default: true)
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: .generalSuite) static let confirmClose: Key<Bool> = UserKey("confirmClose", default: false)
} }
// MARK: Debug // MARK: Debug
@ -250,6 +243,10 @@ extension UserDefaults {
extension Defaults.Keys { extension Defaults.Keys {
static let sendProgressReports: Key<Bool> = .init("sendProgressReports", default: true, suite: .debugSuite) static func DebugKey<Value: Defaults.Serializable>(_ name: String, default: Value) -> Key<Value> {
Key(name, default: `default`, suite: .appSuite)
}
static let sendProgressReports: Key<Bool> = DebugKey("sendProgressReports", default: true)
} }
#endif #endif

View File

@ -14,7 +14,7 @@ class SwiftfinNotification {
@Injected(Notifications.service) @Injected(Notifications.service)
private var notificationService private var notificationService
private let name: Notification.Name let name: Notification.Name
fileprivate init(_ notificationName: Notification.Name) { fileprivate init(_ notificationName: Notification.Name) {
self.name = notificationName self.name = notificationName
@ -39,7 +39,7 @@ class SwiftfinNotification {
enum Notifications { enum Notifications {
static let service = Factory(scope: .singleton) { NotificationCenter() } static let service = Factory(scope: .singleton) { NotificationCenter.default }
struct Key: Hashable { struct Key: Hashable {
@ -76,6 +76,10 @@ extension Notifications.Key {
static let didChangeCurrentServerURL = NotificationKey("didChangeCurrentServerURL") static let didChangeCurrentServerURL = NotificationKey("didChangeCurrentServerURL")
static let didSendStopReport = NotificationKey("didSendStopReport") static let didSendStopReport = NotificationKey("didSendStopReport")
static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh") static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
static let didFailMigration = NotificationKey("didFailMigration")
static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange") static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange")
static let didConnectToServer = NotificationKey("didConnectToServer")
static let didDeleteServer = NotificationKey("didDeleteServer")
} }

View File

@ -1,230 +0,0 @@
//
// 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 Defaults
import Foundation
typealias ServerModel = SwiftfinStore.Models.StoredServer
typealias UserModel = SwiftfinStore.Models.StoredUser
typealias ServerState = SwiftfinStore.State.Server
typealias UserState = SwiftfinStore.State.User
enum SwiftfinStore {
// MARK: State
// Safe, copyable representations of their underlying CoreStoredObject
// Relationships are represented by object IDs
enum State {
struct Server: Hashable, Identifiable {
let urls: Set<URL>
let currentURL: URL
let name: String
let id: String
let os: String
let version: String
let userIDs: [String]
init(
urls: Set<URL>,
currentURL: URL,
name: String,
id: String,
os: String,
version: String,
usersIDs: [String]
) {
self.urls = urls
self.currentURL = currentURL
self.name = name
self.id = id
self.os = os
self.version = version
self.userIDs = usersIDs
}
static var sample: Server {
.init(
urls: [
.init(string: "http://localhost:8096")!,
],
currentURL: .init(string: "http://localhost:8096")!,
name: "Johnny's Tree",
id: "123abc",
os: "macOS",
version: "1.1.1",
usersIDs: ["1", "2"]
)
}
}
struct User: Hashable, Identifiable {
let accessToken: String
let id: String
let serverID: String
let username: String
fileprivate init(
accessToken: String,
id: String,
serverID: String,
username: String
) {
self.accessToken = accessToken
self.id = id
self.serverID = serverID
self.username = username
}
static var sample: Self {
.init(
accessToken: "open-sesame",
id: "123abc",
serverID: "123abc",
username: "JohnnyAppleseed"
)
}
}
}
// MARK: Models
enum Models {
final class StoredServer: CoreStoreObject {
@Field.Coded("urls", coder: FieldCoders.Json.self)
var urls: Set<URL> = []
@Field.Stored("currentURL")
var currentURL: URL = .init(string: "/")!
@Field.Stored("name")
var name: String = ""
@Field.Stored("id")
var id: String = ""
@Field.Stored("os")
var os: String = ""
@Field.Stored("version")
var version: String = ""
@Field.Relationship("users", inverse: \StoredUser.$server)
var users: Set<StoredUser>
var state: ServerState {
.init(
urls: urls,
currentURL: currentURL,
name: name,
id: id,
os: os,
version: version,
usersIDs: users.map(\.id)
)
}
}
final class StoredUser: CoreStoreObject {
@Field.Stored("accessToken")
var accessToken: String = ""
@Field.Stored("username")
var username: String = ""
@Field.Stored("id")
var id: String = ""
@Field.Stored("appleTVID")
var appleTVID: String = ""
@Field.Relationship("server")
var server: StoredServer?
var state: UserState {
guard let server = server else { fatalError("No server associated with user") }
return .init(
accessToken: accessToken,
id: id,
serverID: server.id,
username: username
)
}
}
}
// MARK: Error
enum Error {
case existingServer(State.Server)
case existingUser(State.User)
}
// MARK: dataStack
private static let v1Schema = CoreStoreSchema(
modelVersion: "V1",
entities: [
Entity<SwiftfinStore.Models.StoredServer>("Server"),
Entity<SwiftfinStore.Models.StoredUser>("User"),
],
versionLock: [
"Server": [
0x4E8_8201_635C_2BB5,
0x7A7_85D8_A65D_177C,
0x3FE6_7B5B_D402_6EEE,
0x8893_16D4_188E_B136,
],
"User": [
0x1001_44F1_4D4D_5A31,
0x828F_7943_7D0B_4C03,
0x3824_5761_B815_D61A,
0x3C1D_BF68_E42B_1DA6,
],
]
)
static let dataStack: DataStack = {
let _dataStack = DataStack(v1Schema)
try! _dataStack.addStorageAndWait(SQLiteStore(
fileName: "Swiftfin.sqlite",
localStorageOptions: .recreateStoreOnModelMismatch
))
return _dataStack
}()
}
// MARK: LocalizedError
extension SwiftfinStore.Error: LocalizedError {
var title: String {
switch self {
case .existingServer:
return L10n.existingServer
case .existingUser:
return L10n.existingUser
}
}
var errorDescription: String? {
switch self {
case let .existingServer(server):
return L10n.serverAlreadyConnected(server.name)
case let .existingUser(user):
return L10n.userAlreadySignedIn(user.username)
}
}
}

View File

@ -0,0 +1,70 @@
//
// 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 CoreData
import CoreStore
import Defaults
import Factory
import Foundation
import JellyfinAPI
import Pulse
import UIKit
final class UserSession {
let client: JellyfinClient
let server: ServerState
let user: UserState
init(
server: ServerState,
user: UserState
) {
self.server = server
self.user = user
let client = JellyfinClient(
configuration: .swiftfinConfiguration(url: server.currentURL),
sessionConfiguration: .swiftfin,
sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger()),
accessToken: user.accessToken
)
self.client = client
}
}
fileprivate extension Container.Scope {
static let userSessionScope = Cached()
}
extension UserSession {
static let current = Factory<UserSession?>(scope: .userSessionScope) {
if let lastUserID = Defaults[.lastSignedInUserID],
let user = try? SwiftfinStore.dataStack.fetchOne(
From<UserModel>().where(\.$id == lastUserID)
)
{
guard let server = user.server,
let existingServer = SwiftfinStore.dataStack.fetchExisting(server)
else {
fatalError("No associated server for last user")
}
return .init(
server: server.state,
user: user.state
)
}
return nil
}
}

View File

@ -0,0 +1,204 @@
//
// 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 CoreStore
import Foundation
import SwiftUI
// TODO: observation
/// A property wrapper for a stored `AnyData` object.
@propertyWrapper
struct StoredValue<Value: Codable>: DynamicProperty {
@ObservedObject
private var observable: Observable
let key: StoredValues.Key<Value>
var projectedValue: Binding<Value> {
$observable.value
}
var wrappedValue: Value {
get {
observable.value
}
nonmutating set {
observable.value = newValue
}
}
init(_ key: StoredValues.Key<Value>) {
self.key = key
self.observable = .init(key: key)
}
mutating func update() {
_observable.update()
}
}
extension StoredValue {
final class Observable: ObservableObject {
let key: StoredValues.Key<Value>
let objectWillChange = ObservableObjectPublisher()
private var objectPublisher: ObjectPublisher<AnyStoredData>?
private var shouldListenToPublish: Bool = true
var value: Value {
get {
guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return key.defaultValue() }
let fetchedValue: Value? = try? AnyStoredData.fetch(
key.name,
ownerID: key.ownerID,
domain: key.domain
)
return fetchedValue ?? key.defaultValue()
}
set {
guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return }
shouldListenToPublish = false
objectWillChange.send()
try? AnyStoredData.store(
value: newValue,
key: key.name,
ownerID: key.ownerID,
domain: key.domain ?? ""
)
shouldListenToPublish = true
}
}
init(key: StoredValues.Key<Value>) {
self.key = key
self.objectPublisher = makeObjectPublisher()
}
private func makeObjectPublisher() -> ObjectPublisher<AnyStoredData>? {
guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return nil }
let domain = key.domain ?? "none"
let clause = From<AnyStoredData>()
.where(\.$ownerID == key.ownerID && \.$key == key.name && \.$domain == domain)
if let values = try? SwiftfinStore.dataStack.fetchAll(clause), let first = values.first {
let publisher = first.asPublisher(in: SwiftfinStore.dataStack)
publisher.addObserver(self) { [weak self] objectPublisher in
guard self?.shouldListenToPublish ?? false else { return }
guard let data = objectPublisher.object?.data else { return }
guard let newValue = try? JSONDecoder().decode(Value.self, from: data) else { fatalError() }
DispatchQueue.main.async {
self?.value = newValue
}
}
return publisher
} else {
// Stored value doesn't exist but we want to observe it.
// Create default and get new publisher
do {
try AnyStoredData.store(
value: key.defaultValue(),
key: key.name,
ownerID: key.ownerID,
domain: key.domain
)
} catch {
LogManager.service().error("Unable to store and create publisher for: \(key)")
return nil
}
return makeObjectPublisher()
}
}
}
}
enum StoredValues {
typealias Keys = _AnyKey
// swiftformat:disable enumnamespaces
class _AnyKey {
typealias Key = StoredValues.Key
}
/// A key to an `AnyData` object.
///
/// - Important: if `name` or `ownerID` are empty, the default value
/// will always be retrieved and nothing will be set.
final class Key<Value: Codable>: _AnyKey {
let defaultValue: () -> Value
let domain: String?
let name: String
let ownerID: String
init(
_ name: String,
ownerID: String,
domain: String?,
default defaultValue: @autoclosure @escaping () -> Value
) {
self.defaultValue = defaultValue
self.domain = domain
self.ownerID = ownerID
self.name = name
}
/// Always returns the given value and does not
/// set anything to storage.
init(always: @autoclosure @escaping () -> Value) {
defaultValue = always
domain = nil
name = ""
ownerID = ""
}
}
// TODO: find way that code isn't just copied from `Observable` above
static subscript<Value: Codable>(key: Key<Value>) -> Value {
get {
guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return key.defaultValue() }
let fetchedValue: Value? = try? AnyStoredData.fetch(
key.name,
ownerID: key.ownerID,
domain: key.domain
)
return fetchedValue ?? key.defaultValue()
}
set {
guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return }
try? AnyStoredData.store(
value: newValue,
key: key.name,
ownerID: key.ownerID,
domain: key.domain ?? ""
)
}
}
}

View File

@ -0,0 +1,58 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import Foundation
import JellyfinAPI
// TODO: also have matching properties on `ServerState` that get/set values
// MARK: keys
extension StoredValues.Keys {
static func ServerKey<Value: Codable>(
_ name: String?,
ownerID: String,
domain: String,
default defaultValue: Value
) -> Key<Value> {
guard let name else {
return Key(always: defaultValue)
}
return Key(
name,
ownerID: ownerID,
domain: domain,
default: defaultValue
)
}
static func ServerKey<Value: Codable>(always: Value) -> Key<Value> {
Key(always: always)
}
}
// MARK: values
extension StoredValues.Keys {
enum Server {
static func publicInfo(id: String) -> Key<PublicSystemInfo> {
ServerKey(
"publicInfo",
ownerID: id,
domain: "publicInfo",
default: .init()
)
}
}
}

View File

@ -0,0 +1,73 @@
//
// 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 JellyfinAPI
// Note: Temporary values to avoid refactoring or
// reduce complexity at local sites.
//
// Values can be cleaned up at any time so and are
// meant to have a short lifetime.
extension StoredValues.Keys {
static func TempKey<Value: Codable>(
_ name: String?,
ownerID: String,
domain: String,
default defaultValue: Value
) -> Key<Value> {
guard let name else {
return Key(always: defaultValue)
}
return Key(
name,
ownerID: ownerID,
domain: domain,
default: defaultValue
)
}
}
// MARK: values
extension StoredValues.Keys {
enum Temp {
static let userSignInPolicy: Key<UserAccessPolicy> = TempKey(
"userSignInPolicy",
ownerID: "temporary",
domain: "userSignInPolicy",
default: .none
)
static let userLocalPin: Key<String> = TempKey(
"userLocalPin",
ownerID: "temporary",
domain: "userLocalPin",
default: ""
)
static let userLocalPinHint: Key<String> = TempKey(
"userLocalPinHint",
ownerID: "temporary",
domain: "userLocalPinHint",
default: ""
)
static let userData: Key<UserDto> = TempKey(
"tempUserData",
ownerID: "temporary",
domain: "tempUserData",
default: .init()
)
}
}

View File

@ -0,0 +1,136 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import Foundation
import JellyfinAPI
// TODO: also have matching properties on `UserState` that get/set values
// TODO: cleanup/organize
// MARK: keys
extension StoredValues.Keys {
/// Construct a key where `ownerID` is the id of the user in the
/// current user session, or always returns the default if there
/// isn't a current session user.
static func CurrentUserKey<Value: Codable>(
_ name: String?,
domain: String,
default defaultValue: Value
) -> Key<Value> {
guard let name, let currentUser = UserSession.current()?.user else {
return Key(always: defaultValue)
}
return Key(
name,
ownerID: currentUser.id,
domain: domain,
default: defaultValue
)
}
static func UserKey<Value: Codable>(
_ name: String?,
ownerID: String,
domain: String,
default defaultValue: Value
) -> Key<Value> {
guard let name else {
return Key(always: defaultValue)
}
return Key(
name,
ownerID: ownerID,
domain: domain,
default: defaultValue
)
}
static func UserKey<Value: Codable>(always: Value) -> Key<Value> {
Key(always: always)
}
}
// MARK: values
extension StoredValues.Keys {
enum User {
// Doesn't use `CurrentUserKey` because data may be
// retrieved and stored without a user session
static func accessPolicy(id: String) -> Key<UserAccessPolicy> {
UserKey(
"accessPolicy",
ownerID: id,
domain: "accessPolicy",
default: .none
)
}
// Doesn't use `CurrentUserKey` because data may be
// retrieved and stored without a user session
static func data(id: String) -> Key<UserDto> {
UserKey(
"userData",
ownerID: id,
domain: "userData",
default: .init()
)
}
static func libraryDisplayType(parentID: String?) -> Key<LibraryDisplayType> {
CurrentUserKey(
parentID,
domain: "libraryDisplayType",
default: Defaults[.Customization.Library.displayType]
)
}
static func libraryListColumnCount(parentID: String?) -> Key<Int> {
CurrentUserKey(
parentID,
domain: "libraryListColumnCount",
default: Defaults[.Customization.Library.listColumnCount]
)
}
static func libraryPosterType(parentID: String?) -> Key<PosterDisplayType> {
CurrentUserKey(
parentID,
domain: "libraryPosterType",
default: Defaults[.Customization.Library.posterType]
)
}
// TODO: for now, only used for `sortBy` and `sortOrder`. Need to come up with
// rules for how stored filters work with libraries that should init
// with non-default filters (atow ex: favorites)
static func libraryFilters(parentID: String?) -> Key<ItemFilterCollection> {
CurrentUserKey(
parentID,
domain: "libraryFilters",
default: ItemFilterCollection.default
)
}
static func pinHint(id: String) -> Key<String> {
UserKey(
"pinHint",
ownerID: id,
domain: "pinHint",
default: ""
)
}
}
}

View File

@ -0,0 +1,52 @@
//
// 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 KeychainSwift
extension SwiftfinStore {
enum Mappings {}
}
extension SwiftfinStore.Mappings {
// MARK: User V1 to V2
// V1 users had access token stored in Core Data.
// Move to the Keychain.
static let userV1_V2 = {
CustomSchemaMappingProvider(
from: "V1",
to: "V2",
entityMappings: [
.transformEntity(
sourceEntity: "User",
destinationEntity: "User",
transformer: { sourceObject, createDestinationObject in
// move access token to Keychain
if let id = sourceObject["id"] as? String, let accessToken = sourceObject["accessToken"] as? String {
Keychain.service().set(accessToken, forKey: "\(id)-accessToken")
} else {
fatalError("wtf")
}
let destinationObject = createDestinationObject()
destinationObject.enumerateAttributes { attribute, sourceAttribute in
if let sourceAttribute {
destinationObject[attribute] = sourceObject[attribute]
}
}
}
),
]
)
}()
}

View File

@ -0,0 +1,80 @@
//
// 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 JellyfinAPI
import Pulse
extension SwiftfinStore.State {
struct Server: Hashable, Identifiable {
let urls: Set<URL>
let currentURL: URL
let name: String
let id: String
let userIDs: [String]
init(
urls: Set<URL>,
currentURL: URL,
name: String,
id: String,
usersIDs: [String]
) {
self.urls = urls
self.currentURL = currentURL
self.name = name
self.id = id
self.userIDs = usersIDs
}
/// - Note: Since this is created from a server, it does not
/// have a user access token.
var client: JellyfinClient {
JellyfinClient(
configuration: .swiftfinConfiguration(url: currentURL),
sessionConfiguration: .swiftfin,
sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger())
)
}
}
}
extension ServerState {
/// Deletes the model that this state represents and
/// all settings from `StoredValues`.
func delete() throws {
try SwiftfinStore.dataStack.perform { transaction in
guard let storedServer = try transaction.fetchOne(From<ServerModel>().where(\.$id == id)) else {
throw JellyfinAPIError("Unable to find server to delete")
}
let storedDataClause = AnyStoredData.fetchClause(ownerID: id)
let storedData = try transaction.fetchAll(storedDataClause)
transaction.delete(storedData)
transaction.delete(storedServer)
}
}
func getPublicSystemInfo() async throws -> PublicSystemInfo {
let request = Paths.getPublicSystemInfo
let response = try await client.send(request)
return response.value
}
func splashScreenImageSource() -> ImageSource {
let request = Paths.getSplashscreen()
return ImageSource(url: client.fullURL(with: request))
}
}

View File

@ -0,0 +1,77 @@
//
// 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 Factory
import Foundation
import JellyfinAPI
typealias AnyStoredData = SwiftfinStore.V2.AnyData
typealias ServerModel = SwiftfinStore.V2.StoredServer
typealias UserModel = SwiftfinStore.V2.StoredUser
typealias ServerState = SwiftfinStore.State.Server
typealias UserState = SwiftfinStore.State.User
// MARK: Namespaces
enum SwiftfinStore {
/// Namespace for V1 objects
enum V1 {}
/// Namespace for V2 objects
enum V2 {}
/// Namespace for state objects
enum State {}
}
// MARK: dataStack
// TODO: cleanup
extension SwiftfinStore {
static let dataStack: DataStack = {
DataStack(
V1.schema,
V2.schema,
migrationChain: ["V1", "V2"]
)
}()
private static let storage: SQLiteStore = {
SQLiteStore(
fileName: "Swiftfin.sqlite",
migrationMappingProviders: [Mappings.userV1_V2]
)
}()
static func requiresMigration() throws -> Bool {
try dataStack.requiredMigrationsForStorage(storage).isNotEmpty
}
static func setupDataStack() async throws {
try await withCheckedThrowingContinuation { continuation in
_ = dataStack.addStorage(storage) { result in
switch result {
case .success:
continuation.resume()
case let .failure(error):
LogManager.service().error("Failed creating datastack with: \(error.localizedDescription)")
continuation.resume(throwing: JellyfinAPIError("Failed creating datastack with: \(error.localizedDescription)"))
}
}
}
}
static let service = Factory<DataStack>(scope: .singleton) {
SwiftfinStore.dataStack
}
}

View File

@ -0,0 +1,162 @@
//
// 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 JellyfinAPI
import KeychainSwift
import Pulse
import UIKit
// Note: it is kind of backwards to have a "state" object with a mix of
// non-mutable and "mutable" values, but it just works.
extension SwiftfinStore.State {
struct User: Hashable, Identifiable {
let id: String
let serverID: String
let username: String
init(
id: String,
serverID: String,
username: String
) {
self.id = id
self.serverID = serverID
self.username = username
}
}
}
extension UserState {
typealias Key = StoredValues.Key
var accessToken: String {
get {
guard let accessToken = Keychain.service().get("\(id)-accessToken") else {
assertionFailure("access token missing in keychain")
return ""
}
return accessToken
}
nonmutating set {
Keychain.service().set(newValue, forKey: "\(id)-accessToken")
}
}
var data: UserDto {
get {
StoredValues[.User.data(id: id)]
}
nonmutating set {
StoredValues[.User.data(id: id)] = newValue
}
}
var pinHint: String {
get {
StoredValues[.User.pinHint(id: id)]
}
nonmutating set {
StoredValues[.User.pinHint(id: id)] = newValue
}
}
// TODO: rename to accessPolicy and fix all uses
var signInPolicy: UserAccessPolicy {
get {
StoredValues[.User.accessPolicy(id: id)]
}
nonmutating set {
StoredValues[.User.accessPolicy(id: id)] = newValue
}
}
}
extension UserState {
/// Deletes the model that this state represents and
/// all settings from `Defaults` `Keychain`, and `StoredValues`
func delete() throws {
try SwiftfinStore.dataStack.perform { transaction in
guard let storedUser = try transaction.fetchOne(From<UserModel>().where(\.$id == id)) else {
throw JellyfinAPIError("Unable to find user to delete")
}
let storedDataClause = AnyStoredData.fetchClause(ownerID: id)
let storedData = try transaction.fetchAll(storedDataClause)
transaction.delete(storedUser)
transaction.delete(storedData)
}
UserDefaults.userSuite(id: id).removeAll()
let keychain = Keychain.service()
keychain.delete("\(id)-pin")
}
/// Deletes user settings from `UserDefaults` and `StoredValues`
///
/// Note: if performing deletion with another transaction, use
/// `AnyStoredData.fetchClause` instead within that transaction
/// and delete `Defaults` manually
func deleteSettings() throws {
try SwiftfinStore.dataStack.perform { transaction in
let userData = try transaction.fetchAll(
From<AnyStoredData>()
.where(\.$ownerID == id)
)
transaction.delete(userData)
}
UserDefaults.userSuite(id: id).removeAll()
}
/// Must pass the server to create a JellyfinClient
/// with an access token
func getUserData(server: ServerState) async throws -> UserDto {
let client = JellyfinClient(
configuration: .swiftfinConfiguration(url: server.currentURL),
sessionConfiguration: .swiftfin,
sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger()),
accessToken: accessToken
)
let request = Paths.getCurrentUser
let response = try await client.send(request)
return response.value
}
func profileImageSource(
client: JellyfinClient,
maxWidth: CGFloat? = nil,
maxHeight: CGFloat? = nil
) -> ImageSource {
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 request = Paths.getUserImage(
userID: id,
imageType: "Primary",
parameters: parameters
)
let profileImageURL = client.fullURL(with: request)
return ImageSource(url: profileImageURL)
}
}

View File

@ -0,0 +1,35 @@
//
// 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
extension SwiftfinStore.V1 {
static let schema = CoreStoreSchema(
modelVersion: "V1",
entities: [
Entity<StoredServer>("Server"),
Entity<StoredUser>("User"),
],
versionLock: [
"Server": [
0x4E8_8201_635C_2BB5,
0x7A7_85D8_A65D_177C,
0x3FE6_7B5B_D402_6EEE,
0x8893_16D4_188E_B136,
],
"User": [
0x1001_44F1_4D4D_5A31,
0x828F_7943_7D0B_4C03,
0x3824_5761_B815_D61A,
0x3C1D_BF68_E42B_1DA6,
],
]
)
}

View File

@ -0,0 +1,47 @@
//
// 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
extension SwiftfinStore.V1 {
final class StoredServer: CoreStoreObject {
@Field.Coded("urls", coder: FieldCoders.Json.self)
var urls: Set<URL> = []
@Field.Stored("currentURL")
var currentURL: URL = .init(string: "/")!
@Field.Stored("name")
var name: String = ""
@Field.Stored("id")
var id: String = ""
@Field.Stored("os")
var os: String = ""
@Field.Stored("version")
var version: String = ""
@Field.Relationship("users", inverse: \StoredUser.$server)
var users: Set<StoredUser>
var state: ServerState {
.init(
urls: urls,
currentURL: currentURL,
name: name,
id: id,
usersIDs: users.map(\.id)
)
}
}
}

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 CoreStore
import Foundation
extension SwiftfinStore.V1 {
final class StoredUser: CoreStoreObject {
@Field.Stored("accessToken")
var accessToken: String = ""
@Field.Stored("username")
var username: String = ""
@Field.Stored("id")
var id: String = ""
@Field.Stored("appleTVID")
var appleTVID: String = ""
@Field.Relationship("server")
var server: StoredServer?
var state: UserState {
guard let server = server else { fatalError("No server associated with user") }
return .init(
id: id,
serverID: server.id,
username: username
)
}
}
}

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 CoreStore
import Foundation
// TODO: complete and make migration
extension SwiftfinStore.V2 {
static let schema = CoreStoreSchema(
modelVersion: "V2",
entities: [
Entity<StoredServer>("Server"),
Entity<StoredUser>("User"),
Entity<AnyData>("AnyData"),
],
versionLock: [
"AnyData": [0x749D_39C2_219D_4918, 0x9281_539F_1DFB_63E1, 0x293F_D0B7_B64C_E984, 0x8F2F_91F2_33EA_8EB5],
"Server": [0xC831_8BCA_3734_8B36, 0x78F9_E383_4EC4_0409, 0xC32D_7C44_D347_6825, 0x8593_766E_CEC6_0CFD],
"User": [0xAE4F_5BDB_1E41_8019, 0x7E5D_7722_D051_7C12, 0x3867_AC59_9F91_A895, 0x6CB9_F896_6ED4_4944],
]
)
}

View File

@ -0,0 +1,169 @@
//
// 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 CoreStore
import Defaults
import Factory
import Foundation
import SwiftUI
extension SwiftfinStore.V2 {
/// Used to store arbitrary data with a `name` and `ownerID`.
///
/// Essentially just a bag-of-bytes model like UserDefaults, but for
/// storing larger objects or arbitrary collection elements.
///
/// Relationships generally take the form below, where `ownerID` is like
/// an object, `domain`s are property names, and `key`s are values within
/// the `domain`. An instance where `domain == key` is like a single-value
/// property while a `domain` with many `keys` is like a dictionary.
///
/// ownerID
/// - domain
/// - key(s)
/// - domain
/// - key(s)
///
/// This can be useful to not require migrations on model objects for new
/// "properties".
final class AnyData: CoreStoreObject {
@Field.Stored("data")
var data: Data? = nil
@Field.Stored("domain")
var domain: String = ""
@Field.Stored("key")
var key: String = ""
@Field.Stored("ownerID")
var ownerID: String = ""
}
}
extension AnyStoredData {
/// Note: if `domain == nil`, will default to "none" to avoid local typing issues.
static func fetch<Value: Codable>(_ key: String, ownerID: String, domain: String? = nil) throws -> Value? {
let domain = domain ?? "none"
let clause = From<AnyStoredData>()
.where(\.$ownerID == ownerID && \.$key == key && \.$domain == domain)
let values = try SwiftfinStore.dataStack
.fetchAll(
clause
)
.compactMap(\.data)
.compactMap {
try JSONDecoder().decode(Value.self, from: $0)
}
assert(values.count < 2, "More than one stored object for same name, id, and domain!")
return values.first
}
/// Note: if `domain == nil`, will default to "none" to avoid local typing issues.
static func store<Value: Codable>(value: Value, key: String, ownerID: String, domain: String? = nil) throws {
let domain = domain ?? "none"
let clause = From<AnyStoredData>()
.where(\.$ownerID == ownerID && \.$key == key && \.$domain == domain)
try SwiftfinStore.dataStack.perform { transaction in
let existing = try transaction.fetchAll(clause)
assert(existing.count < 2, "More than one stored object for same name, id, and domain!")
let encodedData = try JSONEncoder().encode(value)
if let existingObject = existing.first {
let edit = transaction.edit(existingObject)
edit?.data = encodedData
} else {
let newData = transaction.create(Into<AnyStoredData>())
newData.data = encodedData
newData.domain = domain
newData.ownerID = ownerID
newData.key = key
}
}
}
/// Creates a fetch clause to be used within local transactions
static func fetchClause(ownerID: String) -> FetchChainBuilder<AnyStoredData> {
From<AnyStoredData>()
.where(\.$ownerID == ownerID)
}
/// Creates a fetch clause to be used within local transactions
///
/// Note: if `domain == nil`, will default to "none"
static func fetchClause(ownerID: String, domain: String? = nil) throws -> FetchChainBuilder<AnyStoredData> {
let domain = domain ?? "none"
return From<AnyStoredData>()
.where(\.$ownerID == ownerID && \.$domain == domain)
}
/// Creates a fetch clause to be used within local transactions
///
/// Note: if `domain == nil`, will default to "none"
static func fetchClause(key: String, ownerID: String, domain: String? = nil) throws -> FetchChainBuilder<AnyStoredData> {
let domain = domain ?? "none"
return From<AnyStoredData>()
.where(\.$ownerID == ownerID && \.$key == key && \.$domain == domain)
}
/// Delete all data with the given `ownerID`
///
/// Note: if performing deletion with another transaction, use `fetchClause`
/// instead to delete within the other transaction
static func deleteAll(ownerID: String) throws {
try SwiftfinStore.dataStack.perform { transaction in
let values = try transaction.fetchAll(fetchClause(ownerID: ownerID))
transaction.delete(values)
}
}
/// Delete all data with the given `ownerID` and `domain`
///
/// Note: if performing deletion with another transaction, use `fetchClause`
/// instead to delete within the other transaction
/// Note: if `domain == nil`, will default to "none"
static func deleteAll(ownerID: String, domain: String? = nil) throws {
try SwiftfinStore.dataStack.perform { transaction in
let values = try transaction.fetchAll(fetchClause(ownerID: ownerID, domain: domain))
transaction.delete(values)
}
}
/// Delete all data given `key`, `ownerID`, and `domain`.
///
///
/// Note: if performing deletion with another transaction, use `fetchClause`
/// instead to delete within the other transaction
/// Note: if `domain == nil`, will default to "none"
static func delete(key: String, ownerID: String, domain: String? = nil) throws {
try SwiftfinStore.dataStack.perform { transaction in
let values = try transaction.fetchAll(fetchClause(key: key, ownerID: ownerID, domain: domain))
transaction.delete(values)
}
}
}

View File

@ -0,0 +1,43 @@
//
// 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
// TODO: complete and make migration
extension SwiftfinStore.V2 {
final class StoredServer: CoreStoreObject {
@Field.Coded("urls", coder: FieldCoders.Json.self)
var urls: Set<URL> = []
@Field.Stored("currentURL")
var currentURL: URL = .init(string: "/")!
@Field.Stored("name")
var name: String = ""
@Field.Stored("id")
var id: String = ""
@Field.Relationship("users", inverse: \StoredUser.$server)
var users: Set<StoredUser>
var state: ServerState {
.init(
urls: urls,
currentURL: currentURL,
name: name,
id: id,
usersIDs: users.map(\.id)
)
}
}
}

View File

@ -0,0 +1,37 @@
//
// 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 UIKit
// TODO: complete and make migration
extension SwiftfinStore.V2 {
final class StoredUser: CoreStoreObject {
@Field.Stored("username")
var username: String = ""
@Field.Stored("id")
var id: String = ""
@Field.Relationship("server")
var server: StoredServer?
var state: UserState {
guard let server = server else { fatalError("No server associated with user") }
return .init(
id: id,
serverID: server.id,
username: username
)
}
}
}

View File

@ -6,45 +6,137 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors // Copyright (c) 2024 Jellyfin & Jellyfin Contributors
// //
import Combine
import CoreStore import CoreStore
import CryptoKit
import Defaults
import Factory
import Foundation import Foundation
import Get import Get
import JellyfinAPI import JellyfinAPI
import OrderedCollections
import Pulse import Pulse
import UIKit
final class ConnectToServerViewModel: ViewModel { final class ConnectToServerViewModel: ViewModel, Eventful, Stateful {
// MARK: Event
enum Event {
case connected(ServerState)
case duplicateServer(ServerState)
case error(JellyfinAPIError)
}
// MARK: Action
enum Action: Equatable {
case addNewURL(ServerState)
case cancel
case connect(String)
case searchForServers
}
// MARK: BackgroundState
enum BackgroundState: Hashable {
case searching
}
// MARK: State
enum State: Hashable {
case connecting
case initial
}
@Published @Published
private(set) var discoveredServers: [ServerState] = [] var backgroundStates: OrderedSet<BackgroundState> = []
// no longer-found servers are not cleared, but not an issue
@Published @Published
private(set) var isSearching = false var localServers: OrderedSet<ServerState> = []
@Published
var state: State = .initial
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var connectTask: AnyCancellable? = nil
private let discovery = ServerDiscovery() private let discovery = ServerDiscovery()
private var eventSubject: PassthroughSubject<Event, Never> = .init()
var connectToServerTask: Task<ServerState, Error>? deinit {
discovery.close()
}
func connectToServer(url: String) async throws -> (server: ServerState, url: URL) { override init() {
super.init()
#if os(iOS) Task { [weak self] in
// shhhh guard let self else { return }
// TODO: remove
if let data = url.data(using: .utf8) {
var sha = SHA256()
sha.update(data: data)
let digest = sha.finalize()
let urlHash = digest.compactMap { String(format: "%02x", $0) }.joined()
if urlHash == "7499aced43869b27f505701e4edc737f0cc346add1240d4ba86fbfa251e0fc35" {
Defaults[.Experimental.downloads] = true
await UIDevice.feedback(.success) for await response in discovery.discoveredServers.values {
await MainActor.run {
let _ = self.localServers.append(response.asServerState)
}
} }
} }
#endif .store(in: &cancellables)
}
func respond(to action: Action) -> State {
switch action {
case let .addNewURL(server):
addNewURL(server: server)
return state
case .cancel:
connectTask?.cancel()
return .initial
case let .connect(url):
connectTask?.cancel()
connectTask = Task {
do {
let server = try await connectToServer(url: url)
if isDuplicate(server: server) {
await MainActor.run {
// server has same id, but (possible) new URL
self.eventSubject.send(.duplicateServer(server))
}
} else {
try await save(server: server)
await MainActor.run {
self.eventSubject.send(.connected(server))
}
}
await MainActor.run {
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 .connecting
case .searchForServers:
discovery.broadcast()
return state
}
}
private func connectToServer(url: String) async throws -> ServerState {
let formattedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) let formattedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .objectReplacement) .trimmingCharacters(in: .objectReplacement)
@ -54,37 +146,35 @@ final class ConnectToServerViewModel: ViewModel {
let client = JellyfinClient( let client = JellyfinClient(
configuration: .swiftfinConfiguration(url: url), configuration: .swiftfinConfiguration(url: url),
sessionDelegate: URLSessionProxyDelegate() sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger())
) )
let response = try await client.send(Paths.getPublicSystemInfo) let response = try await client.send(Paths.getPublicSystemInfo)
guard let name = response.value.serverName, guard let name = response.value.serverName,
let id = response.value.id, let id = response.value.id
let os = response.value.operatingSystem,
let version = response.value.version
else { else {
throw JellyfinAPIError("Missing server data from network call") logger.critical("Missing server data from network call")
throw JellyfinAPIError("An internal error has occurred")
} }
// in case of redirects, we must process the new URL let connectionURL = processConnectionURL(
initial: url,
let connectionURL = processConnectionURL(initial: url, response: response.response.url) response: response.response.url
)
let newServerState = ServerState( let newServerState = ServerState(
urls: [connectionURL], urls: [connectionURL],
currentURL: connectionURL, currentURL: connectionURL,
name: name, name: name,
id: id, id: id,
os: os,
version: version,
usersIDs: [] usersIDs: []
) )
return (newServerState, url) return newServerState
} }
// TODO: this probably isn't the best way to properly handle this, fix if necessary // In the event of redirects, get the new host URL from response
private func processConnectionURL(initial url: URL, response: URL?) -> URL { private func processConnectionURL(initial url: URL, response: URL?) -> URL {
guard let response else { return url } guard let response else { return url }
@ -105,72 +195,48 @@ final class ConnectToServerViewModel: ViewModel {
return url return url
} }
func isDuplicate(server: ServerState) -> Bool { private func isDuplicate(server: ServerState) -> Bool {
if let _ = try? SwiftfinStore.dataStack.fetchOne( let existingServer = try? SwiftfinStore
From<SwiftfinStore.Models.StoredServer>(), .dataStack
[Where<SwiftfinStore.Models.StoredServer>( .fetchOne(From<ServerModel>().where(\.$id == server.id))
"id == %@", return existingServer != nil
server.id
)]
) {
return true
}
return false
} }
func save(server: ServerState) throws { private func save(server: ServerState) async throws {
try SwiftfinStore.dataStack.perform { transaction in try dataStack.perform { transaction in
let newServer = transaction.create(Into<SwiftfinStore.Models.StoredServer>()) let newServer = transaction.create(Into<ServerModel>())
newServer.urls = server.urls newServer.urls = server.urls
newServer.currentURL = server.currentURL newServer.currentURL = server.currentURL
newServer.name = server.name newServer.name = server.name
newServer.id = server.id newServer.id = server.id
newServer.os = server.os
newServer.version = server.version
newServer.users = [] newServer.users = []
} }
let publicInfo = try await server.getPublicSystemInfo()
StoredValues[.Server.publicInfo(id: server.id)] = publicInfo
} }
func discoverServers() { // server has same id, but (possible) new URL
isSearching = true private func addNewURL(server: ServerState) {
discoveredServers.removeAll() do {
let newState = try dataStack.perform { transaction in
let existingServer = try self.dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id))
guard let editServer = transaction.edit(existingServer) else {
logger.critical("Could not find server to add new url")
throw JellyfinAPIError("An internal error has occurred")
}
var _discoveredServers: Set<SwiftfinStore.State.Server> = [] editServer.urls.insert(server.currentURL)
editServer.currentURL = server.currentURL
discovery.locateServer { server in return editServer.state
if let server = server {
_discoveredServers.insert(.init(
urls: [],
currentURL: server.url,
name: server.name,
id: server.id,
os: "",
version: "",
usersIDs: []
))
} }
}
// Timeout after 3 seconds Notifications[.didChangeCurrentServerURL].post(object: newState)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { } catch {
self.isSearching = false logger.critical("\(error.localizedDescription)")
self.discoveredServers = _discoveredServers.sorted(by: { $0.name < $1.name })
}
}
func add(url: URL, server: ServerState) {
try! SwiftfinStore.dataStack.perform { transaction in
let existingServer = try! SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
)]
)
let editServer = transaction.edit(existingServer)!
editServer.urls.insert(url)
} }
} }
} }

View File

@ -85,24 +85,26 @@ final class HomeViewModel: ViewModel, Stateful {
backgroundStates.append(.refresh) backgroundStates.append(.refresh)
backgroundRefreshTask = Task { [weak self] in backgroundRefreshTask = Task { [weak self] in
guard let self else { return }
do { do {
self?.nextUpViewModel.send(.refresh)
self?.recentlyAddedViewModel.send(.refresh)
nextUpViewModel.send(.refresh) let resumeItems = try await self?.getResumeItems() ?? []
recentlyAddedViewModel.send(.refresh)
let resumeItems = try await getResumeItems()
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
guard let self else { return }
self.resumeItems.elements = resumeItems self.resumeItems.elements = resumeItems
self.backgroundStates.remove(.refresh) self.backgroundStates.remove(.refresh)
} }
} catch is CancellationError {
// cancelled
} catch { } catch {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
guard let self else { return }
self.backgroundStates.remove(.refresh) self.backgroundStates.remove(.refresh)
self.send(.error(.init(error.localizedDescription))) self.send(.error(.init(error.localizedDescription)))
} }
@ -127,20 +129,22 @@ final class HomeViewModel: ViewModel, Stateful {
refreshTask?.cancel() refreshTask?.cancel()
refreshTask = Task { [weak self] in refreshTask = Task { [weak self] in
guard let self else { return }
do { do {
try await self?.refresh()
try await self.refresh()
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
guard let self else { return }
self.state = .content self.state = .content
} }
} catch is CancellationError {
// cancelled
} catch { } catch {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
guard let self else { return }
self.send(.error(.init(error.localizedDescription))) self.send(.error(.init(error.localizedDescription)))
} }
} }

View File

@ -13,7 +13,7 @@ import JellyfinAPI
final class NextUpLibraryViewModel: PagingLibraryViewModel<BaseItemDto> { final class NextUpLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
init() { init() {
super.init(parent: TitledLibraryParent(displayTitle: L10n.nextUp)) super.init(parent: TitledLibraryParent(displayTitle: L10n.nextUp, id: "nextUp"))
} }
override func get(page: Int) async throws -> [BaseItemDto] { override func get(page: Int) async throws -> [BaseItemDto] {

View File

@ -7,6 +7,7 @@
// //
import Combine import Combine
import Defaults
import Foundation import Foundation
import Get import Get
import JellyfinAPI import JellyfinAPI
@ -23,11 +24,19 @@ private let DefaultPageSize = 50
// on refresh. Should make bidirectional/offset index start? // on refresh. Should make bidirectional/offset index start?
// - use startIndex/index ranges instead of pages // - use startIndex/index ranges instead of pages
// - source of data doesn't guarantee that all items in 0 ..< startIndex exist // - source of data doesn't guarantee that all items in 0 ..< startIndex exist
/*
Note: if `rememberSort == true`, then will override given filters with stored sorts
for parent ID. This was just easy. See `PagingLibraryView` notes for lack of
`rememberSort` observation and `StoredValues.User.libraryFilters` for TODO
on remembering other filters.
*/
class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful { class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
// MARK: Event // MARK: Event
enum Event: Equatable { enum Event {
case gotRandomItem(Element) case gotRandomItem(Element)
} }
@ -103,9 +112,16 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
convenience init( convenience init(
title: String, title: String,
id: String?,
_ data: some Collection<Element> _ data: some Collection<Element>
) { ) {
self.init(data, parent: TitledLibraryParent(displayTitle: title)) self.init(
data,
parent: TitledLibraryParent(
displayTitle: title,
id: id
)
)
} }
// paging // paging
@ -120,6 +136,19 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
self.parent = parent self.parent = parent
if let filters { if let filters {
var filters = filters
if let id = parent?.id, Defaults[.Customization.Library.rememberSort] {
// TODO: see `StoredValues.User.libraryFilters` for TODO
// on remembering other filters
let storedFilters = StoredValues[.User.libraryFilters(parentID: id)]
filters = filters
.mutating(\.sortBy, with: storedFilters.sortBy)
.mutating(\.sortOrder, with: storedFilters.sortOrder)
}
self.filterViewModel = .init( self.filterViewModel = .init(
parent: parent, parent: parent,
currentFilters: filters currentFilters: filters
@ -148,11 +177,15 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
convenience init( convenience init(
title: String, title: String,
filters: ItemFilterCollection = .default, id: String?,
filters: ItemFilterCollection? = nil,
pageSize: Int = DefaultPageSize pageSize: Int = DefaultPageSize
) { ) {
self.init( self.init(
parent: TitledLibraryParent(displayTitle: title), parent: TitledLibraryParent(
displayTitle: title,
id: id
),
filters: filters, filters: filters,
pageSize: pageSize pageSize: pageSize
) )

View File

@ -17,10 +17,11 @@ final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
// Necessary because this is paginated and also used on home view // Necessary because this is paginated and also used on home view
init(customPageSize: Int? = nil) { init(customPageSize: Int? = nil) {
// Why doesn't `super.init(title:id:pageSize)` init work?
if let customPageSize { if let customPageSize {
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded), pageSize: customPageSize) super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded"), pageSize: customPageSize)
} else { } else {
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded)) super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded"))
} }
} }

View File

@ -0,0 +1,92 @@
//
// 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
final class QuickConnectAuthorizeViewModel: ViewModel, Eventful, Stateful {
// MARK: Event
enum Event {
case authorized
case error(JellyfinAPIError)
}
// MARK: Action
enum Action: Equatable {
case authorize(String)
case cancel
}
// MARK: State
enum State: Hashable {
case authorizing
case initial
}
@Published
var lastAction: Action? = nil
@Published
var state: State = .initial
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var authorizeTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init()
func respond(to action: Action) -> State {
switch action {
case let .authorize(code):
authorizeTask = Task {
try? await Task.sleep(nanoseconds: 10_000_000_000)
do {
try await authorize(code: code)
await MainActor.run {
self.eventSubject.send(.authorized)
self.state = .initial
}
} catch {
await MainActor.run {
self.eventSubject.send(.error(.init(error.localizedDescription)))
self.state = .initial
}
}
}
.asAnyCancellable()
return .authorizing
case .cancel:
authorizeTask?.cancel()
return .initial
}
}
private func authorize(code: String) async throws {
let request = Paths.authorize(code: code)
let response = try await userSession.client.send(request)
let decoder = JSONDecoder()
let isAuthorized = (try? decoder.decode(Bool.self, from: response.value)) ?? false
if !isAuthorized {
throw JellyfinAPIError("Authorization unsuccessful")
}
}
}

View File

@ -1,25 +0,0 @@
//
// 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 JellyfinAPI
final class QuickConnectSettingsViewModel: ViewModel {
func authorize(code: String) async throws {
let request = Paths.authorize(code: code)
let response = try await userSession.client.send(request)
let decoder = JSONDecoder()
let isAuthorized = (try? decoder.decode(Bool.self, from: response.value)) ?? false
if !isAuthorized {
throw JellyfinAPIError("Authorization unsuccessful")
}
}
}

View File

@ -1,173 +0,0 @@
//
// 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 Defaults
import Factory
import Foundation
import JellyfinAPI
import Pulse
/// Handles getting and exposing quick connect code and related states and polling for authentication secret and
/// exposing it to a consumer.
/// __Does not handle using the authentication secret itself to sign in.__
final class QuickConnectViewModel: ViewModel, Stateful {
// MARK: Action
enum Action {
case startQuickConnect
case cancelQuickConnect
}
// MARK: State
// The typical quick connect lifecycle is as follows:
enum State: Hashable {
// 0. User has not interacted with quick connect
case initial
// 1. User clicks quick connect
case fetchingSecret
// 2. We fetch a secret and code from the server
// 3. Display the code to user, poll for authentication from server using secret
// 4. User enters code to the server
case awaitingAuthentication(code: String)
// 5. Authentication poll succeeds with another secret. A consumer uses this secret to sign in.
// In particular, the responsibility to consume this secret and handle any errors and state changes
// is deferred to the consumer.
case authenticated(secret: String)
// Store the error and surface it to user if possible
case error(QuickConnectError)
}
// TODO: Consider giving these errors a message and using it in the QuickConnectViews
enum QuickConnectError: Error {
case fetchSecretFailed
case pollingFailed
case unknown
}
@Published
var state: State = .initial
var lastAction: Action? = nil
let client: JellyfinClient
/// How often to poll quick connect auth
private let quickConnectPollTimeoutSeconds: Int = 5
private let quickConnectMaxRetries: Int = 200
private var quickConnectPollTask: Task<String, any Error>?
init(client: JellyfinClient) {
self.client = client
super.init()
}
func respond(to action: Action) -> State {
switch action {
case .startQuickConnect:
Task {
await fetchAuthCode()
}
return .fetchingSecret
case .cancelQuickConnect:
stopQuickConnectAuthCheck()
return .initial
}
}
/// Retrieves sign in secret, and stores it in the state for a consumer to use.
private func fetchAuthCode() async {
do {
await MainActor.run {
state = .fetchingSecret
}
let (initiateSecret, code) = try await startQuickConnect()
await MainActor.run {
state = .awaitingAuthentication(code: code)
}
let authSecret = try await pollForAuthSecret(initialSecret: initiateSecret)
await MainActor.run {
state = .authenticated(secret: authSecret)
}
} catch let error as QuickConnectError {
await MainActor.run {
state = .error(error)
}
} catch {
await MainActor.run {
state = .error(.unknown)
}
}
}
/// Gets secret and code to start quick connect authorization flow.
private func startQuickConnect() async throws -> (secret: String, code: String) {
logger.debug("Attempting to start quick connect...")
let initiatePath = Paths.initiate
let response = try await client.send(initiatePath)
guard let secret = response.value.secret,
let code = response.value.code
else {
throw QuickConnectError.fetchSecretFailed
}
return (secret, code)
}
private func pollForAuthSecret(initialSecret: String) async throws -> String {
let task = Task {
var authSecret: String?
for _ in 1 ... quickConnectMaxRetries {
authSecret = try await checkAuth(initialSecret: initialSecret)
if authSecret != nil { break }
try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * quickConnectPollTimeoutSeconds))
}
guard let authSecret = authSecret else {
logger.warning("Hit max retries while using quick connect, did the `pollForAuthSecret` task keep running after signing in?")
throw QuickConnectError.pollingFailed
}
return authSecret
}
quickConnectPollTask = task
return try await task.result.get()
}
private func checkAuth(initialSecret: String) async throws -> String? {
logger.debug("Attempting to poll for quick connect auth")
let connectPath = Paths.connect(secret: initialSecret)
do {
let response = try await client.send(connectPath)
guard response.value.isAuthenticated ?? false else {
return nil
}
guard let authSecret = response.value.secret else {
logger.debug("Quick connect response was authorized but secret missing")
throw QuickConnectError.pollingFailed
}
return authSecret
} catch {
throw QuickConnectError.pollingFailed
}
}
private func stopQuickConnectAuthCheck() {
logger.debug("Stopping quick connect")
state = .initial
quickConnectPollTask?.cancel()
}
}

View File

@ -0,0 +1,86 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Combine
import Foundation
import JellyfinAPI
final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful {
// MARK: Event
enum Event {
case error(JellyfinAPIError)
case success
}
// MARK: Action
enum Action: Equatable {
case cancel
case reset(current: String, new: String)
}
// MARK: State
enum State: Hashable {
case initial
case resetting
}
@Published
var state: State = .initial
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var resetTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init()
func respond(to action: Action) -> State {
switch action {
case .cancel:
resetTask?.cancel()
return .initial
case let .reset(current, new):
resetTask = Task {
do {
// try await Task.sleep(nanoseconds: 5_000_000_000)
try await reset(current: current, new: new)
await MainActor.run {
self.eventSubject.send(.success)
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 .resetting
}
}
private func reset(current: String, new: String) async throws {
let body = UpdateUserPassword(currentPw: current, newPw: new)
let request = Paths.updateUserPassword(userID: userSession.user.id, body)
try await userSession.client.send(request)
}
}

View File

@ -0,0 +1,114 @@
//
// 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 CoreStore
import Factory
import Foundation
import JellyfinAPI
import KeychainSwift
import OrderedCollections
class SelectUserViewModel: ViewModel, Eventful, Stateful {
// MARK: Event
enum Event {
case error(JellyfinAPIError)
case signedIn(UserState)
}
// MARK: Action
enum Action: Equatable {
case deleteUsers([UserState])
case getServers
case signIn(UserState, pin: String)
}
// MARK: State
enum State: Hashable {
case content
}
@Published
var servers: OrderedDictionary<ServerState, [UserState]> = [:]
@Published
var state: State = .content
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var eventSubject: PassthroughSubject<Event, Never> = .init()
@MainActor
func respond(to action: Action) -> State {
switch action {
case let .deleteUsers(users):
do {
for user in users {
try delete(user: user)
}
send(.getServers)
} catch {
eventSubject.send(.error(.init(error.localizedDescription)))
}
case .getServers:
do {
servers = try getServers()
.zipped(map: getUsers)
.reduce(into: OrderedDictionary<ServerState, [UserState]>()) { partialResult, pair in
partialResult[pair.0] = pair.1
}
return .content
} catch {
eventSubject.send(.error(.init(error.localizedDescription)))
}
case let .signIn(user, pin):
if user.signInPolicy == .requirePin, let storedPin = keychain.get("\(user.id)-pin") {
if pin != storedPin {
eventSubject.send(.error(.init("Incorrect pin for \(user.username)")))
return .content
}
}
eventSubject.send(.signedIn(user))
}
return .content
}
private func getServers() throws -> [ServerState] {
try SwiftfinStore
.dataStack
.fetchAll(From<ServerModel>())
.map(\.state)
.sorted(using: \.name)
}
private func getUsers(for server: ServerState) throws -> [UserState] {
guard let storedServer = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
throw JellyfinAPIError("Unable to find server for users")
}
return storedServer.users
.map(\.state)
}
private func delete(user: UserState) throws {
try user.delete()
}
}

View File

@ -0,0 +1,57 @@
//
// 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
class ServerCheckViewModel: ViewModel, Stateful {
enum Action: Equatable {
case checkServer
}
enum State: Hashable {
case connecting
case connected
case error(JellyfinAPIError)
case initial
}
@Published
var state: State = .initial
private var connectCancellable: AnyCancellable?
func respond(to action: Action) -> State {
switch action {
case .checkServer:
connectCancellable?.cancel()
// TODO: also server stuff
connectCancellable = Task {
do {
let request = Paths.getCurrentUser
let response = try await userSession.client.send(request)
await MainActor.run {
userSession.user.data = response.value
self.state = .connected
}
} catch {
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}
}
.asAnyCancellable()
return .connecting
}
}
}

View File

@ -10,7 +10,7 @@ import CoreStore
import Foundation import Foundation
import JellyfinAPI import JellyfinAPI
class ServerDetailViewModel: ViewModel { class EditServerViewModel: ViewModel {
@Published @Published
var server: ServerState var server: ServerState
@ -19,36 +19,59 @@ class ServerDetailViewModel: ViewModel {
self.server = server self.server = server
} }
func setCurrentServerURL(to url: URL) { // TODO: this could probably be cleaner
func delete() {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( guard let storedServer = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
From<ServerModel>(), logger.critical("Unable to find server to delete")
[Where<ServerModel>("id == %@", server.id)]
) else {
logger.error("Unable to find server")
return return
} }
guard storedServer.urls.contains(url) else { let userStates = storedServer.users.map(\.state)
logger.error("Server did not have matching URL")
return
}
let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let editServer = transaction.edit(storedServer) else {
logger.error("Unable to create edit server instance")
return
}
editServer.currentURL = url
// Note: don't use Server/UserState.delete() to have
// all deletions in a single transaction
do { do {
try transaction.commitAndWait() try dataStack.perform { transaction in
Notifications[.didChangeCurrentServerURL].post(object: editServer.state) /// Delete stored data for all users
for user in storedServer.users {
let storedDataClause = AnyStoredData.fetchClause(ownerID: user.id)
let storedData = try transaction.fetchAll(storedDataClause)
transaction.delete(storedData)
}
transaction.delete(storedServer.users)
transaction.delete(storedServer)
}
for user in userStates {
UserDefaults.userSuite(id: user.id).removeAll()
}
Notifications[.didDeleteServer].post(object: server)
} catch { } catch {
logger.error("Unable to edit server") logger.critical("Unable to delete server: \(server.name)")
}
}
func setCurrentURL(to url: URL) {
do {
let newState = try dataStack.perform { transaction in
guard let storedServer = try transaction.fetchOne(From<ServerModel>().where(\.$id == self.server.id)) else {
throw JellyfinAPIError("Unable to find server for URL change: \(self.server.name)")
}
storedServer.currentURL = url
return storedServer.state
}
Notifications[.didChangeCurrentServerURL].post(object: newState)
self.server = newState
} catch {
logger.critical("\(error.localizedDescription)")
} }
} }
} }

View File

@ -1,82 +0,0 @@
//
// 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 SwiftUI
final class ServerListViewModel: ViewModel {
@Published
var servers: [SwiftfinStore.State.Server] = []
override init() {
super.init()
// Oct. 15, 2021
// This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing.
// Feature request issue: https://github.com/rundfunk47/stinsen/issues/33
// Go to each MainCoordinator and implement the rebuild of the root when receiving the notification
Notifications[.didPurge].subscribe(self, selector: #selector(didPurge))
}
func fetchServers() {
let servers = try! SwiftfinStore.dataStack.fetchAll(From<SwiftfinStore.Models.StoredServer>())
self.servers = servers.map(\.state)
}
func userTextFor(server: SwiftfinStore.State.Server) -> String {
if server.userIDs.count == 1 {
return L10n.oneUser
} else {
return L10n.multipleUsers(server.userIDs.count)
}
}
func remove(server: SwiftfinStore.State.Server) {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)]
)
else { fatalError("No stored server for state server?") }
try! SwiftfinStore.dataStack.perform { transaction in
transaction.delete(storedServer.users)
transaction.delete(storedServer)
}
fetchServers()
}
@objc
private func didPurge() {
fetchServers()
}
func purge() {
try? SwiftfinStore.dataStack.perform { transaction in
let users = try! transaction.fetchAll(From<UserModel>())
transaction.delete(users)
let servers = try! transaction.fetchAll(From<ServerModel>())
for server in servers {
transaction.delete(server.users)
}
transaction.delete(servers)
}
fetchServers()
UserDefaults.generalSuite.removeAll()
UserDefaults.universalSuite.removeAll()
}
}

View File

@ -17,6 +17,8 @@ final class SettingsViewModel: ViewModel {
@Published @Published
var currentAppIcon: any AppIcon = PrimaryAppIcon.primary var currentAppIcon: any AppIcon = PrimaryAppIcon.primary
@Published
var servers: [ServerState] = []
override init() { override init() {
@ -28,35 +30,31 @@ final class SettingsViewModel: ViewModel {
if let appicon = PrimaryAppIcon.createCase(iconName: iconName) { if let appicon = PrimaryAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon currentAppIcon = appicon
super.init()
return
} }
if let appicon = DarkAppIcon.createCase(iconName: iconName) { if let appicon = DarkAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon currentAppIcon = appicon
super.init()
return
} }
if let appicon = InvertedDarkAppIcon.createCase(iconName: iconName) { if let appicon = InvertedDarkAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon currentAppIcon = appicon
super.init()
return
} }
if let appicon = InvertedLightAppIcon.createCase(iconName: iconName) { if let appicon = InvertedLightAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon currentAppIcon = appicon
super.init()
return
} }
if let appicon = LightAppIcon.createCase(iconName: iconName) { if let appicon = LightAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon currentAppIcon = appicon
super.init()
return
} }
super.init() super.init()
do {
servers = try getServers()
} catch {
logger.critical("Could not retrieve servers")
}
} }
func select(icon: any AppIcon) { func select(icon: any AppIcon) {
@ -78,21 +76,17 @@ final class SettingsViewModel: ViewModel {
} }
} }
private func getServers() throws -> [ServerState] {
try SwiftfinStore
.dataStack
.fetchAll(From<ServerModel>())
.map(\.state)
.sorted(using: \.name)
}
func signOut() { func signOut() {
Defaults[.lastServerUserID] = nil Defaults[.lastSignedInUserID] = nil
Container.userSession.reset() UserSession.current.reset()
Notifications[.didSignOut].post() Notifications[.didSignOut].post()
} }
func resetUserSettings() {
UserDefaults.generalSuite.removeAll()
}
func removeAllServers() {
guard let allServers = try? SwiftfinStore.dataStack.fetchAll(From<ServerModel>()) else { return }
try? SwiftfinStore.dataStack.perform { transaction in
transaction.delete(allServers)
}
}
} }

View File

@ -1,84 +0,0 @@
//
// 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 Defaults
import Factory
import Foundation
import JellyfinAPI
import Pulse
import SwiftUI
class UserListViewModel: ViewModel {
@Published
private(set) var users: [UserState] = []
@Published
private(set) var server: ServerState
var client: JellyfinClient {
JellyfinClient(
configuration: .swiftfinConfiguration(url: server.currentURL),
sessionDelegate: URLSessionProxyDelegate()
)
}
init(server: ServerState) {
self.server = server
super.init()
Notifications[.didChangeCurrentServerURL]
.publisher
.sink { [weak self] notification in
guard let serverState = notification.object as? SwiftfinStore.State.Server else {
return
}
self?.server = serverState
}
.store(in: &cancellables)
}
func fetchUsers() {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
From<ServerModel>(),
Where<ServerModel>("id == %@", server.id)
)
else { fatalError("No stored server associated with given state server?") }
users = storedServer.users
.map(\.state)
.sorted(using: \.username)
}
func signIn(user: UserState) {
Defaults[.lastServerUserID] = user.id
Container.userSession.reset()
Notifications[.didSignIn].post()
}
func remove(user: UserState) {
guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", user.id)]
) else {
logger.error("Unable to find user to delete")
return
}
let transaction = SwiftfinStore.dataStack.beginUnsafe()
transaction.delete(storedUser)
do {
try transaction.commitAndWait()
fetchUsers()
} catch {
logger.error("Unable to delete user")
}
}
}

View File

@ -0,0 +1,81 @@
//
// 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 KeychainSwift
class UserLocalSecurityViewModel: ViewModel, Eventful {
enum Event: Hashable {
case error(JellyfinAPIError)
case promptForOldDeviceAuth
case promptForOldPin
case promptForNewDeviceAuth
case promptForNewPin
}
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var eventSubject: PassthroughSubject<Event, Never> = .init()
// Will throw and send event if needing to prompt for old auth.
func checkForOldPolicy() throws {
let oldPolicy = userSession.user.signInPolicy
switch oldPolicy {
case .requireDeviceAuthentication:
eventSubject.send(.promptForOldDeviceAuth)
throw JellyfinAPIError("Prompt for old device auth")
case .requirePin:
eventSubject.send(.promptForOldPin)
throw JellyfinAPIError("Prompt for old pin")
case .none: ()
}
}
// Will throw and send event if needing to prompt for new auth.
func checkFor(newPolicy: UserAccessPolicy) throws {
switch newPolicy {
case .requireDeviceAuthentication:
eventSubject.send(.promptForNewDeviceAuth)
case .requirePin:
eventSubject.send(.promptForNewPin)
case .none: ()
}
}
func check(oldPin: String) throws {
if let storedPin = keychain.get("\(userSession.user.id)-pin") {
if oldPin != storedPin {
eventSubject.send(.error(.init("Incorrect pin for \(userSession.user.username)")))
throw JellyfinAPIError("invalid pin")
}
}
}
func set(newPolicy: UserAccessPolicy, newPin: String, newPinHint: String) {
if newPolicy == .requirePin {
keychain.set(newPin, forKey: "\(userSession.user.id)-pin")
} else {
keychain.delete(StoredValues[.Temp.userLocalPin])
}
userSession.user.signInPolicy = newPolicy
userSession.user.pinHint = newPinHint
}
}

View File

@ -6,21 +6,48 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors // Copyright (c) 2024 Jellyfin & Jellyfin Contributors
// //
import Combine
import CoreStore import CoreStore
import Defaults
import Factory import Factory
import Foundation import Foundation
import Get
import JellyfinAPI import JellyfinAPI
import Pulse import KeychainSwift
import OrderedCollections
import SwiftUI
final class UserSignInViewModel: ViewModel, Stateful { // TODO: instead of just signing in duplicate user, send event for alert
// to override existing user access token?
// - won't require deleting and re-signing in user for password changes
// - account for local device auth required
// TODO: ignore NSURLErrorDomain Code=-999 cancelled error on sign in
// - need to make NSError wrappres anyways
// Note: UserDto in StoredValues so that it doesn't need to be passed
// around along with the user UserState. Was just easy
final class UserSignInViewModel: ViewModel, Eventful, Stateful {
// MARK: Event
enum Event {
case duplicateUser(UserState)
case error(JellyfinAPIError)
case signedIn(UserState)
}
// MARK: Action // MARK: Action
enum Action: Equatable { enum Action: Equatable {
case signInWithUserPass(username: String, password: String) case getPublicData
case signInWithQuickConnect(authSecret: String) case signIn(username: String, password: String, policy: UserAccessPolicy)
case cancelSignIn case signInDuplicate(UserState, replace: Bool)
case signInQuickConnect(secret: String, policy: UserAccessPolicy)
case cancel
}
enum BackgroundState: Hashable {
case gettingPublicData
} }
// MARK: State // MARK: State
@ -28,180 +55,299 @@ final class UserSignInViewModel: ViewModel, Stateful {
enum State: Hashable { enum State: Hashable {
case initial case initial
case signingIn case signingIn
case signedIn
case error(SignInError)
}
// TODO: Add more detailed errors
enum SignInError: Error {
case unknown
} }
@Published
var backgroundStates: OrderedSet<BackgroundState> = []
@Published
var isQuickConnectEnabled = false
@Published
var publicUsers: [UserDto] = []
@Published
var serverDisclaimer: String? = nil
@Published @Published
var state: State = .initial var state: State = .initial
var lastAction: Action? = nil
@Published var events: AnyPublisher<Event, Never> {
private(set) var publicUsers: [UserDto] = [] eventSubject
@Published .receive(on: RunLoop.main)
private(set) var quickConnectEnabled = false .eraseToAnyPublisher()
}
private var signInTask: Task<Void, Never>? let quickConnect: QuickConnect
let server: ServerState
let quickConnectViewModel: QuickConnectViewModel private var eventSubject: PassthroughSubject<Event, Never> = .init()
private var signInTask: AnyCancellable?
let client: JellyfinClient
let server: SwiftfinStore.State.Server
init(server: ServerState) { init(server: ServerState) {
self.client = JellyfinClient(
configuration: .swiftfinConfiguration(url: server.currentURL),
sessionDelegate: URLSessionProxyDelegate()
)
self.server = server self.server = server
self.quickConnectViewModel = .init(client: client) self.quickConnect = QuickConnect(client: server.client)
super.init() super.init()
quickConnect.$state
.sink { [weak self] state in
if case let QuickConnect.State.authenticated(secret: secret) = state {
guard let self else { return }
Task {
await self.send(.signInQuickConnect(secret: secret, policy: StoredValues[.Temp.userSignInPolicy]))
}
}
}
.store(in: &cancellables)
} }
func respond(to action: Action) -> State { func respond(to action: Action) -> State {
switch action { switch action {
case let .signInWithUserPass(username, password): case .getPublicData:
guard state != .signingIn else { return .signingIn } Task { [weak self] in
Task {
do { do {
try await signIn(username: username, password: password)
await MainActor.run {
let _ = self?.backgroundStates.append(.gettingPublicData)
}
let isQuickConnectEnabled = try await self?.retrieveQuickConnectEnabled()
let publicUsers = try await self?.retrievePublicUsers()
let serverMessage = try await self?.retrieveServerDisclaimer()
guard let self else { return }
await MainActor.run {
self.backgroundStates.remove(.gettingPublicData)
self.isQuickConnectEnabled = isQuickConnectEnabled ?? false
self.publicUsers = publicUsers ?? []
self.serverDisclaimer = serverMessage
}
} catch {
self?.backgroundStates.remove(.gettingPublicData)
}
}
.store(in: &cancellables)
return state
case let .signIn(username, password, policy):
signInTask?.cancel()
signInTask = Task {
do {
let user = try await signIn(username: username, password: password, policy: policy)
if isDuplicate(user: user) {
await MainActor.run {
// user has same id, but new access token
self.eventSubject.send(.duplicateUser(user))
}
} else {
try await save(user: user)
await MainActor.run {
self.eventSubject.send(.signedIn(user))
}
}
await MainActor.run {
self.state = .initial
}
} catch is CancellationError {
// cancel doesn't matter
} catch { } catch {
await MainActor.run { await MainActor.run {
state = .error(.unknown) self.eventSubject.send(.error(.init(error.localizedDescription)))
self.state = .initial
} }
} }
} }
.asAnyCancellable()
return .signingIn return .signingIn
case let .signInWithQuickConnect(authSecret): case let .signInDuplicate(duplicateUser, replace):
guard state != .signingIn else { return .signingIn } if replace {
Task { setNewAccessToken(user: duplicateUser)
} else {
// just need the id, even though this has a different
// access token than stored
eventSubject.send(.signedIn(duplicateUser))
}
return state
case let .signInQuickConnect(secret, policy):
signInTask?.cancel()
signInTask = Task {
do { do {
try await signIn(quickConnectSecret: authSecret) let user = try await signIn(secret: secret, policy: policy)
if isDuplicate(user: user) {
await MainActor.run {
// user has same id, but new access token
self.eventSubject.send(.duplicateUser(user))
}
} else {
try await save(user: user)
await MainActor.run {
self.eventSubject.send(.signedIn(user))
}
}
await MainActor.run {
self.state = .initial
}
} catch is CancellationError {
// cancel doesn't matter
} catch { } catch {
await MainActor.run { await MainActor.run {
state = .error(.unknown) self.eventSubject.send(.error(.init(error.localizedDescription)))
self.state = .initial
} }
} }
} }
.asAnyCancellable()
return .signingIn return .signingIn
case .cancelSignIn: case .cancel:
self.signInTask?.cancel() signInTask?.cancel()
return .initial return .initial
} }
} }
private func signIn(username: String, password: String) async throws { private func signIn(username: String, password: String, policy: UserAccessPolicy) async throws -> UserState {
let username = username.trimmingCharacters(in: .whitespacesAndNewlines) let username = username
.trimmingCharacters(in: .objectReplacement) .trimmingCharacters(in: .whitespacesAndNewlines)
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .objectReplacement) .trimmingCharacters(in: .objectReplacement)
let response = try await client.signIn(username: username, password: password) let password = password
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .objectReplacement)
let user: UserState let response = try await server.client.signIn(username: username, password: password)
do { guard let accessToken = response.accessToken,
user = try await createLocalUser(response: response) let userData = response.user,
} catch { let id = userData.id,
if case let SwiftfinStore.Error.existingUser(existingUser) = error { let username = userData.name
user = existingUser else {
} else { logger.critical("Missing user data from network call")
throw error throw JellyfinAPIError("An internal error has occurred")
}
} }
Defaults[.lastServerUserID] = user.id StoredValues[.Temp.userData] = userData
Container.userSession.reset() StoredValues[.Temp.userSignInPolicy] = policy
Notifications[.didSignIn].post()
let newState = UserState(
id: id,
serverID: server.id,
username: username
)
newState.accessToken = accessToken
return newState
} }
private func signIn(quickConnectSecret: String) async throws { private func signIn(secret: String, policy: UserAccessPolicy) async throws -> UserState {
let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret))
let response = try await client.send(quickConnectPath)
let user: UserState let response = try await server.client.signIn(quickConnectSecret: secret)
do { guard let accessToken = response.accessToken,
user = try await createLocalUser(response: response.value) let userData = response.user,
} catch { let id = userData.id,
if case let SwiftfinStore.Error.existingUser(existingUser) = error { let username = userData.name
user = existingUser else {
} else { logger.critical("Missing user data from network call")
throw error throw JellyfinAPIError("An internal error has occurred")
}
} }
Defaults[.lastServerUserID] = user.id StoredValues[.Temp.userData] = userData
Container.userSession.reset() StoredValues[.Temp.userSignInPolicy] = policy
Notifications[.didSignIn].post()
let newState = UserState(
id: id,
serverID: server.id,
username: username
)
newState.accessToken = accessToken
return newState
} }
func getPublicUsers() async throws { private func isDuplicate(user: UserState) -> Bool {
let publicUsersPath = Paths.getPublicUsers let existingUser = try? SwiftfinStore
let response = try await client.send(publicUsersPath) .dataStack
.fetchOne(From<UserModel>().where(\.$id == user.id))
await MainActor.run { return existingUser != nil
publicUsers = response.value
}
} }
@MainActor @MainActor
private func createLocalUser(response: AuthenticationResult) async throws -> UserState { private func save(user: UserState) async throws {
guard let accessToken = response.accessToken,
let username = response.user?.name,
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
if let existingUser = try? SwiftfinStore.dataStack.fetchOne( guard let serverModel = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
From<UserModel>(), logger.critical("Unable to find server to save user")
[Where<UserModel>( throw JellyfinAPIError("An internal error has occurred")
"id == %@",
id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
} }
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( let user = try dataStack.perform { transaction in
From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
),
]
)
else { fatalError("No stored server associated with given state server?") }
let user = try SwiftfinStore.dataStack.perform { transaction in
let newUser = transaction.create(Into<UserModel>()) let newUser = transaction.create(Into<UserModel>())
newUser.accessToken = accessToken newUser.id = user.id
newUser.appleTVID = "" newUser.username = user.username
newUser.id = id
newUser.username = username
let editServer = transaction.edit(storedServer)! let editServer = transaction.edit(serverModel)!
editServer.users.insert(newUser) editServer.users.insert(newUser)
return newUser.state return newUser.state
} }
return user user.data = StoredValues[.Temp.userData]
user.signInPolicy = StoredValues[.Temp.userSignInPolicy]
keychain.set(StoredValues[.Temp.userLocalPin], forKey: "\(user.id)-pin")
user.pinHint = StoredValues[.Temp.userLocalPinHint]
// TODO: remove when implemented periodic cleanup elsewhere
StoredValues[.Temp.userSignInPolicy] = .none
StoredValues[.Temp.userLocalPin] = ""
StoredValues[.Temp.userLocalPinHint] = ""
} }
func checkQuickConnect() async throws { private func retrievePublicUsers() async throws -> [UserDto] {
let quickConnectEnabledPath = Paths.getEnabled let request = Paths.getPublicUsers
let response = try await client.send(quickConnectEnabledPath) let response = try await server.client.send(request)
let decoder = JSONDecoder()
let isEnabled = try? decoder.decode(Bool.self, from: response.value)
await MainActor.run { return response.value
quickConnectEnabled = isEnabled ?? false }
private func retrieveServerDisclaimer() async throws -> String? {
let request = Paths.getBrandingOptions
let response = try await server.client.send(request)
guard let disclaimer = response.value.loginDisclaimer, disclaimer.isNotEmpty else { return nil }
return disclaimer
}
private func retrieveQuickConnectEnabled() async throws -> Bool {
let request = Paths.getEnabled
let response = try await server.client.send(request)
let isEnabled = try? JSONDecoder().decode(Bool.self, from: response.value)
return isEnabled ?? false
}
// server has same id, but new access token
private func setNewAccessToken(user: UserState) {
do {
guard let existingUser = try dataStack.fetchOne(From<UserModel>().where(\.$id == user.id)) else { return }
existingUser.state.accessToken = user.accessToken
eventSubject.send(.signedIn(existingUser.state))
} catch {
logger.critical("\(error.localizedDescription)")
} }
} }
} }

View File

@ -30,8 +30,6 @@ class VideoPlayerViewModel: ViewModel {
var hlsPlaybackURL: URL { var hlsPlaybackURL: URL {
let userSession = Container.userSession()
let parameters = Paths.GetMasterHlsVideoPlaylistParameters( let parameters = Paths.GetMasterHlsVideoPlaylistParameters(
isStatic: true, isStatic: true,
tag: mediaSource.eTag, tag: mediaSource.eTag,

Some files were not shown because too many files have changed in this diff Show More