User/Server Sign In Redesign (#1045)
This commit is contained in:
parent
e9baf2dd4f
commit
74b8b286c7
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
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 {
|
||||
stack = NavigationStack(initial: \MainCoordinator.serverList)
|
||||
await MainActor.run {
|
||||
withAnimation(.linear(duration: 0.1)) {
|
||||
let _ = root(\.selectUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
|
||||
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
logger.critical("\(error.localizedDescription)")
|
||||
Notifications[.didFailMigration].post()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
UIScrollView.appearance().keyboardDismissMode = .onDrag
|
||||
// TODO: move these to the App instead?
|
||||
|
||||
ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB
|
||||
|
||||
// Notification setup for state
|
||||
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
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 {
|
||||
stack = NavigationStack(initial: \MainCoordinator.serverList)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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() }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -12,7 +12,7 @@ import UIKit
|
|||
|
||||
extension BaseItemPerson: Displayable {
|
||||
var displayTitle: String {
|
||||
self.name ?? .emptyDash
|
||||
name ?? .emptyDash
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
mutating func toggle(value: Element) {
|
||||
if contains(value) {
|
||||
remove(value)
|
||||
} else {
|
||||
insert(value)
|
||||
}
|
||||
|
||||
init(message: String, code: Int? = nil) {
|
||||
self.code = code
|
||||
self.message = message
|
||||
}
|
||||
}
|
|
@ -118,5 +118,6 @@ extension String {
|
|||
|
||||
extension CharacterSet {
|
||||
|
||||
// Character that appears on tvOS with voice input
|
||||
static var objectReplacement: CharacterSet = .init(charactersIn: "\u{fffc}")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
|||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Useful modifier during development
|
||||
func debugBackground(_ color: Color = Color.red, opacity: CGFloat = 0.5) -> some View {
|
||||
background {
|
||||
color
|
||||
.opacity(opacity)
|
||||
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
|
||||
func debugBackground<S: ShapeStyle>(_ fill: S = .red.opacity(0.5)) -> some View {
|
||||
background {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
private var discoveredServersPublisher = PassthroughSubject<ServerResponse, Never>()
|
||||
|
||||
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 {
|
||||
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
|
||||
logger.debug("Received JellyfinServer from \"\(response.name)\"")
|
||||
completion(response)
|
||||
let response = try JSONDecoder().decode(ServerResponse.self, from: data)
|
||||
discoveredServersPublisher.send(response)
|
||||
|
||||
logger.debug("Found local server: \"\(response.name)\" at: \(response.url.absoluteString)")
|
||||
} catch {
|
||||
completion(nil)
|
||||
logger.debug("Unable to decode local server response from: \(ipAddress):\(port)")
|
||||
}
|
||||
}
|
||||
|
||||
func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
|
||||
logger.error("Error handling response: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
do {
|
||||
self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler)
|
||||
try self.connection?.sendBroadcast("Who is JellyfinServer?")
|
||||
logger.debug("Discovery broadcast sent")
|
||||
} catch {
|
||||
logger.error("Error sending discovery broadcast")
|
||||
}
|
||||
private func handleError(_ error: UDPBroadcastConnection.ConnectionError) {
|
||||
logger.debug("Error handling response: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
}()
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
],
|
||||
]
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
]
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
||||
var _discoveredServers: Set<SwiftfinStore.State.Server> = []
|
||||
|
||||
discovery.locateServer { server in
|
||||
if let server = server {
|
||||
_discoveredServers.insert(.init(
|
||||
urls: [],
|
||||
currentURL: server.url,
|
||||
name: server.name,
|
||||
id: server.id,
|
||||
os: "",
|
||||
version: "",
|
||||
usersIDs: []
|
||||
))
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Timeout after 3 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
self.isSearching = false
|
||||
self.discoveredServers = _discoveredServers.sorted(by: { $0.name < $1.name })
|
||||
}
|
||||
editServer.urls.insert(server.currentURL)
|
||||
editServer.currentURL = server.currentURL
|
||||
|
||||
return editServer.state
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue