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")
.resizable()
.frame(width: size, height: size)
.paletteOverlayRendering(color: .white)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, Color.jellyfinPurple)
.padding(3)
}
}

View File

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

View File

@ -8,8 +8,39 @@
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 {
@State
@ -18,17 +49,15 @@ struct SystemImageContentView: View {
private var labelSize: CGSize = .zero
private var backgroundColor: Color
private var heightRatio: CGFloat
private var ratio: CGFloat
private let systemName: 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.heightRatio = 3
self.ratio = ratio
self.systemName = systemName ?? "circle"
self.title = title
self.widthRatio = 3.5
}
private var imageView: some View {
@ -36,8 +65,7 @@ struct SystemImageContentView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.secondary)
.accessibilityHidden(true)
.frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio)
.frame(width: contentSize.width * ratio, height: contentSize.height * ratio)
}
@ViewBuilder
@ -47,7 +75,7 @@ struct SystemImageContentView: View {
.lineLimit(2)
.multilineTextAlignment(.center)
.font(.footnote.weight(.regular))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.trackingSize($labelSize)
}
}
@ -55,7 +83,6 @@ struct SystemImageContentView: View {
var body: some View {
ZStack {
backgroundColor
.opacity(0.5)
imageView
.frame(width: contentSize.width)
@ -71,12 +98,7 @@ struct SystemImageContentView: View {
extension SystemImageContentView {
func background(color: Color = Color.secondarySystemFill) -> Self {
func background(color: Color) -> Self {
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
// 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 {
@ViewBuilder

View File

@ -10,9 +10,9 @@ import PulseUI
import Stinsen
import SwiftUI
final class BasicAppSettingsCoordinator: NavigationCoordinatable {
final class AppSettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start)
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
@Root
var start = makeStart
@ -31,20 +31,16 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
var log = makeLog
#endif
private let viewModel: SettingsViewModel
init() {
viewModel = .init()
}
init() {}
#if os(iOS)
@ViewBuilder
func makeAbout() -> some View {
func makeAbout(viewModel: SettingsViewModel) -> some View {
AboutAppView(viewModel: viewModel)
}
@ViewBuilder
func makeAppIconSelector() -> some View {
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
AppIconSelectorView(viewModel: viewModel)
}
#endif
@ -56,6 +52,6 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
@ViewBuilder
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
func makeStart() -> some View {
#if os(tvOS)
Text(verbatim: .emptyDash)
AssertionFailureView("Not implemented")
#else
FilterView(viewModel: parameters.viewModel, type: parameters.type)
#endif

View File

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

View File

@ -57,7 +57,13 @@ final class ItemCoordinator: NavigationCoordinatable {
}
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)
}

View File

@ -14,7 +14,10 @@ import JellyfinAPI
import Nuke
import Stinsen
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 {
@ -23,30 +26,55 @@ final class MainCoordinator: NavigationCoordinatable {
var stack: Stinsen.NavigationStack<MainCoordinator>
@Root
var loading = makeLoading
@Root
var mainTab = makeMainTab
@Root
var serverList = makeServerList
@Route(.fullScreen)
var videoPlayer = makeVideoPlayer
var selectUser = makeSelectUser
@Root
var serverCheck = makeServerCheck
@Route(.fullScreen)
var liveVideoPlayer = makeLiveVideoPlayer
private var cancellables = Set<AnyCancellable>()
@Route(.modal)
var settings = makeSettings
@Route(.fullScreen)
var videoPlayer = makeVideoPlayer
init() {
if Container.userSession().authenticated {
stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else {
stack = NavigationStack(initial: \MainCoordinator.serverList)
stack = NavigationStack(initial: \.loading)
Task {
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
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
// TODO: move these to the App instead?
WidgetCenter.shared.reloadAllTimelines()
UIScrollView.appearance().keyboardDismissMode = .onDrag
ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB
// Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
@ -55,16 +83,24 @@ final class MainCoordinator: NavigationCoordinatable {
Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:)))
}
private func didFinishMigration() {}
@objc
func didSignIn() {
logger.info("Signed in")
root(\.mainTab)
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.serverCheck)
}
}
@objc
func didSignOut() {
logger.info("Signed out")
root(\.serverList)
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.selectUser)
}
}
@objc
@ -84,18 +120,34 @@ final class MainCoordinator: NavigationCoordinatable {
@objc
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()
}
func makeLoading() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
AppLoadingView()
}
}
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
NavigationViewCoordinator(SettingsCoordinator())
}
func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator()
}
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
NavigationViewCoordinator(ServerListCoordinator())
func makeSelectUser() -> NavigationViewCoordinator<SelectUserCoordinator> {
NavigationViewCoordinator(SelectUserCoordinator())
}
func makeServerCheck() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerCheckView()
}
}
func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator {

View File

@ -12,6 +12,10 @@ import Nuke
import Stinsen
import SwiftUI
// TODO: clean up like iOS
// - move some things to App
// TODO: server check flow
final class MainCoordinator: NavigationCoordinatable {
@Injected(LogManager.service)
@ -19,21 +23,44 @@ final class MainCoordinator: NavigationCoordinatable {
var stack: Stinsen.NavigationStack<MainCoordinator>
@Root
var loading = makeLoading
@Root
var mainTab = makeMainTab
@Root
var serverList = makeServerList
var selectUser = makeSelectUser
init() {
if Container.userSession().authenticated {
stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else {
stack = NavigationStack(initial: \MainCoordinator.serverList)
stack = NavigationStack(initial: \.loading)
Task {
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
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label]
@ -44,21 +71,33 @@ final class MainCoordinator: NavigationCoordinatable {
@objc
func didSignIn() {
logger.info("Received `didSignIn` from NSNotificationCenter.")
root(\.mainTab)
logger.info("Signed in")
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.mainTab)
}
}
@objc
func didSignOut() {
logger.info("Received `didSignOut` from NSNotificationCenter.")
root(\.serverList)
logger.info("Signed out")
withAnimation(.linear(duration: 0.1)) {
let _ = root(\.selectUser)
}
}
func makeLoading() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
AppLoadingView()
}
}
func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator()
}
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
NavigationViewCoordinator(ServerListCoordinator())
func makeSelectUser() -> NavigationViewCoordinator<SelectUserCoordinator> {
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)
@Route(.push)
var about = makeAbout
@Route(.push)
var appIconSelector = makeAppIconSelector
@Route(.push)
var log = makeLog
@Route(.push)
var nativePlayerSettings = makeNativePlayerSettings
@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)
var customizeViewsSettings = makeCustomizeViewsSettings
@ -63,31 +65,30 @@ final class SettingsCoordinator: NavigationCoordinatable {
var videoPlayerSettings = makeVideoPlayerSettings
#endif
private let viewModel: SettingsViewModel
init() {
viewModel = .init()
}
#if os(iOS)
@ViewBuilder
func makeAbout() -> some View {
AboutAppView(viewModel: viewModel)
}
@ViewBuilder
func makeAppIconSelector() -> some View {
AppIconSelectorView(viewModel: viewModel)
}
@ViewBuilder
func makeNativePlayerSettings() -> some View {
NativeVideoPlayerSettingsView()
}
@ViewBuilder
func makeQuickConnectSettings() -> some View {
QuickConnectSettingsView(viewModel: .init())
func makeQuickConnectAuthorize() -> some View {
QuickConnectAuthorizeView()
}
@ViewBuilder
func makeResetUserPassword() -> some View {
ResetUserPasswordView()
}
@ViewBuilder
func makeLocalSecurity() -> some View {
UserLocalSecurityView()
}
@ViewBuilder
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
UserProfileSettingsView(viewModel: viewModel)
}
@ViewBuilder
@ -107,7 +108,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
@ViewBuilder
func makeServerDetail(server: ServerState) -> some View {
ServerDetailView(server: server)
EditServerView(server: server)
}
#if DEBUG
@ -145,19 +146,15 @@ final class SettingsCoordinator: NavigationCoordinatable {
}
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(
BasicNavigationViewCoordinator {
IndicatorSettingsView()
}
)
NavigationViewCoordinator {
IndicatorSettingsView()
}
}
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(
BasicNavigationViewCoordinator {
ServerDetailView(server: server)
}
)
NavigationViewCoordinator {
EditServerView(server: server)
}
}
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
@ -172,6 +169,6 @@ final class SettingsCoordinator: NavigationCoordinatable {
@ViewBuilder
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 JellyfinAPI
import Stinsen
import SwiftUI
final class UserSignInCoordinator: NavigationCoordinatable {
struct SecurityParameters {
let pinHint: Binding<String>
let signInPolicy: Binding<UserAccessPolicy>
}
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
@Root
var start = makeStart
#if os(iOS)
@Route(.modal)
var quickConnect = makeQuickConnect
#if os(iOS)
@Route(.modal)
var security = makeSecurity
#endif
let viewModel: UserSignInViewModel
private let server: ServerState
init(viewModel: UserSignInViewModel) {
self.viewModel = viewModel
init(server: ServerState) {
self.server = server
}
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
QuickConnectView(quickConnect: quickConnect)
}
}
#if os(iOS)
func makeQuickConnect() -> NavigationViewCoordinator<QuickConnectCoordinator> {
NavigationViewCoordinator(QuickConnectCoordinator(viewModel: viewModel))
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
UserSignInView.SecurityView(
pinHint: parameters.pinHint,
signInPolicy: parameters.signInPolicy
)
}
}
#endif
@ViewBuilder
func makeStart() -> some View {
UserSignInView(viewModel: viewModel)
UserSignInView(server: server)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,11 @@ import SwiftUI
extension EnvironmentValues {
var accentColor: Binding<Color> {
get { self[AccentColor.self] }
set { self[AccentColor.self] = newValue }
}
var audioOffset: Binding<Int> {
get { self[AudioOffsetKey.self] }
set { self[AudioOffsetKey.self] = newValue }
@ -25,6 +30,11 @@ extension EnvironmentValues {
set { self[CurrentOverlayTypeKey.self] = newValue }
}
var isEditing: Bool {
get { self[IsEditingKey.self] }
set { self[IsEditingKey.self] = newValue }
}
var isPresentingOverlay: Binding<Bool> {
get { self[IsPresentingOverlayKey.self] }
set { self[IsPresentingOverlayKey.self] = newValue }
@ -35,6 +45,11 @@ extension EnvironmentValues {
set { self[IsScrubbingKey.self] = newValue }
}
var isSelected: Bool {
get { self[IsSelectedKey.self] }
set { self[IsSelectedKey.self] = newValue }
}
var playbackSpeed: Binding<Double> {
get { self[PlaybackSpeedKey.self] }
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
// TODO: remove and just use overlay + offset
extension HorizontalAlignment {
struct VideoPlayerTitleAlignment: AlignmentID {
@ -17,12 +18,4 @@ extension HorizontalAlignment {
}
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
}
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(
maxWidth: scaleWidth,
maxHeight: scaleHeight,

View File

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

View File

@ -85,20 +85,6 @@ extension BaseItemDto {
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? {
guard let startDate, let endDate else { return nil }
return endDate.timeIntervalSince(startDate)
@ -174,7 +160,10 @@ extension BaseItemDto {
}
var hasRatings: Bool {
[criticRating, communityRating].oneSatisfies { $0 != nil }
[
criticRating,
communityRating,
].contains { $0 != nil }
}
// MARK: Chapter Images
@ -204,8 +193,8 @@ extension BaseItemDto {
parameters: parameters
)
let imageURL = Container
.userSession()
let imageURL = UserSession
.current()!
.client
.fullURL(with: request)

View File

@ -23,11 +23,12 @@ extension BaseItemPerson: Poster {
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
// - maxWidth assume already scaled?
let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!)
let client = Container.userSession().client
let imageRequestParameters = Paths.GetItemImageParameters(
maxWidth: scaleWidth ?? Int(maxWidth),
tag: primaryImageTag

View File

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

View File

@ -9,6 +9,7 @@
import Foundation
import Get
import JellyfinAPI
import UIKit
extension JellyfinClient {
@ -26,8 +27,30 @@ extension JellyfinClient {
/// Appends the path to the current configuration `URL`, assuming that the path begins with a leading `/`.
/// Returns `nil` if the new `URL` is malformed.
func fullURL(with path: String) -> URL? {
guard let fullPath = URL(string: configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path)
else { return nil }
return fullPath
let fullPath = configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path
return URL(string: 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 {
let userSession = Container.userSession()
let userSession: UserSession! = UserSession.current()
let playbackURL: URL
let streamType: StreamType
@ -67,7 +67,8 @@ extension MediaSourceInfo {
}
func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
let userSession = Container.userSession.callAsFunction()
let userSession: UserSession! = UserSession.current()
let playbackURL: URL
let streamType: StreamType

View File

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

View File

@ -6,26 +6,25 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Factory
import Foundation
import Get
import JellyfinAPI
import UIKit
extension UserDto {
func profileImageSource(client: JellyfinClient, maxWidth: CGFloat, maxHeight: CGFloat) -> ImageSource {
let scaleWidth = UIScreen.main.scale(maxWidth)
let scaleHeight = UIScreen.main.scale(maxHeight)
let request = Paths.getUserImage(
userID: id ?? "",
imageType: "Primary",
parameters: .init(maxWidth: scaleWidth, maxHeight: scaleHeight)
func profileImageSource(
client: JellyfinClient,
maxWidth: CGFloat? = nil,
maxHeight: CGFloat? = nil
) -> ImageSource {
UserState(
id: id ?? "",
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 SwiftUI
extension NavigationCoordinatable {
func inNavigationViewCoordinator() -> NavigationViewCoordinator<Self> {
NavigationViewCoordinator(self)
}
}
extension NavigationViewCoordinator<BasicNavigationViewCoordinator> {
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] {
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 {

View File

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

View File

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

View File

@ -49,11 +49,15 @@ extension UIDevice {
}
static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) {
#if os(iOS)
UINotificationFeedbackGenerator().notificationOccurred(type)
#endif
}
static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) {
#if os(iOS)
UIImpactFeedbackGenerator(style: type).impactOccurred()
#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 {
/// 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
func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View {
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)
// TODO: - remove comment when migrated away from Stinsen
@ -62,3 +83,16 @@ extension Backport where Content: View {
}
#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 {
let notification: NSNotification.Name
let onReceive: () -> Void
let onReceive: (Notification) -> Void
func body(content: Content) -> some View {
content
.onReceive(NotificationCenter.default.publisher(for: notification)) { _ in
onReceive()
.onReceive(NotificationCenter.default.publisher(for: notification)) {
onReceive($0)
}
}
}

View File

@ -21,3 +21,23 @@ struct OnSizeChangedModifier<Wrapped: View>: ViewModifier {
.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 {
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
}
}

View File

@ -196,8 +196,6 @@ extension View {
}
}
// TODO: have width/height tracked binding
func onSizeChanged(perform action: @escaping (CGSize) -> Void) -> some View {
onSizeChanged { size, _ in
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
func navigationBarHidden() -> some View {
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(
OnReceiveNotificationModifier(
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
// Useful modifier during development
func debugBackground(_ color: Color = Color.red, opacity: CGFloat = 0.5) -> some View {
func debugBackground<S: ShapeStyle>(_ fill: S = .red.opacity(0.5)) -> some View {
background {
color
.opacity(opacity)
Rectangle()
.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
}

View File

@ -99,32 +99,35 @@ extension CaseIterablePicker {
// 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?>) {
self.init(
selection: selection,
label: { Label($0.displayTitle, systemImage: $0.systemImage) },
title: title,
hasNone: true,
noneStyle: .text
)
}
init(title: String, selection: Binding<Element>) {
let binding = Binding<Element?> {
selection.wrappedValue
} 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) },
title: title,
hasNone: false,
noneStyle: .text
)
}
}
// extension CaseIterablePicker where Element: SystemImageable {
//
// init(title: String, selection: Binding<Element?>) {
// self.init(
// selection: selection,
// label: { Label($0.displayTitle, systemImage: $0.systemImage) },
// title: title,
// hasNone: true,
// noneStyle: .text
// )
// }
//
// init(title: String, selection: Binding<Element>) {
// let binding = Binding<Element?> {
// selection.wrappedValue
// } 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) },
// title: title,
// hasNone: false,
// noneStyle: .text
// )
// }
// }

View File

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

View File

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

View File

@ -13,6 +13,11 @@ import JellyfinAPI
struct TitledLibraryParent: LibraryParent {
let displayTitle: String
let id: String? = nil
let id: String?
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 SwiftUI
enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable {
enum PosterDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable {
case landscape
case portrait
@ -23,4 +23,13 @@ enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable
"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
// TODO: official way for a cleaner `respond` method so it doesn't have all Task
// construction and get bloated
// TODO: make Action: Hashable just for consistency
protocol Stateful: AnyObject {
@ -43,6 +44,11 @@ protocol Stateful: AnyObject {
extension Stateful {
var lastAction: Action? {
get { nil }
set {}
}
@MainActor
func send(_ action: 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
//
import Combine
import Factory
import Foundation
import UDPBroadcast
@ -15,69 +16,45 @@ class ServerDiscovery {
@Injected(LogManager.service)
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?
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) {
do {
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
logger.debug("Received JellyfinServer from \"\(response.name)\"")
completion(response)
} catch {
completion(nil)
}
}
private var discoveredServersPublisher = PassthroughSubject<ServerResponse, Never>()
func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
logger.error("Error handling response: \(error.localizedDescription)")
}
func broadcast() {
try? connection?.sendBroadcast("Who is JellyfinServer?")
}
func close() {
connection?.closeConnection()
discoveredServersPublisher.send(completion: .finished)
}
private func handleServerResponse(_ ipAddress: String, _ port: Int, data: Data) {
do {
self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler)
try self.connection?.sendBroadcast("Who is JellyfinServer?")
logger.debug("Discovery broadcast sent")
let response = try JSONDecoder().decode(ServerResponse.self, from: data)
discoveredServersPublisher.send(response)
logger.debug("Found local server: \"\(response.name)\" at: \(response.url.absoluteString)")
} 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)
private var logger
@Injected(Container.userSession)
private var userSession
@Injected(UserSession.current)
private var userSession: UserSession!
@Published
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 Factory
import Foundation
import SwiftUI
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 {
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 {
// Universal settings
static let accentColor: Key<Color> = .init("accentColor", default: .jellyfinPurple, suite: .universalSuite)
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: .universalSuite)
static let hapticFeedback: Key<Bool> = .init("hapticFeedback", default: true, suite: .universalSuite)
static let lastServerUserID = Defaults.Key<String?>("lastServerUserID", suite: .universalSuite)
/// The _real_ accent color key to be used.
///
/// This is set externally whenever the app or user accent colors change,
/// depending on the current app state.
static var accentColor: Key<Color> = AppKey("accentColor", default: .jellyfinPurple)
// TODO: Replace with a cache
static let libraryFilterStore = Key<[String: ItemFilterCollection]>("libraryFilterStore", default: [:], suite: .generalSuite)
/// The _real_ appearance key to be used.
///
/// 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 {
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 nextUpPosterType = Key<PosterDisplayType>("nextUpPosterType", default: .portrait, suite: .generalSuite)
static let recentlyAddedPosterType = Key<PosterDisplayType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
static let latestInLibraryPosterType = Key<PosterDisplayType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: .generalSuite)
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: .generalSuite)
static let similarPosterType = Key<PosterDisplayType>("similarPosterType", default: .portrait, suite: .generalSuite)
static let showPosterLabels: Key<Bool> = UserKey("showPosterLabels", default: true)
static let nextUpPosterType: Key<PosterDisplayType> = UserKey("nextUpPosterType", default: .portrait)
static let recentlyAddedPosterType: Key<PosterDisplayType> = UserKey("recentlyAddedPosterType", default: .portrait)
static let latestInLibraryPosterType: Key<PosterDisplayType> = UserKey("latestInLibraryPosterType", default: .portrait)
static let shouldShowMissingSeasons: Key<Bool> = UserKey("shouldShowMissingSeasons", default: true)
static let shouldShowMissingEpisodes: Key<Bool> = UserKey("shouldShowMissingEpisodes", default: true)
static let similarPosterType: Key<PosterDisplayType> = UserKey("similarPosterType", default: .portrait)
// 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 {
static let usePrimaryImage: Key<Bool> = .init("cinematicItemViewTypeUsePrimaryImage", default: false, suite: .generalSuite)
static let usePrimaryImage: Key<Bool> = UserKey("cinematicItemViewTypeUsePrimaryImage", default: false)
}
enum Episodes {
static let useSeriesLandscapeBackdrop = Key<Bool>("useSeriesBackdrop", default: true, suite: .generalSuite)
static let useSeriesLandscapeBackdrop: Key<Bool> = UserKey("useSeriesBackdrop", default: true)
}
enum Indicators {
static let showFavorited: Key<Bool> = .init("showFavoritedIndicator", default: true, suite: .generalSuite)
static let showProgress: Key<Bool> = .init("showProgressIndicator", default: true, suite: .generalSuite)
static let showUnplayed: Key<Bool> = .init("showUnplayedIndicator", default: true, suite: .generalSuite)
static let showPlayed: Key<Bool> = .init("showPlayedIndicator", default: true, suite: .generalSuite)
static let showFavorited: Key<Bool> = UserKey("showFavoritedIndicator", default: true)
static let showProgress: Key<Bool> = UserKey("showProgressIndicator", default: true)
static let showUnplayed: Key<Bool> = UserKey("showUnplayedIndicator", default: true)
static let showPlayed: Key<Bool> = UserKey("showPlayedIndicator", default: true)
}
enum Library {
static let cinematicBackground: Key<Bool> = .init(
"Customization.Library.cinematicBackground",
default: true,
suite: .generalSuite
)
static let enabledDrawerFilters: Key<[ItemFilterType]> = .init(
static let cinematicBackground: Key<Bool> = UserKey("Customization.Library.cinematicBackground", default: true)
static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey(
"Library.enabledDrawerFilters",
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
default: ItemFilterType.allCases
)
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 {
static let enabledDrawerFilters: Key<[ItemFilterType]> = .init(
static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey(
"Search.enabledDrawerFilters",
default: ItemFilterType.allCases,
suite: .generalSuite
default: ItemFilterType.allCases
)
}
}
enum VideoPlayer {
static let autoPlayEnabled: Key<Bool> = .init("autoPlayEnabled", default: true, suite: .generalSuite)
static let barActionButtons: Key<[VideoPlayerActionButton]> = .init(
static let autoPlayEnabled: Key<Bool> = UserKey("autoPlayEnabled", default: true)
static let barActionButtons: Key<[VideoPlayerActionButton]> = UserKey(
"barActionButtons",
default: VideoPlayerActionButton.defaultBarActionButtons,
suite: .generalSuite
default: VideoPlayerActionButton.defaultBarActionButtons
)
static let jumpBackwardLength: Key<VideoPlayerJumpLength> = .init(
"jumpBackwardLength",
default: .fifteen,
suite: .generalSuite
)
static let jumpForwardLength: Key<VideoPlayerJumpLength> = .init(
"jumpForwardLength",
default: .fifteen,
suite: .generalSuite
)
static let menuActionButtons: Key<[VideoPlayerActionButton]> = .init(
static let jumpBackwardLength: Key<VideoPlayerJumpLength> = UserKey("jumpBackwardLength", default: .fifteen)
static let jumpForwardLength: Key<VideoPlayerJumpLength> = UserKey("jumpForwardLength", default: .fifteen)
static let menuActionButtons: Key<[VideoPlayerActionButton]> = UserKey(
"menuActionButtons",
default: VideoPlayerActionButton.defaultMenuActionButtons,
suite: .generalSuite
default: VideoPlayerActionButton.defaultMenuActionButtons
)
static let resumeOffset: Key<Int> = .init("resumeOffset", default: 0, suite: .generalSuite)
static let showJumpButtons: Key<Bool> = .init("showJumpButtons", default: true, suite: .generalSuite)
static let videoPlayerType: Key<VideoPlayerType> = .init("videoPlayerType", default: .swiftfin, suite: .generalSuite)
static let resumeOffset: Key<Int> = UserKey("resumeOffset", default: 0)
static let showJumpButtons: Key<Bool> = UserKey("showJumpButtons", default: true)
static let videoPlayerType: Key<VideoPlayerType> = UserKey("videoPlayerType", default: .swiftfin)
enum Gesture {
static let horizontalPanGesture: Key<PanAction> = .init(
"videoPlayerHorizontalPanGesture",
default: .none,
suite: .generalSuite
)
static let horizontalSwipeGesture: Key<SwipeAction> = .init(
"videoPlayerHorizontalSwipeGesture",
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
)
static let horizontalPanGesture: Key<PanAction> = UserKey("videoPlayerHorizontalPanGesture", default: .none)
static let horizontalSwipeGesture: Key<SwipeAction> = UserKey("videoPlayerHorizontalSwipeGesture", default: .none)
static let longPressGesture: Key<LongPressAction> = UserKey("videoPlayerLongPressGesture", default: .gestureLock)
static let multiTapGesture: Key<MultiTapAction> = UserKey("videoPlayerMultiTapGesture", default: .none)
static let doubleTouchGesture: Key<DoubleTouchAction> = UserKey("videoPlayerDoubleTouchGesture", default: .none)
static let pinchGesture: Key<PinchAction> = UserKey("videoPlayerSwipeGesture", default: .aspectFill)
static let verticalPanGestureLeft: Key<PanAction> = UserKey("videoPlayerVerticalPanGestureLeft", default: .none)
static let verticalPanGestureRight: Key<PanAction> = UserKey("videoPlayerVerticalPanGestureRight", default: .none)
}
enum Overlay {
static let chapterSlider: Key<Bool> = .init("chapterSlider", default: true, suite: .generalSuite)
static let playbackButtonType: Key<PlaybackButtonType> = .init(
"videoPlayerPlaybackButtonLocation",
default: .large,
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)
static let chapterSlider: Key<Bool> = UserKey("chapterSlider", default: true)
static let playbackButtonType: Key<PlaybackButtonType> = UserKey("videoPlayerPlaybackButtonLocation", default: .large)
static let sliderColor: Key<Color> = UserKey("sliderColor", default: Color.white)
static let sliderType: Key<SliderType> = UserKey("sliderType", default: .capsule)
// Timestamp
static let trailingTimestampType: Key<TrailingTimestampType> = .init(
"trailingTimestamp",
default: .timeLeft,
suite: .generalSuite
)
static let showCurrentTimeWhileScrubbing: Key<Bool> = .init(
"showCurrentTimeWhileScrubbing",
default: true,
suite: .generalSuite
)
static let timestampType: Key<TimestampType> = .init("timestampType", default: .split, suite: .generalSuite)
static let trailingTimestampType: Key<TrailingTimestampType> = UserKey("trailingTimestamp", default: .timeLeft)
static let showCurrentTimeWhileScrubbing: Key<Bool> = UserKey("showCurrentTimeWhileScrubbing", default: true)
static let timestampType: Key<TimestampType> = UserKey("timestampType", default: .split)
}
enum Subtitle {
static let subtitleColor: Key<Color> = .init(
"subtitleColor",
default: .white,
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)
static let subtitleColor: Key<Color> = UserKey("subtitleColor", default: .white)
static let subtitleFontName: Key<String> = UserKey("subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName)
static let subtitleSize: Key<Int> = UserKey("subtitleSize", default: 16)
}
enum Transition {
static let pauseOnBackground: Key<Bool> = .init("pauseOnBackground", default: false, suite: .generalSuite)
static let playOnActive: Key<Bool> = .init("playOnActive", default: false, suite: .generalSuite)
static let pauseOnBackground: Key<Bool> = UserKey("pauseOnBackground", default: false)
static let playOnActive: Key<Bool> = UserKey("playOnActive", default: false)
}
}
// Experimental settings
enum Experimental {
static let downloads: Key<Bool> = .init("experimentalDownloads", default: false, suite: .generalSuite)
static let syncSubtitleStateWithAdjacent = Key<Bool>(
"experimentalSyncSubtitleState",
default: false,
suite: .generalSuite
)
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: .generalSuite)
static let liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: .generalSuite)
static let downloads: Key<Bool> = UserKey("experimentalDownloads", default: false)
static let forceDirectPlay: Key<Bool> = UserKey("forceDirectPlay", default: false)
static let liveTVForceDirectPlay: Key<Bool> = UserKey("liveTVForceDirectPlay", default: false)
}
// tvos specific
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: .generalSuite)
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: .generalSuite)
static let downActionShowsMenu: Key<Bool> = UserKey("downActionShowsMenu", default: true)
static let confirmClose: Key<Bool> = UserKey("confirmClose", default: false)
}
// MARK: Debug
@ -250,6 +243,10 @@ extension UserDefaults {
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

View File

@ -14,7 +14,7 @@ class SwiftfinNotification {
@Injected(Notifications.service)
private var notificationService
private let name: Notification.Name
let name: Notification.Name
fileprivate init(_ notificationName: Notification.Name) {
self.name = notificationName
@ -39,7 +39,7 @@ class SwiftfinNotification {
enum Notifications {
static let service = Factory(scope: .singleton) { NotificationCenter() }
static let service = Factory(scope: .singleton) { NotificationCenter.default }
struct Key: Hashable {
@ -76,6 +76,10 @@ extension Notifications.Key {
static let didChangeCurrentServerURL = NotificationKey("didChangeCurrentServerURL")
static let didSendStopReport = NotificationKey("didSendStopReport")
static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
static let didFailMigration = NotificationKey("didFailMigration")
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
//
import Combine
import CoreStore
import CryptoKit
import Defaults
import Factory
import Foundation
import Get
import JellyfinAPI
import OrderedCollections
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
private(set) var discoveredServers: [ServerState] = []
var backgroundStates: OrderedSet<BackgroundState> = []
// no longer-found servers are not cleared, but not an issue
@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 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)
// shhhh
// 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
Task { [weak self] in
guard let self else { return }
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)
.trimmingCharacters(in: .objectReplacement)
@ -54,37 +146,35 @@ final class ConnectToServerViewModel: ViewModel {
let client = JellyfinClient(
configuration: .swiftfinConfiguration(url: url),
sessionDelegate: URLSessionProxyDelegate()
sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger())
)
let response = try await client.send(Paths.getPublicSystemInfo)
guard let name = response.value.serverName,
let id = response.value.id,
let os = response.value.operatingSystem,
let version = response.value.version
let id = response.value.id
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, response: response.response.url)
let connectionURL = processConnectionURL(
initial: url,
response: response.response.url
)
let newServerState = ServerState(
urls: [connectionURL],
currentURL: connectionURL,
name: name,
id: id,
os: os,
version: version,
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 {
guard let response else { return url }
@ -105,72 +195,48 @@ final class ConnectToServerViewModel: ViewModel {
return url
}
func isDuplicate(server: ServerState) -> Bool {
if let _ = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
)]
) {
return true
}
return false
private func isDuplicate(server: ServerState) -> Bool {
let existingServer = try? SwiftfinStore
.dataStack
.fetchOne(From<ServerModel>().where(\.$id == server.id))
return existingServer != nil
}
func save(server: ServerState) throws {
try SwiftfinStore.dataStack.perform { transaction in
let newServer = transaction.create(Into<SwiftfinStore.Models.StoredServer>())
private func save(server: ServerState) async throws {
try dataStack.perform { transaction in
let newServer = transaction.create(Into<ServerModel>())
newServer.urls = server.urls
newServer.currentURL = server.currentURL
newServer.name = server.name
newServer.id = server.id
newServer.os = server.os
newServer.version = server.version
newServer.users = []
}
let publicInfo = try await server.getPublicSystemInfo()
StoredValues[.Server.publicInfo(id: server.id)] = publicInfo
}
func discoverServers() {
isSearching = true
discoveredServers.removeAll()
// server has same id, but (possible) new URL
private func addNewURL(server: ServerState) {
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
if let server = server {
_discoveredServers.insert(.init(
urls: [],
currentURL: server.url,
name: server.name,
id: server.id,
os: "",
version: "",
usersIDs: []
))
return editServer.state
}
}
// Timeout after 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.isSearching = false
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)
Notifications[.didChangeCurrentServerURL].post(object: newState)
} catch {
logger.critical("\(error.localizedDescription)")
}
}
}

View File

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

View File

@ -13,7 +13,7 @@ import JellyfinAPI
final class NextUpLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
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] {

View File

@ -7,6 +7,7 @@
//
import Combine
import Defaults
import Foundation
import Get
import JellyfinAPI
@ -23,11 +24,19 @@ private let DefaultPageSize = 50
// on refresh. Should make bidirectional/offset index start?
// - use startIndex/index ranges instead of pages
// - 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 {
// MARK: Event
enum Event: Equatable {
enum Event {
case gotRandomItem(Element)
}
@ -103,9 +112,16 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
convenience init(
title: String,
id: String?,
_ data: some Collection<Element>
) {
self.init(data, parent: TitledLibraryParent(displayTitle: title))
self.init(
data,
parent: TitledLibraryParent(
displayTitle: title,
id: id
)
)
}
// paging
@ -120,6 +136,19 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
self.parent = parent
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(
parent: parent,
currentFilters: filters
@ -148,11 +177,15 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
convenience init(
title: String,
filters: ItemFilterCollection = .default,
id: String?,
filters: ItemFilterCollection? = nil,
pageSize: Int = DefaultPageSize
) {
self.init(
parent: TitledLibraryParent(displayTitle: title),
parent: TitledLibraryParent(
displayTitle: title,
id: id
),
filters: filters,
pageSize: pageSize
)

View File

@ -17,10 +17,11 @@ final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
// Necessary because this is paginated and also used on home view
init(customPageSize: Int? = nil) {
// Why doesn't `super.init(title:id:pageSize)` init work?
if let customPageSize {
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded), pageSize: customPageSize)
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded"), pageSize: customPageSize)
} 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 JellyfinAPI
class ServerDetailViewModel: ViewModel {
class EditServerViewModel: ViewModel {
@Published
var server: ServerState
@ -19,36 +19,59 @@ class ServerDetailViewModel: ViewModel {
self.server = server
}
func setCurrentServerURL(to url: URL) {
// TODO: this could probably be cleaner
func delete() {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
From<ServerModel>(),
[Where<ServerModel>("id == %@", server.id)]
) else {
logger.error("Unable to find server")
guard let storedServer = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
logger.critical("Unable to find server to delete")
return
}
guard storedServer.urls.contains(url) else {
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
let userStates = storedServer.users.map(\.state)
// Note: don't use Server/UserState.delete() to have
// all deletions in a single transaction
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 {
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
var currentAppIcon: any AppIcon = PrimaryAppIcon.primary
@Published
var servers: [ServerState] = []
override init() {
@ -28,35 +30,31 @@ final class SettingsViewModel: ViewModel {
if let appicon = PrimaryAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon
super.init()
return
}
if let appicon = DarkAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon
super.init()
return
}
if let appicon = InvertedDarkAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon
super.init()
return
}
if let appicon = InvertedLightAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon
super.init()
return
}
if let appicon = LightAppIcon.createCase(iconName: iconName) {
currentAppIcon = appicon
super.init()
return
}
super.init()
do {
servers = try getServers()
} catch {
logger.critical("Could not retrieve servers")
}
}
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() {
Defaults[.lastServerUserID] = nil
Container.userSession.reset()
Defaults[.lastSignedInUserID] = nil
UserSession.current.reset()
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
//
import Combine
import CoreStore
import Defaults
import Factory
import Foundation
import Get
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
enum Action: Equatable {
case signInWithUserPass(username: String, password: String)
case signInWithQuickConnect(authSecret: String)
case cancelSignIn
case getPublicData
case signIn(username: String, password: String, policy: UserAccessPolicy)
case signInDuplicate(UserState, replace: Bool)
case signInQuickConnect(secret: String, policy: UserAccessPolicy)
case cancel
}
enum BackgroundState: Hashable {
case gettingPublicData
}
// MARK: State
@ -28,180 +55,299 @@ final class UserSignInViewModel: ViewModel, Stateful {
enum State: Hashable {
case initial
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
var state: State = .initial
var lastAction: Action? = nil
@Published
private(set) var publicUsers: [UserDto] = []
@Published
private(set) var quickConnectEnabled = false
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var signInTask: Task<Void, Never>?
let quickConnect: QuickConnect
let server: ServerState
let quickConnectViewModel: QuickConnectViewModel
let client: JellyfinClient
let server: SwiftfinStore.State.Server
private var eventSubject: PassthroughSubject<Event, Never> = .init()
private var signInTask: AnyCancellable?
init(server: ServerState) {
self.client = JellyfinClient(
configuration: .swiftfinConfiguration(url: server.currentURL),
sessionDelegate: URLSessionProxyDelegate()
)
self.server = server
self.quickConnectViewModel = .init(client: client)
self.quickConnect = QuickConnect(client: server.client)
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 {
switch action {
case let .signInWithUserPass(username, password):
guard state != .signingIn else { return .signingIn }
Task {
case .getPublicData:
Task { [weak self] in
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 {
await MainActor.run {
state = .error(.unknown)
self.eventSubject.send(.error(.init(error.localizedDescription)))
self.state = .initial
}
}
}
.asAnyCancellable()
return .signingIn
case let .signInWithQuickConnect(authSecret):
guard state != .signingIn else { return .signingIn }
Task {
case let .signInDuplicate(duplicateUser, replace):
if replace {
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 {
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 {
await MainActor.run {
state = .error(.unknown)
self.eventSubject.send(.error(.init(error.localizedDescription)))
self.state = .initial
}
}
}
.asAnyCancellable()
return .signingIn
case .cancelSignIn:
self.signInTask?.cancel()
case .cancel:
signInTask?.cancel()
return .initial
}
}
private func signIn(username: String, password: String) async throws {
let username = username.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .objectReplacement)
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
private func signIn(username: String, password: String, policy: UserAccessPolicy) async throws -> UserState {
let username = username
.trimmingCharacters(in: .whitespacesAndNewlines)
.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 {
user = try await createLocalUser(response: response)
} catch {
if case let SwiftfinStore.Error.existingUser(existingUser) = error {
user = existingUser
} else {
throw error
}
guard let accessToken = response.accessToken,
let userData = response.user,
let id = userData.id,
let username = userData.name
else {
logger.critical("Missing user data from network call")
throw JellyfinAPIError("An internal error has occurred")
}
Defaults[.lastServerUserID] = user.id
Container.userSession.reset()
Notifications[.didSignIn].post()
StoredValues[.Temp.userData] = userData
StoredValues[.Temp.userSignInPolicy] = policy
let newState = UserState(
id: id,
serverID: server.id,
username: username
)
newState.accessToken = accessToken
return newState
}
private func signIn(quickConnectSecret: String) async throws {
let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret))
let response = try await client.send(quickConnectPath)
private func signIn(secret: String, policy: UserAccessPolicy) async throws -> UserState {
let user: UserState
let response = try await server.client.signIn(quickConnectSecret: secret)
do {
user = try await createLocalUser(response: response.value)
} catch {
if case let SwiftfinStore.Error.existingUser(existingUser) = error {
user = existingUser
} else {
throw error
}
guard let accessToken = response.accessToken,
let userData = response.user,
let id = userData.id,
let username = userData.name
else {
logger.critical("Missing user data from network call")
throw JellyfinAPIError("An internal error has occurred")
}
Defaults[.lastServerUserID] = user.id
Container.userSession.reset()
Notifications[.didSignIn].post()
StoredValues[.Temp.userData] = userData
StoredValues[.Temp.userSignInPolicy] = policy
let newState = UserState(
id: id,
serverID: server.id,
username: username
)
newState.accessToken = accessToken
return newState
}
func getPublicUsers() async throws {
let publicUsersPath = Paths.getPublicUsers
let response = try await client.send(publicUsersPath)
await MainActor.run {
publicUsers = response.value
}
private func isDuplicate(user: UserState) -> Bool {
let existingUser = try? SwiftfinStore
.dataStack
.fetchOne(From<UserModel>().where(\.$id == user.id))
return existingUser != nil
}
@MainActor
private func createLocalUser(response: AuthenticationResult) async throws -> UserState {
guard let accessToken = response.accessToken,
let username = response.user?.name,
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
private func save(user: UserState) async throws {
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
From<UserModel>(),
[Where<UserModel>(
"id == %@",
id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
guard let serverModel = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
logger.critical("Unable to find server to save user")
throw JellyfinAPIError("An internal error has occurred")
}
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
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 user = try dataStack.perform { transaction in
let newUser = transaction.create(Into<UserModel>())
newUser.accessToken = accessToken
newUser.appleTVID = ""
newUser.id = id
newUser.username = username
newUser.id = user.id
newUser.username = user.username
let editServer = transaction.edit(storedServer)!
let editServer = transaction.edit(serverModel)!
editServer.users.insert(newUser)
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 {
let quickConnectEnabledPath = Paths.getEnabled
let response = try await client.send(quickConnectEnabledPath)
let decoder = JSONDecoder()
let isEnabled = try? decoder.decode(Bool.self, from: response.value)
private func retrievePublicUsers() async throws -> [UserDto] {
let request = Paths.getPublicUsers
let response = try await server.client.send(request)
await MainActor.run {
quickConnectEnabled = isEnabled ?? false
return response.value
}
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 {
let userSession = Container.userSession()
let parameters = Paths.GetMasterHlsVideoPlaylistParameters(
isStatic: true,
tag: mediaSource.eTag,

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