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")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.paletteOverlayRendering(color: .white)
|
.symbolRenderingMode(.palette)
|
||||||
|
.foregroundStyle(.white, Color.jellyfinPurple)
|
||||||
.padding(3)
|
.padding(3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,9 +47,12 @@ struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
|
||||||
if selection.contains(element) {
|
if selection.contains(element) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.backport
|
||||||
.frame(width: 20, height: 20)
|
.fontWeight(.bold)
|
||||||
.paletteOverlayRendering()
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.symbolRenderingMode(.palette)
|
||||||
|
.foregroundStyle(accentColor.overlayColor, accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,39 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// TODO: is the background color setting really the best way?
|
// TODO: bottom view can probably just be cleaned up and change
|
||||||
|
// usages to use local background views
|
||||||
|
|
||||||
|
struct RelativeSystemImageView: View {
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var contentSize: CGSize = .zero
|
||||||
|
|
||||||
|
private let systemName: String
|
||||||
|
private let ratio: CGFloat
|
||||||
|
|
||||||
|
init(
|
||||||
|
systemName: String,
|
||||||
|
ratio: CGFloat = 0.5
|
||||||
|
) {
|
||||||
|
self.systemName = systemName
|
||||||
|
self.ratio = ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
AlternateLayoutView {
|
||||||
|
Color.clear
|
||||||
|
.trackingSize($contentSize)
|
||||||
|
} content: {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: contentSize.width * ratio, height: contentSize.height * ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: cleanup and become the failure view for poster buttons
|
||||||
struct SystemImageContentView: View {
|
struct SystemImageContentView: View {
|
||||||
|
|
||||||
@State
|
@State
|
||||||
|
@ -18,17 +49,15 @@ struct SystemImageContentView: View {
|
||||||
private var labelSize: CGSize = .zero
|
private var labelSize: CGSize = .zero
|
||||||
|
|
||||||
private var backgroundColor: Color
|
private var backgroundColor: Color
|
||||||
private var heightRatio: CGFloat
|
private var ratio: CGFloat
|
||||||
private let systemName: String
|
private let systemName: String
|
||||||
private let title: String?
|
private let title: String?
|
||||||
private var widthRatio: CGFloat
|
|
||||||
|
|
||||||
init(title: String? = nil, systemName: String?) {
|
init(title: String? = nil, systemName: String?, ratio: CGFloat = 0.3) {
|
||||||
self.backgroundColor = Color.secondarySystemFill
|
self.backgroundColor = Color.secondarySystemFill
|
||||||
self.heightRatio = 3
|
self.ratio = ratio
|
||||||
self.systemName = systemName ?? "circle"
|
self.systemName = systemName ?? "circle"
|
||||||
self.title = title
|
self.title = title
|
||||||
self.widthRatio = 3.5
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var imageView: some View {
|
private var imageView: some View {
|
||||||
|
@ -36,8 +65,7 @@ struct SystemImageContentView: View {
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.accessibilityHidden(true)
|
.frame(width: contentSize.width * ratio, height: contentSize.height * ratio)
|
||||||
.frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -47,7 +75,7 @@ struct SystemImageContentView: View {
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.font(.footnote.weight(.regular))
|
.font(.footnote.weight(.regular))
|
||||||
.foregroundColor(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.trackingSize($labelSize)
|
.trackingSize($labelSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,7 +83,6 @@ struct SystemImageContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
backgroundColor
|
backgroundColor
|
||||||
.opacity(0.5)
|
|
||||||
|
|
||||||
imageView
|
imageView
|
||||||
.frame(width: contentSize.width)
|
.frame(width: contentSize.width)
|
||||||
|
@ -71,12 +98,7 @@ struct SystemImageContentView: View {
|
||||||
|
|
||||||
extension SystemImageContentView {
|
extension SystemImageContentView {
|
||||||
|
|
||||||
func background(color: Color = Color.secondarySystemFill) -> Self {
|
func background(color: Color) -> Self {
|
||||||
copy(modifying: \.backgroundColor, with: color)
|
copy(modifying: \.backgroundColor, with: color)
|
||||||
}
|
}
|
||||||
|
|
||||||
func imageFrameRatio(width: CGFloat = 3.5, height: CGFloat = 3) -> Self {
|
|
||||||
copy(modifying: \.heightRatio, with: height)
|
|
||||||
.copy(modifying: \.widthRatio, with: width)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,10 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: mainly used as a view to hold views for states
|
||||||
|
// but doesn't work with animations/transitions.
|
||||||
|
// Look at alternative with just ZStack and remove
|
||||||
|
|
||||||
struct WrappedView<Content: View>: View {
|
struct WrappedView<Content: View>: View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
|
@ -10,9 +10,9 @@ import PulseUI
|
||||||
import Stinsen
|
import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class BasicAppSettingsCoordinator: NavigationCoordinatable {
|
final class AppSettingsCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start)
|
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
|
||||||
|
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
|
@ -31,20 +31,16 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
|
||||||
var log = makeLog
|
var log = makeLog
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let viewModel: SettingsViewModel
|
init() {}
|
||||||
|
|
||||||
init() {
|
|
||||||
viewModel = .init()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeAbout() -> some View {
|
func makeAbout(viewModel: SettingsViewModel) -> some View {
|
||||||
AboutAppView(viewModel: viewModel)
|
AboutAppView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeAppIconSelector() -> some View {
|
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
|
||||||
AppIconSelectorView(viewModel: viewModel)
|
AppIconSelectorView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
@ -56,6 +52,6 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
BasicAppSettingsView(viewModel: viewModel)
|
AppSettingsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
Text(verbatim: .emptyDash)
|
AssertionFailureView("Not implemented")
|
||||||
#else
|
#else
|
||||||
FilterView(viewModel: parameters.viewModel, type: parameters.type)
|
FilterView(viewModel: parameters.viewModel, type: parameters.type)
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -17,8 +17,6 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
@Route(.modal)
|
|
||||||
var settings = makeSettings
|
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@Route(.modal)
|
@Route(.modal)
|
||||||
|
@ -32,10 +30,6 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(SettingsCoordinator())
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
|
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
|
||||||
NavigationViewCoordinator(ItemCoordinator(item: item))
|
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||||
|
|
|
@ -57,7 +57,13 @@ final class ItemCoordinator: NavigationCoordinatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator<BaseItemPerson> {
|
func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator<BaseItemPerson> {
|
||||||
let viewModel = PagingLibraryViewModel(title: L10n.castAndCrew, people)
|
let id: String? = itemDto.id == nil ? nil : "castAndCrew-\(itemDto.id!)"
|
||||||
|
|
||||||
|
let viewModel = PagingLibraryViewModel(
|
||||||
|
title: L10n.castAndCrew,
|
||||||
|
id: id,
|
||||||
|
people
|
||||||
|
)
|
||||||
return LibraryCoordinator(viewModel: viewModel)
|
return LibraryCoordinator(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,10 @@ import JellyfinAPI
|
||||||
import Nuke
|
import Nuke
|
||||||
import Stinsen
|
import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
|
||||||
|
// TODO: could possibly clean up
|
||||||
|
// - only go to loading if migrations necessary
|
||||||
|
// - account for other migrations (Defaults)
|
||||||
|
|
||||||
final class MainCoordinator: NavigationCoordinatable {
|
final class MainCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
|
@ -23,30 +26,55 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
var stack: Stinsen.NavigationStack<MainCoordinator>
|
var stack: Stinsen.NavigationStack<MainCoordinator>
|
||||||
|
|
||||||
|
@Root
|
||||||
|
var loading = makeLoading
|
||||||
@Root
|
@Root
|
||||||
var mainTab = makeMainTab
|
var mainTab = makeMainTab
|
||||||
@Root
|
@Root
|
||||||
var serverList = makeServerList
|
var selectUser = makeSelectUser
|
||||||
@Route(.fullScreen)
|
@Root
|
||||||
var videoPlayer = makeVideoPlayer
|
var serverCheck = makeServerCheck
|
||||||
|
|
||||||
@Route(.fullScreen)
|
@Route(.fullScreen)
|
||||||
var liveVideoPlayer = makeLiveVideoPlayer
|
var liveVideoPlayer = makeLiveVideoPlayer
|
||||||
|
@Route(.modal)
|
||||||
private var cancellables = Set<AnyCancellable>()
|
var settings = makeSettings
|
||||||
|
@Route(.fullScreen)
|
||||||
|
var videoPlayer = makeVideoPlayer
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
||||||
if Container.userSession().authenticated {
|
stack = NavigationStack(initial: \.loading)
|
||||||
stack = NavigationStack(initial: \MainCoordinator.mainTab)
|
|
||||||
} else {
|
Task {
|
||||||
stack = NavigationStack(initial: \MainCoordinator.serverList)
|
do {
|
||||||
|
try await SwiftfinStore.setupDataStack()
|
||||||
|
|
||||||
|
if UserSession.current() != nil, !Defaults[.signOutOnClose] {
|
||||||
|
await MainActor.run {
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.serverCheck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run {
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.selectUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
logger.critical("\(error.localizedDescription)")
|
||||||
|
Notifications[.didFailMigration].post()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
|
// TODO: move these to the App instead?
|
||||||
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
|
|
||||||
|
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB
|
||||||
UIScrollView.appearance().keyboardDismissMode = .onDrag
|
|
||||||
|
|
||||||
// Notification setup for state
|
// Notification setup for state
|
||||||
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
|
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
|
||||||
|
@ -55,16 +83,24 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:)))
|
Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func didFinishMigration() {}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func didSignIn() {
|
func didSignIn() {
|
||||||
logger.info("Signed in")
|
logger.info("Signed in")
|
||||||
root(\.mainTab)
|
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.serverCheck)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func didSignOut() {
|
func didSignOut() {
|
||||||
logger.info("Signed out")
|
logger.info("Signed out")
|
||||||
root(\.serverList)
|
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.selectUser)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
|
@ -84,18 +120,34 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
@objc
|
@objc
|
||||||
func didChangeCurrentServerURL(_ notification: Notification) {
|
func didChangeCurrentServerURL(_ notification: Notification) {
|
||||||
|
|
||||||
guard Container.userSession().authenticated else { return }
|
guard UserSession.current() != nil else { return }
|
||||||
|
|
||||||
Container.userSession.reset()
|
UserSession.current.reset()
|
||||||
Notifications[.didSignIn].post()
|
Notifications[.didSignIn].post()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeLoading() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
|
NavigationViewCoordinator {
|
||||||
|
AppLoadingView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
|
||||||
|
NavigationViewCoordinator(SettingsCoordinator())
|
||||||
|
}
|
||||||
|
|
||||||
func makeMainTab() -> MainTabCoordinator {
|
func makeMainTab() -> MainTabCoordinator {
|
||||||
MainTabCoordinator()
|
MainTabCoordinator()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
|
func makeSelectUser() -> NavigationViewCoordinator<SelectUserCoordinator> {
|
||||||
NavigationViewCoordinator(ServerListCoordinator())
|
NavigationViewCoordinator(SelectUserCoordinator())
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeServerCheck() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
|
NavigationViewCoordinator {
|
||||||
|
ServerCheckView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator {
|
func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator {
|
||||||
|
|
|
@ -12,6 +12,10 @@ import Nuke
|
||||||
import Stinsen
|
import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: clean up like iOS
|
||||||
|
// - move some things to App
|
||||||
|
// TODO: server check flow
|
||||||
|
|
||||||
final class MainCoordinator: NavigationCoordinatable {
|
final class MainCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@Injected(LogManager.service)
|
@Injected(LogManager.service)
|
||||||
|
@ -19,21 +23,44 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
var stack: Stinsen.NavigationStack<MainCoordinator>
|
var stack: Stinsen.NavigationStack<MainCoordinator>
|
||||||
|
|
||||||
|
@Root
|
||||||
|
var loading = makeLoading
|
||||||
@Root
|
@Root
|
||||||
var mainTab = makeMainTab
|
var mainTab = makeMainTab
|
||||||
@Root
|
@Root
|
||||||
var serverList = makeServerList
|
var selectUser = makeSelectUser
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
||||||
if Container.userSession().authenticated {
|
stack = NavigationStack(initial: \.loading)
|
||||||
stack = NavigationStack(initial: \MainCoordinator.mainTab)
|
|
||||||
} else {
|
Task {
|
||||||
stack = NavigationStack(initial: \MainCoordinator.serverList)
|
do {
|
||||||
|
try await SwiftfinStore.setupDataStack()
|
||||||
|
|
||||||
|
if UserSession.current() != nil {
|
||||||
|
await MainActor.run {
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.mainTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run {
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.selectUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
logger.critical("\(error.localizedDescription)")
|
||||||
|
Notifications[.didFailMigration].post()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
|
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
|
||||||
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
|
|
||||||
|
|
||||||
UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label]
|
UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label]
|
||||||
|
|
||||||
|
@ -44,21 +71,33 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func didSignIn() {
|
func didSignIn() {
|
||||||
logger.info("Received `didSignIn` from NSNotificationCenter.")
|
logger.info("Signed in")
|
||||||
root(\.mainTab)
|
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.mainTab)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func didSignOut() {
|
func didSignOut() {
|
||||||
logger.info("Received `didSignOut` from NSNotificationCenter.")
|
logger.info("Signed out")
|
||||||
root(\.serverList)
|
|
||||||
|
withAnimation(.linear(duration: 0.1)) {
|
||||||
|
let _ = root(\.selectUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeLoading() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
|
NavigationViewCoordinator {
|
||||||
|
AppLoadingView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeMainTab() -> MainTabCoordinator {
|
func makeMainTab() -> MainTabCoordinator {
|
||||||
MainTabCoordinator()
|
MainTabCoordinator()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
|
func makeSelectUser() -> NavigationViewCoordinator<SelectUserCoordinator> {
|
||||||
NavigationViewCoordinator(ServerListCoordinator())
|
NavigationViewCoordinator(SelectUserCoordinator())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
#if os(iOS)
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var about = makeAbout
|
|
||||||
@Route(.push)
|
|
||||||
var appIconSelector = makeAppIconSelector
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
var log = makeLog
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var nativePlayerSettings = makeNativePlayerSettings
|
var nativePlayerSettings = makeNativePlayerSettings
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var quickConnect = makeQuickConnectSettings
|
var quickConnect = makeQuickConnectAuthorize
|
||||||
|
@Route(.push)
|
||||||
|
var resetUserPassword = makeResetUserPassword
|
||||||
|
@Route(.push)
|
||||||
|
var localSecurity = makeLocalSecurity
|
||||||
|
@Route(.push)
|
||||||
|
var userProfile = makeUserProfileSettings
|
||||||
|
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
var customizeViewsSettings = makeCustomizeViewsSettings
|
||||||
|
@ -63,31 +65,30 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
var videoPlayerSettings = makeVideoPlayerSettings
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let viewModel: SettingsViewModel
|
|
||||||
|
|
||||||
init() {
|
|
||||||
viewModel = .init()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@ViewBuilder
|
|
||||||
func makeAbout() -> some View {
|
|
||||||
AboutAppView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeAppIconSelector() -> some View {
|
|
||||||
AppIconSelectorView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeNativePlayerSettings() -> some View {
|
func makeNativePlayerSettings() -> some View {
|
||||||
NativeVideoPlayerSettingsView()
|
NativeVideoPlayerSettingsView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeQuickConnectSettings() -> some View {
|
func makeQuickConnectAuthorize() -> some View {
|
||||||
QuickConnectSettingsView(viewModel: .init())
|
QuickConnectAuthorizeView()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func makeResetUserPassword() -> some View {
|
||||||
|
ResetUserPasswordView()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func makeLocalSecurity() -> some View {
|
||||||
|
UserLocalSecurityView()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
|
||||||
|
UserProfileSettingsView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -107,7 +108,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeServerDetail(server: ServerState) -> some View {
|
func makeServerDetail(server: ServerState) -> some View {
|
||||||
ServerDetailView(server: server)
|
EditServerView(server: server)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
@ -145,19 +146,15 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
NavigationViewCoordinator(
|
NavigationViewCoordinator {
|
||||||
BasicNavigationViewCoordinator {
|
IndicatorSettingsView()
|
||||||
IndicatorSettingsView()
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
NavigationViewCoordinator(
|
NavigationViewCoordinator {
|
||||||
BasicNavigationViewCoordinator {
|
EditServerView(server: server)
|
||||||
ServerDetailView(server: server)
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
|
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
|
||||||
|
@ -172,6 +169,6 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
SettingsView(viewModel: viewModel)
|
SettingsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 Foundation
|
||||||
|
import JellyfinAPI
|
||||||
import Stinsen
|
import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class UserSignInCoordinator: NavigationCoordinatable {
|
final class UserSignInCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
|
struct SecurityParameters {
|
||||||
|
let pinHint: Binding<String>
|
||||||
|
let signInPolicy: Binding<UserAccessPolicy>
|
||||||
|
}
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
|
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
|
||||||
|
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
#if os(iOS)
|
|
||||||
@Route(.modal)
|
@Route(.modal)
|
||||||
var quickConnect = makeQuickConnect
|
var quickConnect = makeQuickConnect
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
@Route(.modal)
|
||||||
|
var security = makeSecurity
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let viewModel: UserSignInViewModel
|
private let server: ServerState
|
||||||
|
|
||||||
init(viewModel: UserSignInViewModel) {
|
init(server: ServerState) {
|
||||||
self.viewModel = viewModel
|
self.server = server
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
|
NavigationViewCoordinator {
|
||||||
|
QuickConnectView(quickConnect: quickConnect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func makeQuickConnect() -> NavigationViewCoordinator<QuickConnectCoordinator> {
|
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
NavigationViewCoordinator(QuickConnectCoordinator(viewModel: viewModel))
|
NavigationViewCoordinator {
|
||||||
|
UserSignInView.SecurityView(
|
||||||
|
pinHint: parameters.pinHint,
|
||||||
|
signInPolicy: parameters.signInPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
UserSignInView(viewModel: viewModel)
|
UserSignInView(server: server)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,8 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
|
// This is only kept as reference until more strongly-typed errors are implemented.
|
||||||
|
|
||||||
// enum NetworkError: Error {
|
// enum NetworkError: Error {
|
||||||
//
|
//
|
||||||
// /// For the case that the ErrorResponse object has a code of -1
|
// /// For the case that the ErrorResponse object has a code of -1
|
||||||
|
|
|
@ -30,10 +30,6 @@ extension Array {
|
||||||
try filter(predicate).count
|
try filter(predicate).count
|
||||||
}
|
}
|
||||||
|
|
||||||
func oneSatisfies(_ predicate: (Element) throws -> Bool) rethrows -> Bool {
|
|
||||||
try contains(where: predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepending(_ element: Element) -> [Element] {
|
func prepending(_ element: Element) -> [Element] {
|
||||||
[element] + self
|
[element] + self
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,9 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: add all other missing colors from UIColor and fix usages
|
||||||
|
// - move row dividers to divider color
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
|
|
||||||
static let jellyfinPurple = Color(uiColor: .jellyfinPurple)
|
static let jellyfinPurple = Color(uiColor: .jellyfinPurple)
|
||||||
|
@ -26,9 +29,13 @@ extension Color {
|
||||||
static let secondarySystemFill = Color(UIColor.gray)
|
static let secondarySystemFill = Color(UIColor.gray)
|
||||||
static let tertiarySystemFill = Color(UIColor.black)
|
static let tertiarySystemFill = Color(UIColor.black)
|
||||||
static let lightGray = Color(UIColor.lightGray)
|
static let lightGray = Color(UIColor.lightGray)
|
||||||
|
|
||||||
#else
|
#else
|
||||||
static let systemFill = Color(UIColor.systemFill)
|
|
||||||
static let systemBackground = Color(UIColor.systemBackground)
|
static let systemBackground = Color(UIColor.systemBackground)
|
||||||
|
static let secondarySystemBackground = Color(UIColor.secondarySystemBackground)
|
||||||
|
static let tertiarySystemBackground = Color(UIColor.tertiarySystemBackground)
|
||||||
|
|
||||||
|
static let systemFill = Color(UIColor.systemFill)
|
||||||
static let secondarySystemFill = Color(UIColor.secondarySystemFill)
|
static let secondarySystemFill = Color(UIColor.secondarySystemFill)
|
||||||
static let tertiarySystemFill = Color(UIColor.tertiarySystemFill)
|
static let tertiarySystemFill = Color(UIColor.tertiarySystemFill)
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -15,7 +15,7 @@ extension EdgeInsets {
|
||||||
/// typically the edges of the View's scene
|
/// typically the edges of the View's scene
|
||||||
static let edgePadding: CGFloat = {
|
static let edgePadding: CGFloat = {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
50
|
44
|
||||||
#else
|
#else
|
||||||
if UIDevice.isPad {
|
if UIDevice.isPad {
|
||||||
24
|
24
|
||||||
|
|
|
@ -11,6 +11,10 @@ import SwiftUI
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
|
|
||||||
|
struct AccentColor: EnvironmentKey {
|
||||||
|
static let defaultValue: Binding<Color> = .constant(Color.jellyfinPurple)
|
||||||
|
}
|
||||||
|
|
||||||
struct AudioOffsetKey: EnvironmentKey {
|
struct AudioOffsetKey: EnvironmentKey {
|
||||||
static let defaultValue: Binding<Int> = .constant(0)
|
static let defaultValue: Binding<Int> = .constant(0)
|
||||||
}
|
}
|
||||||
|
@ -23,10 +27,18 @@ extension EnvironmentValues {
|
||||||
static let defaultValue: Binding<VideoPlayer.OverlayType> = .constant(.main)
|
static let defaultValue: Binding<VideoPlayer.OverlayType> = .constant(.main)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct IsEditingKey: EnvironmentKey {
|
||||||
|
static let defaultValue: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
struct IsScrubbingKey: EnvironmentKey {
|
struct IsScrubbingKey: EnvironmentKey {
|
||||||
static let defaultValue: Binding<Bool> = .constant(false)
|
static let defaultValue: Binding<Bool> = .constant(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct IsSelectedKey: EnvironmentKey {
|
||||||
|
static let defaultValue: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
struct PlaybackSpeedKey: EnvironmentKey {
|
struct PlaybackSpeedKey: EnvironmentKey {
|
||||||
static let defaultValue: Binding<Double> = .constant(1)
|
static let defaultValue: Binding<Double> = .constant(1)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,11 @@ import SwiftUI
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
|
|
||||||
|
var accentColor: Binding<Color> {
|
||||||
|
get { self[AccentColor.self] }
|
||||||
|
set { self[AccentColor.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
var audioOffset: Binding<Int> {
|
var audioOffset: Binding<Int> {
|
||||||
get { self[AudioOffsetKey.self] }
|
get { self[AudioOffsetKey.self] }
|
||||||
set { self[AudioOffsetKey.self] = newValue }
|
set { self[AudioOffsetKey.self] = newValue }
|
||||||
|
@ -25,6 +30,11 @@ extension EnvironmentValues {
|
||||||
set { self[CurrentOverlayTypeKey.self] = newValue }
|
set { self[CurrentOverlayTypeKey.self] = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isEditing: Bool {
|
||||||
|
get { self[IsEditingKey.self] }
|
||||||
|
set { self[IsEditingKey.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
var isPresentingOverlay: Binding<Bool> {
|
var isPresentingOverlay: Binding<Bool> {
|
||||||
get { self[IsPresentingOverlayKey.self] }
|
get { self[IsPresentingOverlayKey.self] }
|
||||||
set { self[IsPresentingOverlayKey.self] = newValue }
|
set { self[IsPresentingOverlayKey.self] = newValue }
|
||||||
|
@ -35,6 +45,11 @@ extension EnvironmentValues {
|
||||||
set { self[IsScrubbingKey.self] = newValue }
|
set { self[IsScrubbingKey.self] = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isSelected: Bool {
|
||||||
|
get { self[IsSelectedKey.self] }
|
||||||
|
set { self[IsSelectedKey.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
var playbackSpeed: Binding<Double> {
|
var playbackSpeed: Binding<Double> {
|
||||||
get { self[PlaybackSpeedKey.self] }
|
get { self[PlaybackSpeedKey.self] }
|
||||||
set { self[PlaybackSpeedKey.self] = newValue }
|
set { self[PlaybackSpeedKey.self] = newValue }
|
||||||
|
|
|
@ -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
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: remove and just use overlay + offset
|
||||||
extension HorizontalAlignment {
|
extension HorizontalAlignment {
|
||||||
|
|
||||||
struct VideoPlayerTitleAlignment: AlignmentID {
|
struct VideoPlayerTitleAlignment: AlignmentID {
|
||||||
|
@ -17,12 +18,4 @@ extension HorizontalAlignment {
|
||||||
}
|
}
|
||||||
|
|
||||||
static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self)
|
static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self)
|
||||||
|
|
||||||
struct LibraryRowContentAlignment: AlignmentID {
|
|
||||||
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
|
||||||
context[HorizontalAlignment.leading]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let LeadingLibraryRowContentAlignmentGuide = HorizontalAlignment(LibraryRowContentAlignment.self)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,7 +97,9 @@ extension BaseItemDto {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = Container.userSession().client
|
// TODO: client passing for widget/shared group views?
|
||||||
|
guard let client = UserSession.current()?.client else { return nil }
|
||||||
|
|
||||||
let parameters = Paths.GetItemImageParameters(
|
let parameters = Paths.GetItemImageParameters(
|
||||||
maxWidth: scaleWidth,
|
maxWidth: scaleWidth,
|
||||||
maxHeight: scaleHeight,
|
maxHeight: scaleHeight,
|
||||||
|
|
|
@ -21,7 +21,7 @@ extension BaseItemDto {
|
||||||
let tempOverkillBitrate = 360_000_000
|
let tempOverkillBitrate = 360_000_000
|
||||||
let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate)
|
let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate)
|
||||||
|
|
||||||
let userSession = Container.userSession()
|
let userSession = UserSession.current()!
|
||||||
|
|
||||||
let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
|
let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
|
||||||
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
|
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
|
||||||
|
@ -56,7 +56,7 @@ extension BaseItemDto {
|
||||||
profile.directPlayProfiles = [DirectPlayProfile(type: .video)]
|
profile.directPlayProfiles = [DirectPlayProfile(type: .video)]
|
||||||
}
|
}
|
||||||
|
|
||||||
let userSession = Container.userSession.callAsFunction()
|
let userSession = UserSession.current()!
|
||||||
|
|
||||||
let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
|
let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
|
||||||
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
|
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
|
||||||
|
|
|
@ -85,20 +85,6 @@ extension BaseItemDto {
|
||||||
return formatter.string(from: .init(remainingSeconds))
|
return formatter.string(from: .init(remainingSeconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLiveStartTimeString(formatter: DateFormatter) -> String {
|
|
||||||
if let startDate = self.startDate {
|
|
||||||
return formatter.string(from: startDate)
|
|
||||||
}
|
|
||||||
return " "
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLiveEndTimeString(formatter: DateFormatter) -> String {
|
|
||||||
if let endDate = self.endDate {
|
|
||||||
return formatter.string(from: endDate)
|
|
||||||
}
|
|
||||||
return " "
|
|
||||||
}
|
|
||||||
|
|
||||||
var programDuration: TimeInterval? {
|
var programDuration: TimeInterval? {
|
||||||
guard let startDate, let endDate else { return nil }
|
guard let startDate, let endDate else { return nil }
|
||||||
return endDate.timeIntervalSince(startDate)
|
return endDate.timeIntervalSince(startDate)
|
||||||
|
@ -174,7 +160,10 @@ extension BaseItemDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasRatings: Bool {
|
var hasRatings: Bool {
|
||||||
[criticRating, communityRating].oneSatisfies { $0 != nil }
|
[
|
||||||
|
criticRating,
|
||||||
|
communityRating,
|
||||||
|
].contains { $0 != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Chapter Images
|
// MARK: Chapter Images
|
||||||
|
@ -204,8 +193,8 @@ extension BaseItemDto {
|
||||||
parameters: parameters
|
parameters: parameters
|
||||||
)
|
)
|
||||||
|
|
||||||
let imageURL = Container
|
let imageURL = UserSession
|
||||||
.userSession()
|
.current()!
|
||||||
.client
|
.client
|
||||||
.fullURL(with: request)
|
.fullURL(with: request)
|
||||||
|
|
||||||
|
|
|
@ -23,11 +23,12 @@ extension BaseItemPerson: Poster {
|
||||||
|
|
||||||
func portraitImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] {
|
func portraitImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] {
|
||||||
|
|
||||||
|
guard let client = UserSession.current()?.client else { return [] }
|
||||||
|
|
||||||
// TODO: figure out what to do about screen scaling with .main being deprecated
|
// TODO: figure out what to do about screen scaling with .main being deprecated
|
||||||
// - maxWidth assume already scaled?
|
// - maxWidth assume already scaled?
|
||||||
let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!)
|
let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!)
|
||||||
|
|
||||||
let client = Container.userSession().client
|
|
||||||
let imageRequestParameters = Paths.GetItemImageParameters(
|
let imageRequestParameters = Paths.GetItemImageParameters(
|
||||||
maxWidth: scaleWidth ?? Int(maxWidth),
|
maxWidth: scaleWidth ?? Int(maxWidth),
|
||||||
tag: primaryImageTag
|
tag: primaryImageTag
|
||||||
|
|
|
@ -12,7 +12,7 @@ import UIKit
|
||||||
|
|
||||||
extension BaseItemPerson: Displayable {
|
extension BaseItemPerson: Displayable {
|
||||||
var displayTitle: String {
|
var displayTitle: String {
|
||||||
self.name ?? .emptyDash
|
name ?? .emptyDash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Get
|
import Get
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
extension JellyfinClient {
|
extension JellyfinClient {
|
||||||
|
|
||||||
|
@ -26,8 +27,30 @@ extension JellyfinClient {
|
||||||
/// Appends the path to the current configuration `URL`, assuming that the path begins with a leading `/`.
|
/// Appends the path to the current configuration `URL`, assuming that the path begins with a leading `/`.
|
||||||
/// Returns `nil` if the new `URL` is malformed.
|
/// Returns `nil` if the new `URL` is malformed.
|
||||||
func fullURL(with path: String) -> URL? {
|
func fullURL(with path: String) -> URL? {
|
||||||
guard let fullPath = URL(string: configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path)
|
let fullPath = configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path
|
||||||
else { return nil }
|
return URL(string: fullPath)
|
||||||
return fullPath
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension JellyfinClient.Configuration {
|
||||||
|
|
||||||
|
static func swiftfinConfiguration(url: URL) -> Self {
|
||||||
|
|
||||||
|
let client = "Swiftfin \(UIDevice.platform)"
|
||||||
|
let deviceName = UIDevice.current.name
|
||||||
|
.folding(options: .diacriticInsensitive, locale: .current)
|
||||||
|
.unicodeScalars
|
||||||
|
.filter { CharacterSet.urlQueryAllowed.contains($0) }
|
||||||
|
.description
|
||||||
|
let deviceID = "\(UIDevice.platform)_\(UIDevice.vendorUUIDString)"
|
||||||
|
let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.1"
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
url: url,
|
||||||
|
client: client,
|
||||||
|
deviceName: deviceName,
|
||||||
|
deviceID: deviceID,
|
||||||
|
version: version
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ extension MediaSourceInfo {
|
||||||
|
|
||||||
func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
|
func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
|
||||||
|
|
||||||
let userSession = Container.userSession()
|
let userSession: UserSession! = UserSession.current()
|
||||||
let playbackURL: URL
|
let playbackURL: URL
|
||||||
let streamType: StreamType
|
let streamType: StreamType
|
||||||
|
|
||||||
|
@ -67,7 +67,8 @@ extension MediaSourceInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
|
func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
|
||||||
let userSession = Container.userSession.callAsFunction()
|
|
||||||
|
let userSession: UserSession! = UserSession.current()
|
||||||
let playbackURL: URL
|
let playbackURL: URL
|
||||||
let streamType: StreamType
|
let streamType: StreamType
|
||||||
|
|
||||||
|
|
|
@ -16,8 +16,8 @@ extension MediaStream {
|
||||||
static var none: MediaStream = .init(displayTitle: L10n.none, index: -1)
|
static var none: MediaStream = .init(displayTitle: L10n.none, index: -1)
|
||||||
|
|
||||||
var asPlaybackChild: VLCVideoPlayer.PlaybackChild? {
|
var asPlaybackChild: VLCVideoPlayer.PlaybackChild? {
|
||||||
guard let deliveryURL else { return nil }
|
guard let deliveryURL, let client = UserSession.current()?.client else { return nil }
|
||||||
let client = Container.userSession().client
|
|
||||||
let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/")
|
let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/")
|
||||||
|
|
||||||
guard let fullURL = client.fullURL(with: deliveryPath) else { return nil }
|
guard let fullURL = client.fullURL(with: deliveryPath) else { return nil }
|
||||||
|
@ -250,22 +250,22 @@ extension [MediaStream] {
|
||||||
}
|
}
|
||||||
|
|
||||||
var has4KVideo: Bool {
|
var has4KVideo: Bool {
|
||||||
oneSatisfies { $0.is4kVideo }
|
contains { $0.is4kVideo }
|
||||||
}
|
}
|
||||||
|
|
||||||
var has51AudioChannelLayout: Bool {
|
var has51AudioChannelLayout: Bool {
|
||||||
oneSatisfies { $0.is51AudioChannelLayout }
|
contains { $0.is51AudioChannelLayout }
|
||||||
}
|
}
|
||||||
|
|
||||||
var has71AudioChannelLayout: Bool {
|
var has71AudioChannelLayout: Bool {
|
||||||
oneSatisfies { $0.is71AudioChannelLayout }
|
contains { $0.is71AudioChannelLayout }
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasHDVideo: Bool {
|
var hasHDVideo: Bool {
|
||||||
oneSatisfies { $0.isHDVideo }
|
contains { $0.isHDVideo }
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasSubtitles: Bool {
|
var hasSubtitles: Bool {
|
||||||
oneSatisfies { $0.type == .subtitle }
|
contains { $0.type == .subtitle }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,26 +6,25 @@
|
||||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
import Factory
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Get
|
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UserDto {
|
extension UserDto {
|
||||||
|
|
||||||
func profileImageSource(client: JellyfinClient, maxWidth: CGFloat, maxHeight: CGFloat) -> ImageSource {
|
func profileImageSource(
|
||||||
let scaleWidth = UIScreen.main.scale(maxWidth)
|
client: JellyfinClient,
|
||||||
let scaleHeight = UIScreen.main.scale(maxHeight)
|
maxWidth: CGFloat? = nil,
|
||||||
|
maxHeight: CGFloat? = nil
|
||||||
let request = Paths.getUserImage(
|
) -> ImageSource {
|
||||||
userID: id ?? "",
|
UserState(
|
||||||
imageType: "Primary",
|
id: id ?? "",
|
||||||
parameters: .init(maxWidth: scaleWidth, maxHeight: scaleHeight)
|
serverID: "",
|
||||||
|
username: ""
|
||||||
|
)
|
||||||
|
.profileImageSource(
|
||||||
|
client: client,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
maxHeight: maxHeight
|
||||||
)
|
)
|
||||||
|
|
||||||
let profileImageURL = client.fullURL(with: request)
|
|
||||||
|
|
||||||
return ImageSource(url: profileImageURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,13 +9,6 @@
|
||||||
import Stinsen
|
import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension NavigationCoordinatable {
|
|
||||||
|
|
||||||
func inNavigationViewCoordinator() -> NavigationViewCoordinator<Self> {
|
|
||||||
NavigationViewCoordinator(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
extension NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
|
|
||||||
convenience init<Content: View>(@ViewBuilder content: @escaping () -> Content) {
|
convenience init<Content: View>(@ViewBuilder content: @escaping () -> Content) {
|
||||||
|
|
|
@ -48,6 +48,10 @@ extension Sequence {
|
||||||
func subtracting<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
|
func subtracting<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
|
||||||
filter { !other.contains($0[keyPath: keyPath]) }
|
filter { !other.contains($0[keyPath: keyPath]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func zipped<Value>(map mapToOther: (Element) throws -> Value) rethrows -> [(Element, Value)] {
|
||||||
|
try map { try ($0, mapToOther($0)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Sequence where Element: Equatable {
|
extension Sequence where Element: Equatable {
|
||||||
|
|
|
@ -7,20 +7,14 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
// TODO: remove
|
extension Set {
|
||||||
struct ErrorMessage: Hashable, Identifiable {
|
|
||||||
|
|
||||||
let code: Int?
|
mutating func toggle(value: Element) {
|
||||||
let message: String
|
if contains(value) {
|
||||||
|
remove(value)
|
||||||
var id: Int {
|
} else {
|
||||||
hashValue
|
insert(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(message: String, code: Int? = nil) {
|
|
||||||
self.code = code
|
|
||||||
self.message = message
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -118,5 +118,6 @@ extension String {
|
||||||
|
|
||||||
extension CharacterSet {
|
extension CharacterSet {
|
||||||
|
|
||||||
|
// Character that appears on tvOS with voice input
|
||||||
static var objectReplacement: CharacterSet = .init(charactersIn: "\u{fffc}")
|
static var objectReplacement: CharacterSet = .init(charactersIn: "\u{fffc}")
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,11 +49,15 @@ extension UIDevice {
|
||||||
}
|
}
|
||||||
|
|
||||||
static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) {
|
static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) {
|
||||||
|
#if os(iOS)
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(type)
|
UINotificationFeedbackGenerator().notificationOccurred(type)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) {
|
static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||||
|
#if os(iOS)
|
||||||
UIImpactFeedbackGenerator(style: type).impactOccurred()
|
UIImpactFeedbackGenerator(style: type).impactOccurred()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
extension Backport where Content: View {
|
||||||
|
|
||||||
|
/// Note: has no effect on iOS/tvOS 15
|
||||||
|
@ViewBuilder
|
||||||
|
func fontWeight(_ weight: Font.Weight?) -> some View {
|
||||||
|
if #available(iOS 16, tvOS 16, *) {
|
||||||
|
content.fontWeight(weight)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View {
|
func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View {
|
||||||
if #available(iOS 16, tvOS 16, *) {
|
if #available(iOS 16, tvOS 16, *) {
|
||||||
|
@ -30,6 +40,17 @@ extension Backport where Content: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDisabled(_ disabled: Bool) -> some View {
|
||||||
|
if #available(iOS 16, tvOS 16, *) {
|
||||||
|
content.scrollDisabled(disabled)
|
||||||
|
} else {
|
||||||
|
content.introspect(.scrollView, on: .iOS(.v15), .tvOS(.v15)) { scrollView in
|
||||||
|
scrollView.isScrollEnabled = !disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|
||||||
// TODO: - remove comment when migrated away from Stinsen
|
// TODO: - remove comment when migrated away from Stinsen
|
||||||
|
@ -62,3 +83,16 @@ extension Backport where Content: View {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: ButtonBorderShape
|
||||||
|
|
||||||
|
extension ButtonBorderShape {
|
||||||
|
|
||||||
|
static let circleBackport: ButtonBorderShape = {
|
||||||
|
if #available(iOS 17, tvOS 16.4, *) {
|
||||||
|
return ButtonBorderShape.circle
|
||||||
|
} else {
|
||||||
|
return ButtonBorderShape.roundedRectangle
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
|
@ -11,12 +11,12 @@ import SwiftUI
|
||||||
struct OnReceiveNotificationModifier: ViewModifier {
|
struct OnReceiveNotificationModifier: ViewModifier {
|
||||||
|
|
||||||
let notification: NSNotification.Name
|
let notification: NSNotification.Name
|
||||||
let onReceive: () -> Void
|
let onReceive: (Notification) -> Void
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.onReceive(NotificationCenter.default.publisher(for: notification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: notification)) {
|
||||||
onReceive()
|
onReceive($0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,3 +21,23 @@ struct OnSizeChangedModifier<Wrapped: View>: ViewModifier {
|
||||||
.trackingSize($size)
|
.trackingSize($size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct EnvironmentModifier<Wrapped: View, Value>: ViewModifier {
|
||||||
|
|
||||||
|
@Environment
|
||||||
|
var environmentValue: Value
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var wrapped: (Value) -> Wrapped
|
||||||
|
|
||||||
|
init(_ keyPath: KeyPath<EnvironmentValues, Value>, @ViewBuilder wrapped: @escaping (Value) -> Wrapped) {
|
||||||
|
self._environmentValue = Environment(keyPath)
|
||||||
|
self.wrapped = wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
wrapped(environmentValue)
|
||||||
|
|
||||||
|
// wrapped(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
func body(content: Content) -> some View {
|
||||||
content.introspect(.scrollView, on: .iOS(.v15), .iOS(.v16), .iOS(.v17)) { scrollView in
|
content.introspect(.scrollView, on: .iOS(.v15), .iOS(.v16), .iOS(.v17), .tvOS(.v15), .tvOS(.v16), .tvOS(.v17)) { scrollView in
|
||||||
scrollView.delegate = scrollViewDelegate
|
scrollView.delegate = scrollViewDelegate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -196,8 +196,6 @@ extension View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: have width/height tracked binding
|
|
||||||
|
|
||||||
func onSizeChanged(perform action: @escaping (CGSize) -> Void) -> some View {
|
func onSizeChanged(perform action: @escaping (CGSize) -> Void) -> some View {
|
||||||
onSizeChanged { size, _ in
|
onSizeChanged { size, _ in
|
||||||
action(size)
|
action(size)
|
||||||
|
@ -246,15 +244,6 @@ extension View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies the `.palette` symbol rendering mode and a foreground style
|
|
||||||
/// where the primary style is the passed `Color`'s `overlayColor` and the
|
|
||||||
/// secondary style is the passed `Color`.
|
|
||||||
///
|
|
||||||
/// If `color == nil`, then `accentColor` from the environment is used.
|
|
||||||
func paletteOverlayRendering(color: Color? = nil) -> some View {
|
|
||||||
modifier(PaletteOverlayRenderingModifier(color: color))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func navigationBarHidden() -> some View {
|
func navigationBarHidden() -> some View {
|
||||||
if #available(iOS 16, tvOS 16, *) {
|
if #available(iOS 16, tvOS 16, *) {
|
||||||
|
@ -321,7 +310,7 @@ extension View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func onNotification(_ name: NSNotification.Name, perform action: @escaping () -> Void) -> some View {
|
func onNotification(_ name: NSNotification.Name, perform action: @escaping (Notification) -> Void) -> some View {
|
||||||
modifier(
|
modifier(
|
||||||
OnReceiveNotificationModifier(
|
OnReceiveNotificationModifier(
|
||||||
notification: name,
|
notification: name,
|
||||||
|
@ -330,13 +319,50 @@ extension View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func onNotification(_ swiftfinNotification: Notifications.Key, perform action: @escaping (Notification) -> Void) -> some View {
|
||||||
|
modifier(
|
||||||
|
OnReceiveNotificationModifier(
|
||||||
|
notification: swiftfinNotification.underlyingNotification.name,
|
||||||
|
onReceive: action
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scroll(ifLargerThan height: CGFloat) -> some View {
|
||||||
|
modifier(ScrollIfLargerThanModifier(height: height))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: debug
|
||||||
|
|
||||||
|
// Useful modifiers during development for layout
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Useful modifier during development
|
func debugBackground<S: ShapeStyle>(_ fill: S = .red.opacity(0.5)) -> some View {
|
||||||
func debugBackground(_ color: Color = Color.red, opacity: CGFloat = 0.5) -> some View {
|
|
||||||
background {
|
background {
|
||||||
color
|
Rectangle()
|
||||||
.opacity(opacity)
|
.fill(fill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func debugVLine<S: ShapeStyle>(_ fill: S) -> some View {
|
||||||
|
overlay {
|
||||||
|
Rectangle()
|
||||||
|
.fill(fill)
|
||||||
|
.frame(width: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugHLine<S: ShapeStyle>(_ fill: S) -> some View {
|
||||||
|
overlay {
|
||||||
|
Rectangle()
|
||||||
|
.fill(fill)
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugCross<S: ShapeStyle>(_ fill: S = .red) -> some View {
|
||||||
|
debugVLine(fill)
|
||||||
|
.debugHLine(fill)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,32 +99,35 @@ extension CaseIterablePicker {
|
||||||
|
|
||||||
// MARK: Label
|
// MARK: Label
|
||||||
|
|
||||||
extension CaseIterablePicker where Element: SystemImageable {
|
// TODO: I didn't entirely like the forced label design that this
|
||||||
|
// uses, decide whether to actually keep
|
||||||
|
|
||||||
init(title: String, selection: Binding<Element?>) {
|
// extension CaseIterablePicker where Element: SystemImageable {
|
||||||
self.init(
|
//
|
||||||
selection: selection,
|
// init(title: String, selection: Binding<Element?>) {
|
||||||
label: { Label($0.displayTitle, systemImage: $0.systemImage) },
|
// self.init(
|
||||||
title: title,
|
// selection: selection,
|
||||||
hasNone: true,
|
// label: { Label($0.displayTitle, systemImage: $0.systemImage) },
|
||||||
noneStyle: .text
|
// title: title,
|
||||||
)
|
// hasNone: true,
|
||||||
}
|
// noneStyle: .text
|
||||||
|
// )
|
||||||
init(title: String, selection: Binding<Element>) {
|
// }
|
||||||
let binding = Binding<Element?> {
|
//
|
||||||
selection.wrappedValue
|
// init(title: String, selection: Binding<Element>) {
|
||||||
} set: { newValue, _ in
|
// let binding = Binding<Element?> {
|
||||||
precondition(newValue != nil, "Should not have nil new value with non-optional binding")
|
// selection.wrappedValue
|
||||||
selection.wrappedValue = newValue!
|
// } set: { newValue, _ in
|
||||||
}
|
// precondition(newValue != nil, "Should not have nil new value with non-optional binding")
|
||||||
|
// selection.wrappedValue = newValue!
|
||||||
self.init(
|
// }
|
||||||
selection: binding,
|
//
|
||||||
label: { Label($0.displayTitle, systemImage: $0.systemImage) },
|
// self.init(
|
||||||
title: title,
|
// selection: binding,
|
||||||
hasNone: false,
|
// label: { Label($0.displayTitle, systemImage: $0.systemImage) },
|
||||||
noneStyle: .text
|
// title: title,
|
||||||
)
|
// hasNone: false,
|
||||||
}
|
// noneStyle: .text
|
||||||
}
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
|
@ -19,7 +19,6 @@ enum ItemFilterType: String, CaseIterable, Defaults.Serializable {
|
||||||
case traits
|
case traits
|
||||||
case years
|
case years
|
||||||
|
|
||||||
// TODO: rename to something indicating plurality instead of concrete type?
|
|
||||||
var selectorType: SelectorType {
|
var selectorType: SelectorType {
|
||||||
switch self {
|
switch self {
|
||||||
case .genres, .tags, .traits, .years:
|
case .genres, .tags, .traits, .years:
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
enum LibraryDisplayType: String, CaseIterable, Displayable, Defaults.Serializable, SystemImageable {
|
enum LibraryDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable {
|
||||||
|
|
||||||
case grid
|
case grid
|
||||||
case list
|
case list
|
||||||
|
|
|
@ -13,6 +13,11 @@ import JellyfinAPI
|
||||||
struct TitledLibraryParent: LibraryParent {
|
struct TitledLibraryParent: LibraryParent {
|
||||||
|
|
||||||
let displayTitle: String
|
let displayTitle: String
|
||||||
let id: String? = nil
|
let id: String?
|
||||||
let libraryType: BaseItemKind? = nil
|
let libraryType: BaseItemKind? = nil
|
||||||
|
|
||||||
|
init(displayTitle: String, id: String? = nil) {
|
||||||
|
self.displayTitle = displayTitle
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import Defaults
|
import Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable {
|
enum PosterDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable {
|
||||||
|
|
||||||
case landscape
|
case landscape
|
||||||
case portrait
|
case portrait
|
||||||
|
@ -23,4 +23,13 @@ enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable
|
||||||
"Portrait"
|
"Portrait"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .landscape:
|
||||||
|
"rectangle.fill"
|
||||||
|
case .portrait:
|
||||||
|
"rectangle.portrait.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
// parent class actions
|
||||||
// TODO: official way for a cleaner `respond` method so it doesn't have all Task
|
// TODO: official way for a cleaner `respond` method so it doesn't have all Task
|
||||||
// construction and get bloated
|
// construction and get bloated
|
||||||
|
// TODO: make Action: Hashable just for consistency
|
||||||
|
|
||||||
protocol Stateful: AnyObject {
|
protocol Stateful: AnyObject {
|
||||||
|
|
||||||
|
@ -43,6 +44,11 @@ protocol Stateful: AnyObject {
|
||||||
|
|
||||||
extension Stateful {
|
extension Stateful {
|
||||||
|
|
||||||
|
var lastAction: Action? {
|
||||||
|
get { nil }
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func send(_ action: Action) {
|
func send(_ action: Action) {
|
||||||
state = respond(to: action)
|
state = respond(to: action)
|
||||||
|
|
|
@ -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
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Factory
|
import Factory
|
||||||
import Foundation
|
import Foundation
|
||||||
import UDPBroadcast
|
import UDPBroadcast
|
||||||
|
@ -15,69 +16,45 @@ class ServerDiscovery {
|
||||||
@Injected(LogManager.service)
|
@Injected(LogManager.service)
|
||||||
private var logger
|
private var logger
|
||||||
|
|
||||||
struct ServerLookupResponse: Codable, Hashable, Identifiable {
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let address: String
|
|
||||||
let id: String
|
|
||||||
let name: String
|
|
||||||
|
|
||||||
var url: URL {
|
|
||||||
URL(string: self.address)!
|
|
||||||
}
|
|
||||||
|
|
||||||
var host: String {
|
|
||||||
let components = URLComponents(string: self.address)
|
|
||||||
if let host = components?.host {
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
return self.address
|
|
||||||
}
|
|
||||||
|
|
||||||
var port: Int {
|
|
||||||
let components = URLComponents(string: self.address)
|
|
||||||
if let port = components?.port {
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
return 7359
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case address = "Address"
|
|
||||||
case id = "Id"
|
|
||||||
case name = "Name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var connection: UDPBroadcastConnection?
|
private var connection: UDPBroadcastConnection?
|
||||||
|
|
||||||
init() {}
|
init() {
|
||||||
|
connection = try? UDPBroadcastConnection(
|
||||||
|
port: 7359,
|
||||||
|
handler: handleServerResponse,
|
||||||
|
errorHandler: handleError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) {
|
var discoveredServers: AnyPublisher<ServerResponse, Never> {
|
||||||
|
discoveredServersPublisher
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) {
|
private var discoveredServersPublisher = PassthroughSubject<ServerResponse, Never>()
|
||||||
do {
|
|
||||||
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
|
|
||||||
logger.debug("Received JellyfinServer from \"\(response.name)\"")
|
|
||||||
completion(response)
|
|
||||||
} catch {
|
|
||||||
completion(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
|
func broadcast() {
|
||||||
logger.error("Error handling response: \(error.localizedDescription)")
|
try? connection?.sendBroadcast("Who is JellyfinServer?")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
connection?.closeConnection()
|
||||||
|
discoveredServersPublisher.send(completion: .finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleServerResponse(_ ipAddress: String, _ port: Int, data: Data) {
|
||||||
do {
|
do {
|
||||||
self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler)
|
let response = try JSONDecoder().decode(ServerResponse.self, from: data)
|
||||||
try self.connection?.sendBroadcast("Who is JellyfinServer?")
|
discoveredServersPublisher.send(response)
|
||||||
logger.debug("Discovery broadcast sent")
|
|
||||||
|
logger.debug("Found local server: \"\(response.name)\" at: \(response.url.absoluteString)")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error sending discovery broadcast")
|
logger.debug("Unable to decode local server response from: \(ipAddress):\(port)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleError(_ error: UDPBroadcastConnection.ConnectionError) {
|
||||||
|
logger.debug("Error handling response: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
@Injected(LogManager.service)
|
||||||
private var logger
|
private var logger
|
||||||
@Injected(Container.userSession)
|
@Injected(UserSession.current)
|
||||||
private var userSession
|
private var userSession: UserSession!
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var state: State = .ready
|
var state: State = .ready
|
||||||
|
|
|
@ -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 Defaults
|
||||||
|
import Factory
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// TODO: Organize
|
// TODO: organize
|
||||||
|
// TODO: all user settings could be moved to `StoredValues`?
|
||||||
|
|
||||||
|
// Note: Only use Defaults for basic single-value settings.
|
||||||
|
// For larger data types and collections, use `StoredValue` instead.
|
||||||
|
|
||||||
|
// MARK: Suites
|
||||||
|
|
||||||
extension UserDefaults {
|
extension UserDefaults {
|
||||||
static let generalSuite = UserDefaults(suiteName: "swiftfinstore-general-defaults")!
|
|
||||||
static let universalSuite = UserDefaults(suiteName: "swiftfinstore-universal-defaults")!
|
// MARK: App
|
||||||
|
|
||||||
|
/// Settings that should apply to the app
|
||||||
|
static let appSuite = UserDefaults(suiteName: "swiftfinApp")!
|
||||||
|
|
||||||
|
// MARK: Usser
|
||||||
|
|
||||||
|
// TODO: the Factory resolver cannot be used because it would cause freezes, but
|
||||||
|
// the Defaults value should always be in sync with the latest user and what
|
||||||
|
// views properly expect. However, this feels like a hack and should be changed?
|
||||||
|
static var currentUserSuite: UserDefaults {
|
||||||
|
userSuite(id: Defaults[.lastSignedInUserID] ?? "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func userSuite(id: String) -> UserDefaults {
|
||||||
|
UserDefaults(suiteName: id)!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension Defaults.Keys {
|
||||||
|
|
||||||
|
static func AppKey<Value: Defaults.Serializable>(_ name: String) -> Key<Value?> {
|
||||||
|
Key(name, suite: .appSuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func AppKey<Value: Defaults.Serializable>(_ name: String, default: Value) -> Key<Value> {
|
||||||
|
Key(name, default: `default`, suite: .appSuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func UserKey<Value: Defaults.Serializable>(_ name: String, default: Value) -> Key<Value> {
|
||||||
|
Key(name, default: `default`, suite: .currentUserSuite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: App
|
||||||
|
|
||||||
extension Defaults.Keys {
|
extension Defaults.Keys {
|
||||||
|
|
||||||
// Universal settings
|
/// The _real_ accent color key to be used.
|
||||||
static let accentColor: Key<Color> = .init("accentColor", default: .jellyfinPurple, suite: .universalSuite)
|
///
|
||||||
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: .universalSuite)
|
/// This is set externally whenever the app or user accent colors change,
|
||||||
static let hapticFeedback: Key<Bool> = .init("hapticFeedback", default: true, suite: .universalSuite)
|
/// depending on the current app state.
|
||||||
static let lastServerUserID = Defaults.Key<String?>("lastServerUserID", suite: .universalSuite)
|
static var accentColor: Key<Color> = AppKey("accentColor", default: .jellyfinPurple)
|
||||||
|
|
||||||
// TODO: Replace with a cache
|
/// The _real_ appearance key to be used.
|
||||||
static let libraryFilterStore = Key<[String: ItemFilterCollection]>("libraryFilterStore", default: [:], suite: .generalSuite)
|
///
|
||||||
|
/// This is set externally whenever the app or user appearances change,
|
||||||
|
/// depending on the current app state.
|
||||||
|
static let appearance: Key<AppAppearance> = AppKey("appearance", default: .system)
|
||||||
|
|
||||||
|
/// The accent color default for non-user contexts.
|
||||||
|
/// Only use for `set`, use `accentColor` for `get`.
|
||||||
|
static let appAccentColor: Key<Color> = AppKey("appAccentColor", default: .jellyfinPurple)
|
||||||
|
|
||||||
|
/// The appearance default for non-user contexts.
|
||||||
|
/// /// Only use for `set`, use `appearance` for `get`.
|
||||||
|
static let appAppearance: Key<AppAppearance> = AppKey("appAppearance", default: .system)
|
||||||
|
|
||||||
|
static let backgroundSignOutInterval: Key<TimeInterval> = AppKey("backgroundSignOutInterval", default: 3600)
|
||||||
|
static let backgroundTimeStamp: Key<Date> = AppKey("backgroundTimeStamp", default: Date.now)
|
||||||
|
static let lastSignedInUserID: Key<String?> = AppKey("lastSignedInUserID")
|
||||||
|
|
||||||
|
static let selectUserDisplayType: Key<LibraryDisplayType> = AppKey("selectUserDisplayType", default: .grid)
|
||||||
|
static let selectUserServerSelection: Key<SelectUserServerSelection> = AppKey("selectUserServerSelection", default: .all)
|
||||||
|
static let selectUserAllServersSplashscreen: Key<SelectUserServerSelection> = AppKey("selectUserAllServersSplashscreen", default: .all)
|
||||||
|
static let selectUserUseSplashscreen: Key<Bool> = AppKey("selectUserUseSplashscreen", default: true)
|
||||||
|
|
||||||
|
static let signOutOnBackground: Key<Bool> = AppKey("signOutOnBackground", default: true)
|
||||||
|
static let signOutOnClose: Key<Bool> = AppKey("signOutOnClose", default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: User
|
||||||
|
|
||||||
|
extension Defaults.Keys {
|
||||||
|
|
||||||
|
/// The accent color default for user contexts.
|
||||||
|
/// Only use for `set`, use `accentColor` for `get`.
|
||||||
|
static var userAccentColor: Key<Color> { UserKey("userAccentColor", default: .jellyfinPurple) }
|
||||||
|
|
||||||
|
/// The appearance default for user contexts.
|
||||||
|
/// /// Only use for `set`, use `appearance` for `get`.
|
||||||
|
static var userAppearance: Key<AppAppearance> { UserKey("userAppearance", default: .system) }
|
||||||
|
|
||||||
enum Customization {
|
enum Customization {
|
||||||
|
|
||||||
static let itemViewType = Key<ItemViewType>("itemViewType", default: .compactLogo, suite: .generalSuite)
|
static let itemViewType: Key<ItemViewType> = UserKey("itemViewType", default: .compactLogo)
|
||||||
|
|
||||||
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: .generalSuite)
|
static let showPosterLabels: Key<Bool> = UserKey("showPosterLabels", default: true)
|
||||||
static let nextUpPosterType = Key<PosterDisplayType>("nextUpPosterType", default: .portrait, suite: .generalSuite)
|
static let nextUpPosterType: Key<PosterDisplayType> = UserKey("nextUpPosterType", default: .portrait)
|
||||||
static let recentlyAddedPosterType = Key<PosterDisplayType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
|
static let recentlyAddedPosterType: Key<PosterDisplayType> = UserKey("recentlyAddedPosterType", default: .portrait)
|
||||||
static let latestInLibraryPosterType = Key<PosterDisplayType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
|
static let latestInLibraryPosterType: Key<PosterDisplayType> = UserKey("latestInLibraryPosterType", default: .portrait)
|
||||||
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: .generalSuite)
|
static let shouldShowMissingSeasons: Key<Bool> = UserKey("shouldShowMissingSeasons", default: true)
|
||||||
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: .generalSuite)
|
static let shouldShowMissingEpisodes: Key<Bool> = UserKey("shouldShowMissingEpisodes", default: true)
|
||||||
static let similarPosterType = Key<PosterDisplayType>("similarPosterType", default: .portrait, suite: .generalSuite)
|
static let similarPosterType: Key<PosterDisplayType> = UserKey("similarPosterType", default: .portrait)
|
||||||
|
|
||||||
// TODO: have search poster type by types of items if applicable
|
// TODO: have search poster type by types of items if applicable
|
||||||
static let searchPosterType = Key<PosterDisplayType>("searchPosterType", default: .portrait, suite: .generalSuite)
|
static let searchPosterType: Key<PosterDisplayType> = UserKey("searchPosterType", default: .portrait)
|
||||||
|
|
||||||
enum CinematicItemViewType {
|
enum CinematicItemViewType {
|
||||||
|
|
||||||
static let usePrimaryImage: Key<Bool> = .init("cinematicItemViewTypeUsePrimaryImage", default: false, suite: .generalSuite)
|
static let usePrimaryImage: Key<Bool> = UserKey("cinematicItemViewTypeUsePrimaryImage", default: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Episodes {
|
enum Episodes {
|
||||||
|
|
||||||
static let useSeriesLandscapeBackdrop = Key<Bool>("useSeriesBackdrop", default: true, suite: .generalSuite)
|
static let useSeriesLandscapeBackdrop: Key<Bool> = UserKey("useSeriesBackdrop", default: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Indicators {
|
enum Indicators {
|
||||||
|
|
||||||
static let showFavorited: Key<Bool> = .init("showFavoritedIndicator", default: true, suite: .generalSuite)
|
static let showFavorited: Key<Bool> = UserKey("showFavoritedIndicator", default: true)
|
||||||
static let showProgress: Key<Bool> = .init("showProgressIndicator", default: true, suite: .generalSuite)
|
static let showProgress: Key<Bool> = UserKey("showProgressIndicator", default: true)
|
||||||
static let showUnplayed: Key<Bool> = .init("showUnplayedIndicator", default: true, suite: .generalSuite)
|
static let showUnplayed: Key<Bool> = UserKey("showUnplayedIndicator", default: true)
|
||||||
static let showPlayed: Key<Bool> = .init("showPlayedIndicator", default: true, suite: .generalSuite)
|
static let showPlayed: Key<Bool> = UserKey("showPlayedIndicator", default: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Library {
|
enum Library {
|
||||||
|
|
||||||
static let cinematicBackground: Key<Bool> = .init(
|
static let cinematicBackground: Key<Bool> = UserKey("Customization.Library.cinematicBackground", default: true)
|
||||||
"Customization.Library.cinematicBackground",
|
static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey(
|
||||||
default: true,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let enabledDrawerFilters: Key<[ItemFilterType]> = .init(
|
|
||||||
"Library.enabledDrawerFilters",
|
"Library.enabledDrawerFilters",
|
||||||
default: ItemFilterType.allCases,
|
default: ItemFilterType.allCases
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let viewType = Key<LibraryDisplayType>(
|
|
||||||
"libraryViewType",
|
|
||||||
default: .grid,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let posterType = Key<PosterDisplayType>(
|
|
||||||
"libraryPosterType",
|
|
||||||
default: .portrait,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let listColumnCount = Key<Int>(
|
|
||||||
"listColumnCount",
|
|
||||||
default: 1,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let randomImage: Key<Bool> = .init(
|
|
||||||
"libraryRandomImage",
|
|
||||||
default: true,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let showFavorites: Key<Bool> = .init(
|
|
||||||
"libraryShowFavorites",
|
|
||||||
default: true,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
)
|
||||||
|
static let displayType: Key<LibraryDisplayType> = UserKey("libraryViewType", default: .grid)
|
||||||
|
static let posterType: Key<PosterDisplayType> = UserKey("libraryPosterType", default: .portrait)
|
||||||
|
static let listColumnCount: Key<Int> = UserKey("listColumnCount", default: 1)
|
||||||
|
static let randomImage: Key<Bool> = UserKey("libraryRandomImage", default: true)
|
||||||
|
static let showFavorites: Key<Bool> = UserKey("libraryShowFavorites", default: true)
|
||||||
|
|
||||||
|
static let rememberLayout: Key<Bool> = UserKey("libraryRememberLayout", default: false)
|
||||||
|
static let rememberSort: Key<Bool> = UserKey("libraryRememberSort", default: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Search {
|
enum Search {
|
||||||
|
|
||||||
static let enabledDrawerFilters: Key<[ItemFilterType]> = .init(
|
static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey(
|
||||||
"Search.enabledDrawerFilters",
|
"Search.enabledDrawerFilters",
|
||||||
default: ItemFilterType.allCases,
|
default: ItemFilterType.allCases
|
||||||
suite: .generalSuite
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VideoPlayer {
|
enum VideoPlayer {
|
||||||
|
|
||||||
static let autoPlayEnabled: Key<Bool> = .init("autoPlayEnabled", default: true, suite: .generalSuite)
|
static let autoPlayEnabled: Key<Bool> = UserKey("autoPlayEnabled", default: true)
|
||||||
static let barActionButtons: Key<[VideoPlayerActionButton]> = .init(
|
static let barActionButtons: Key<[VideoPlayerActionButton]> = UserKey(
|
||||||
"barActionButtons",
|
"barActionButtons",
|
||||||
default: VideoPlayerActionButton.defaultBarActionButtons,
|
default: VideoPlayerActionButton.defaultBarActionButtons
|
||||||
suite: .generalSuite
|
|
||||||
)
|
)
|
||||||
static let jumpBackwardLength: Key<VideoPlayerJumpLength> = .init(
|
static let jumpBackwardLength: Key<VideoPlayerJumpLength> = UserKey("jumpBackwardLength", default: .fifteen)
|
||||||
"jumpBackwardLength",
|
static let jumpForwardLength: Key<VideoPlayerJumpLength> = UserKey("jumpForwardLength", default: .fifteen)
|
||||||
default: .fifteen,
|
static let menuActionButtons: Key<[VideoPlayerActionButton]> = UserKey(
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let jumpForwardLength: Key<VideoPlayerJumpLength> = .init(
|
|
||||||
"jumpForwardLength",
|
|
||||||
default: .fifteen,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let menuActionButtons: Key<[VideoPlayerActionButton]> = .init(
|
|
||||||
"menuActionButtons",
|
"menuActionButtons",
|
||||||
default: VideoPlayerActionButton.defaultMenuActionButtons,
|
default: VideoPlayerActionButton.defaultMenuActionButtons
|
||||||
suite: .generalSuite
|
|
||||||
)
|
)
|
||||||
static let resumeOffset: Key<Int> = .init("resumeOffset", default: 0, suite: .generalSuite)
|
static let resumeOffset: Key<Int> = UserKey("resumeOffset", default: 0)
|
||||||
static let showJumpButtons: Key<Bool> = .init("showJumpButtons", default: true, suite: .generalSuite)
|
static let showJumpButtons: Key<Bool> = UserKey("showJumpButtons", default: true)
|
||||||
static let videoPlayerType: Key<VideoPlayerType> = .init("videoPlayerType", default: .swiftfin, suite: .generalSuite)
|
static let videoPlayerType: Key<VideoPlayerType> = UserKey("videoPlayerType", default: .swiftfin)
|
||||||
|
|
||||||
enum Gesture {
|
enum Gesture {
|
||||||
|
|
||||||
static let horizontalPanGesture: Key<PanAction> = .init(
|
static let horizontalPanGesture: Key<PanAction> = UserKey("videoPlayerHorizontalPanGesture", default: .none)
|
||||||
"videoPlayerHorizontalPanGesture",
|
static let horizontalSwipeGesture: Key<SwipeAction> = UserKey("videoPlayerHorizontalSwipeGesture", default: .none)
|
||||||
default: .none,
|
static let longPressGesture: Key<LongPressAction> = UserKey("videoPlayerLongPressGesture", default: .gestureLock)
|
||||||
suite: .generalSuite
|
static let multiTapGesture: Key<MultiTapAction> = UserKey("videoPlayerMultiTapGesture", default: .none)
|
||||||
)
|
static let doubleTouchGesture: Key<DoubleTouchAction> = UserKey("videoPlayerDoubleTouchGesture", default: .none)
|
||||||
static let horizontalSwipeGesture: Key<SwipeAction> = .init(
|
static let pinchGesture: Key<PinchAction> = UserKey("videoPlayerSwipeGesture", default: .aspectFill)
|
||||||
"videoPlayerHorizontalSwipeGesture",
|
static let verticalPanGestureLeft: Key<PanAction> = UserKey("videoPlayerVerticalPanGestureLeft", default: .none)
|
||||||
default: .none,
|
static let verticalPanGestureRight: Key<PanAction> = UserKey("videoPlayerVerticalPanGestureRight", default: .none)
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let longPressGesture: Key<LongPressAction> = .init(
|
|
||||||
"videoPlayerLongPressGesture",
|
|
||||||
default: .gestureLock,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let multiTapGesture: Key<MultiTapAction> = .init("videoPlayerMultiTapGesture", default: .none, suite: .generalSuite)
|
|
||||||
static let doubleTouchGesture: Key<DoubleTouchAction> = .init(
|
|
||||||
"videoPlayerDoubleTouchGesture",
|
|
||||||
default: .none,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let pinchGesture: Key<PinchAction> = .init("videoPlayerSwipeGesture", default: .aspectFill, suite: .generalSuite)
|
|
||||||
static let verticalPanGestureLeft: Key<PanAction> = .init(
|
|
||||||
"videoPlayerVerticalPanGestureLeft",
|
|
||||||
default: .none,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let verticalPanGestureRight: Key<PanAction> = .init(
|
|
||||||
"videoPlayerVerticalPanGestureRight",
|
|
||||||
default: .none,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Overlay {
|
enum Overlay {
|
||||||
|
|
||||||
static let chapterSlider: Key<Bool> = .init("chapterSlider", default: true, suite: .generalSuite)
|
static let chapterSlider: Key<Bool> = UserKey("chapterSlider", default: true)
|
||||||
static let playbackButtonType: Key<PlaybackButtonType> = .init(
|
static let playbackButtonType: Key<PlaybackButtonType> = UserKey("videoPlayerPlaybackButtonLocation", default: .large)
|
||||||
"videoPlayerPlaybackButtonLocation",
|
static let sliderColor: Key<Color> = UserKey("sliderColor", default: Color.white)
|
||||||
default: .large,
|
static let sliderType: Key<SliderType> = UserKey("sliderType", default: .capsule)
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let sliderColor: Key<Color> = .init("sliderColor", default: Color.white, suite: .generalSuite)
|
|
||||||
static let sliderType: Key<SliderType> = .init("sliderType", default: .capsule, suite: .generalSuite)
|
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
static let trailingTimestampType: Key<TrailingTimestampType> = .init(
|
static let trailingTimestampType: Key<TrailingTimestampType> = UserKey("trailingTimestamp", default: .timeLeft)
|
||||||
"trailingTimestamp",
|
static let showCurrentTimeWhileScrubbing: Key<Bool> = UserKey("showCurrentTimeWhileScrubbing", default: true)
|
||||||
default: .timeLeft,
|
static let timestampType: Key<TimestampType> = UserKey("timestampType", default: .split)
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let showCurrentTimeWhileScrubbing: Key<Bool> = .init(
|
|
||||||
"showCurrentTimeWhileScrubbing",
|
|
||||||
default: true,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let timestampType: Key<TimestampType> = .init("timestampType", default: .split, suite: .generalSuite)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Subtitle {
|
enum Subtitle {
|
||||||
|
|
||||||
static let subtitleColor: Key<Color> = .init(
|
static let subtitleColor: Key<Color> = UserKey("subtitleColor", default: .white)
|
||||||
"subtitleColor",
|
static let subtitleFontName: Key<String> = UserKey("subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName)
|
||||||
default: .white,
|
static let subtitleSize: Key<Int> = UserKey("subtitleSize", default: 16)
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let subtitleFontName: Key<String> = .init(
|
|
||||||
"subtitleFontName",
|
|
||||||
default: UIFont.systemFont(ofSize: 14).fontName,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let subtitleSize: Key<Int> = .init("subtitleSize", default: 16, suite: .generalSuite)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Transition {
|
enum Transition {
|
||||||
static let pauseOnBackground: Key<Bool> = .init("pauseOnBackground", default: false, suite: .generalSuite)
|
static let pauseOnBackground: Key<Bool> = UserKey("pauseOnBackground", default: false)
|
||||||
static let playOnActive: Key<Bool> = .init("playOnActive", default: false, suite: .generalSuite)
|
static let playOnActive: Key<Bool> = UserKey("playOnActive", default: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Experimental settings
|
// Experimental settings
|
||||||
enum Experimental {
|
enum Experimental {
|
||||||
|
|
||||||
static let downloads: Key<Bool> = .init("experimentalDownloads", default: false, suite: .generalSuite)
|
static let downloads: Key<Bool> = UserKey("experimentalDownloads", default: false)
|
||||||
static let syncSubtitleStateWithAdjacent = Key<Bool>(
|
static let forceDirectPlay: Key<Bool> = UserKey("forceDirectPlay", default: false)
|
||||||
"experimentalSyncSubtitleState",
|
static let liveTVForceDirectPlay: Key<Bool> = UserKey("liveTVForceDirectPlay", default: false)
|
||||||
default: false,
|
|
||||||
suite: .generalSuite
|
|
||||||
)
|
|
||||||
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: .generalSuite)
|
|
||||||
|
|
||||||
static let liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: .generalSuite)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tvos specific
|
// tvos specific
|
||||||
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: .generalSuite)
|
static let downActionShowsMenu: Key<Bool> = UserKey("downActionShowsMenu", default: true)
|
||||||
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: .generalSuite)
|
static let confirmClose: Key<Bool> = UserKey("confirmClose", default: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Debug
|
// MARK: Debug
|
||||||
|
@ -250,6 +243,10 @@ extension UserDefaults {
|
||||||
|
|
||||||
extension Defaults.Keys {
|
extension Defaults.Keys {
|
||||||
|
|
||||||
static let sendProgressReports: Key<Bool> = .init("sendProgressReports", default: true, suite: .debugSuite)
|
static func DebugKey<Value: Defaults.Serializable>(_ name: String, default: Value) -> Key<Value> {
|
||||||
|
Key(name, default: `default`, suite: .appSuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let sendProgressReports: Key<Bool> = DebugKey("sendProgressReports", default: true)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -14,7 +14,7 @@ class SwiftfinNotification {
|
||||||
@Injected(Notifications.service)
|
@Injected(Notifications.service)
|
||||||
private var notificationService
|
private var notificationService
|
||||||
|
|
||||||
private let name: Notification.Name
|
let name: Notification.Name
|
||||||
|
|
||||||
fileprivate init(_ notificationName: Notification.Name) {
|
fileprivate init(_ notificationName: Notification.Name) {
|
||||||
self.name = notificationName
|
self.name = notificationName
|
||||||
|
@ -39,7 +39,7 @@ class SwiftfinNotification {
|
||||||
|
|
||||||
enum Notifications {
|
enum Notifications {
|
||||||
|
|
||||||
static let service = Factory(scope: .singleton) { NotificationCenter() }
|
static let service = Factory(scope: .singleton) { NotificationCenter.default }
|
||||||
|
|
||||||
struct Key: Hashable {
|
struct Key: Hashable {
|
||||||
|
|
||||||
|
@ -76,6 +76,10 @@ extension Notifications.Key {
|
||||||
static let didChangeCurrentServerURL = NotificationKey("didChangeCurrentServerURL")
|
static let didChangeCurrentServerURL = NotificationKey("didChangeCurrentServerURL")
|
||||||
static let didSendStopReport = NotificationKey("didSendStopReport")
|
static let didSendStopReport = NotificationKey("didSendStopReport")
|
||||||
static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
|
static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
|
||||||
|
static let didFailMigration = NotificationKey("didFailMigration")
|
||||||
|
|
||||||
static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange")
|
static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange")
|
||||||
|
|
||||||
|
static let didConnectToServer = NotificationKey("didConnectToServer")
|
||||||
|
static let didDeleteServer = NotificationKey("didDeleteServer")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import CoreStore
|
import CoreStore
|
||||||
import CryptoKit
|
|
||||||
import Defaults
|
|
||||||
import Factory
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Get
|
import Get
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
import OrderedCollections
|
||||||
import Pulse
|
import Pulse
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class ConnectToServerViewModel: ViewModel {
|
final class ConnectToServerViewModel: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
|
// MARK: Event
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
case connected(ServerState)
|
||||||
|
case duplicateServer(ServerState)
|
||||||
|
case error(JellyfinAPIError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Action
|
||||||
|
|
||||||
|
enum Action: Equatable {
|
||||||
|
case addNewURL(ServerState)
|
||||||
|
case cancel
|
||||||
|
case connect(String)
|
||||||
|
case searchForServers
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: BackgroundState
|
||||||
|
|
||||||
|
enum BackgroundState: Hashable {
|
||||||
|
case searching
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
|
||||||
|
enum State: Hashable {
|
||||||
|
case connecting
|
||||||
|
case initial
|
||||||
|
}
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
private(set) var discoveredServers: [ServerState] = []
|
var backgroundStates: OrderedSet<BackgroundState> = []
|
||||||
|
|
||||||
|
// no longer-found servers are not cleared, but not an issue
|
||||||
@Published
|
@Published
|
||||||
private(set) var isSearching = false
|
var localServers: OrderedSet<ServerState> = []
|
||||||
|
@Published
|
||||||
|
var state: State = .initial
|
||||||
|
|
||||||
|
var events: AnyPublisher<Event, Never> {
|
||||||
|
eventSubject
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectTask: AnyCancellable? = nil
|
||||||
private let discovery = ServerDiscovery()
|
private let discovery = ServerDiscovery()
|
||||||
|
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||||
|
|
||||||
var connectToServerTask: Task<ServerState, Error>?
|
deinit {
|
||||||
|
discovery.close()
|
||||||
|
}
|
||||||
|
|
||||||
func connectToServer(url: String) async throws -> (server: ServerState, url: URL) {
|
override init() {
|
||||||
|
super.init()
|
||||||
|
|
||||||
#if os(iOS)
|
Task { [weak self] in
|
||||||
// shhhh
|
guard let self else { return }
|
||||||
// TODO: remove
|
|
||||||
if let data = url.data(using: .utf8) {
|
|
||||||
var sha = SHA256()
|
|
||||||
sha.update(data: data)
|
|
||||||
let digest = sha.finalize()
|
|
||||||
let urlHash = digest.compactMap { String(format: "%02x", $0) }.joined()
|
|
||||||
if urlHash == "7499aced43869b27f505701e4edc737f0cc346add1240d4ba86fbfa251e0fc35" {
|
|
||||||
Defaults[.Experimental.downloads] = true
|
|
||||||
|
|
||||||
await UIDevice.feedback(.success)
|
for await response in discovery.discoveredServers.values {
|
||||||
|
await MainActor.run {
|
||||||
|
let _ = self.localServers.append(response.asServerState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func respond(to action: Action) -> State {
|
||||||
|
switch action {
|
||||||
|
case let .addNewURL(server):
|
||||||
|
addNewURL(server: server)
|
||||||
|
|
||||||
|
return state
|
||||||
|
case .cancel:
|
||||||
|
connectTask?.cancel()
|
||||||
|
|
||||||
|
return .initial
|
||||||
|
case let .connect(url):
|
||||||
|
connectTask?.cancel()
|
||||||
|
|
||||||
|
connectTask = Task {
|
||||||
|
do {
|
||||||
|
let server = try await connectToServer(url: url)
|
||||||
|
|
||||||
|
if isDuplicate(server: server) {
|
||||||
|
await MainActor.run {
|
||||||
|
// server has same id, but (possible) new URL
|
||||||
|
self.eventSubject.send(.duplicateServer(server))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try await save(server: server)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.eventSubject.send(.connected(server))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.state = .initial
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancel doesn't matter
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.eventSubject.send(.error(.init(error.localizedDescription)))
|
||||||
|
self.state = .initial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
|
||||||
|
return .connecting
|
||||||
|
case .searchForServers:
|
||||||
|
discovery.broadcast()
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectToServer(url: String) async throws -> ServerState {
|
||||||
|
|
||||||
let formattedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
let formattedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.trimmingCharacters(in: .objectReplacement)
|
.trimmingCharacters(in: .objectReplacement)
|
||||||
|
@ -54,37 +146,35 @@ final class ConnectToServerViewModel: ViewModel {
|
||||||
|
|
||||||
let client = JellyfinClient(
|
let client = JellyfinClient(
|
||||||
configuration: .swiftfinConfiguration(url: url),
|
configuration: .swiftfinConfiguration(url: url),
|
||||||
sessionDelegate: URLSessionProxyDelegate()
|
sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger())
|
||||||
)
|
)
|
||||||
|
|
||||||
let response = try await client.send(Paths.getPublicSystemInfo)
|
let response = try await client.send(Paths.getPublicSystemInfo)
|
||||||
|
|
||||||
guard let name = response.value.serverName,
|
guard let name = response.value.serverName,
|
||||||
let id = response.value.id,
|
let id = response.value.id
|
||||||
let os = response.value.operatingSystem,
|
|
||||||
let version = response.value.version
|
|
||||||
else {
|
else {
|
||||||
throw JellyfinAPIError("Missing server data from network call")
|
logger.critical("Missing server data from network call")
|
||||||
|
throw JellyfinAPIError("An internal error has occurred")
|
||||||
}
|
}
|
||||||
|
|
||||||
// in case of redirects, we must process the new URL
|
let connectionURL = processConnectionURL(
|
||||||
|
initial: url,
|
||||||
let connectionURL = processConnectionURL(initial: url, response: response.response.url)
|
response: response.response.url
|
||||||
|
)
|
||||||
|
|
||||||
let newServerState = ServerState(
|
let newServerState = ServerState(
|
||||||
urls: [connectionURL],
|
urls: [connectionURL],
|
||||||
currentURL: connectionURL,
|
currentURL: connectionURL,
|
||||||
name: name,
|
name: name,
|
||||||
id: id,
|
id: id,
|
||||||
os: os,
|
|
||||||
version: version,
|
|
||||||
usersIDs: []
|
usersIDs: []
|
||||||
)
|
)
|
||||||
|
|
||||||
return (newServerState, url)
|
return newServerState
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this probably isn't the best way to properly handle this, fix if necessary
|
// In the event of redirects, get the new host URL from response
|
||||||
private func processConnectionURL(initial url: URL, response: URL?) -> URL {
|
private func processConnectionURL(initial url: URL, response: URL?) -> URL {
|
||||||
|
|
||||||
guard let response else { return url }
|
guard let response else { return url }
|
||||||
|
@ -105,72 +195,48 @@ final class ConnectToServerViewModel: ViewModel {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDuplicate(server: ServerState) -> Bool {
|
private func isDuplicate(server: ServerState) -> Bool {
|
||||||
if let _ = try? SwiftfinStore.dataStack.fetchOne(
|
let existingServer = try? SwiftfinStore
|
||||||
From<SwiftfinStore.Models.StoredServer>(),
|
.dataStack
|
||||||
[Where<SwiftfinStore.Models.StoredServer>(
|
.fetchOne(From<ServerModel>().where(\.$id == server.id))
|
||||||
"id == %@",
|
return existingServer != nil
|
||||||
server.id
|
|
||||||
)]
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(server: ServerState) throws {
|
private func save(server: ServerState) async throws {
|
||||||
try SwiftfinStore.dataStack.perform { transaction in
|
try dataStack.perform { transaction in
|
||||||
let newServer = transaction.create(Into<SwiftfinStore.Models.StoredServer>())
|
let newServer = transaction.create(Into<ServerModel>())
|
||||||
|
|
||||||
newServer.urls = server.urls
|
newServer.urls = server.urls
|
||||||
newServer.currentURL = server.currentURL
|
newServer.currentURL = server.currentURL
|
||||||
newServer.name = server.name
|
newServer.name = server.name
|
||||||
newServer.id = server.id
|
newServer.id = server.id
|
||||||
newServer.os = server.os
|
|
||||||
newServer.version = server.version
|
|
||||||
newServer.users = []
|
newServer.users = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let publicInfo = try await server.getPublicSystemInfo()
|
||||||
|
|
||||||
|
StoredValues[.Server.publicInfo(id: server.id)] = publicInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoverServers() {
|
// server has same id, but (possible) new URL
|
||||||
isSearching = true
|
private func addNewURL(server: ServerState) {
|
||||||
discoveredServers.removeAll()
|
do {
|
||||||
|
let newState = try dataStack.perform { transaction in
|
||||||
|
let existingServer = try self.dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id))
|
||||||
|
guard let editServer = transaction.edit(existingServer) else {
|
||||||
|
logger.critical("Could not find server to add new url")
|
||||||
|
throw JellyfinAPIError("An internal error has occurred")
|
||||||
|
}
|
||||||
|
|
||||||
var _discoveredServers: Set<SwiftfinStore.State.Server> = []
|
editServer.urls.insert(server.currentURL)
|
||||||
|
editServer.currentURL = server.currentURL
|
||||||
|
|
||||||
discovery.locateServer { server in
|
return editServer.state
|
||||||
if let server = server {
|
|
||||||
_discoveredServers.insert(.init(
|
|
||||||
urls: [],
|
|
||||||
currentURL: server.url,
|
|
||||||
name: server.name,
|
|
||||||
id: server.id,
|
|
||||||
os: "",
|
|
||||||
version: "",
|
|
||||||
usersIDs: []
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout after 3 seconds
|
Notifications[.didChangeCurrentServerURL].post(object: newState)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
} catch {
|
||||||
self.isSearching = false
|
logger.critical("\(error.localizedDescription)")
|
||||||
self.discoveredServers = _discoveredServers.sorted(by: { $0.name < $1.name })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(url: URL, server: ServerState) {
|
|
||||||
try! SwiftfinStore.dataStack.perform { transaction in
|
|
||||||
let existingServer = try! SwiftfinStore.dataStack.fetchOne(
|
|
||||||
From<SwiftfinStore.Models.StoredServer>(),
|
|
||||||
[Where<SwiftfinStore.Models.StoredServer>(
|
|
||||||
"id == %@",
|
|
||||||
server.id
|
|
||||||
)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let editServer = transaction.edit(existingServer)!
|
|
||||||
editServer.urls.insert(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,24 +85,26 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
backgroundStates.append(.refresh)
|
backgroundStates.append(.refresh)
|
||||||
|
|
||||||
backgroundRefreshTask = Task { [weak self] in
|
backgroundRefreshTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
|
||||||
do {
|
do {
|
||||||
|
self?.nextUpViewModel.send(.refresh)
|
||||||
|
self?.recentlyAddedViewModel.send(.refresh)
|
||||||
|
|
||||||
nextUpViewModel.send(.refresh)
|
let resumeItems = try await self?.getResumeItems() ?? []
|
||||||
recentlyAddedViewModel.send(.refresh)
|
|
||||||
|
|
||||||
let resumeItems = try await getResumeItems()
|
|
||||||
|
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
self.resumeItems.elements = resumeItems
|
self.resumeItems.elements = resumeItems
|
||||||
self.backgroundStates.remove(.refresh)
|
self.backgroundStates.remove(.refresh)
|
||||||
}
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancelled
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
self.backgroundStates.remove(.refresh)
|
self.backgroundStates.remove(.refresh)
|
||||||
self.send(.error(.init(error.localizedDescription)))
|
self.send(.error(.init(error.localizedDescription)))
|
||||||
}
|
}
|
||||||
|
@ -127,20 +129,22 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
refreshTask?.cancel()
|
refreshTask?.cancel()
|
||||||
|
|
||||||
refreshTask = Task { [weak self] in
|
refreshTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
|
||||||
do {
|
do {
|
||||||
|
try await self?.refresh()
|
||||||
try await self.refresh()
|
|
||||||
|
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
self.state = .content
|
self.state = .content
|
||||||
}
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancelled
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
self.send(.error(.init(error.localizedDescription)))
|
self.send(.error(.init(error.localizedDescription)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import JellyfinAPI
|
||||||
final class NextUpLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
|
final class NextUpLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init(parent: TitledLibraryParent(displayTitle: L10n.nextUp))
|
super.init(parent: TitledLibraryParent(displayTitle: L10n.nextUp, id: "nextUp"))
|
||||||
}
|
}
|
||||||
|
|
||||||
override func get(page: Int) async throws -> [BaseItemDto] {
|
override func get(page: Int) async throws -> [BaseItemDto] {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import Get
|
import Get
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
@ -23,11 +24,19 @@ private let DefaultPageSize = 50
|
||||||
// on refresh. Should make bidirectional/offset index start?
|
// on refresh. Should make bidirectional/offset index start?
|
||||||
// - use startIndex/index ranges instead of pages
|
// - use startIndex/index ranges instead of pages
|
||||||
// - source of data doesn't guarantee that all items in 0 ..< startIndex exist
|
// - source of data doesn't guarantee that all items in 0 ..< startIndex exist
|
||||||
|
|
||||||
|
/*
|
||||||
|
Note: if `rememberSort == true`, then will override given filters with stored sorts
|
||||||
|
for parent ID. This was just easy. See `PagingLibraryView` notes for lack of
|
||||||
|
`rememberSort` observation and `StoredValues.User.libraryFilters` for TODO
|
||||||
|
on remembering other filters.
|
||||||
|
*/
|
||||||
|
|
||||||
class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
// MARK: Event
|
// MARK: Event
|
||||||
|
|
||||||
enum Event: Equatable {
|
enum Event {
|
||||||
case gotRandomItem(Element)
|
case gotRandomItem(Element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,9 +112,16 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
convenience init(
|
convenience init(
|
||||||
title: String,
|
title: String,
|
||||||
|
id: String?,
|
||||||
_ data: some Collection<Element>
|
_ data: some Collection<Element>
|
||||||
) {
|
) {
|
||||||
self.init(data, parent: TitledLibraryParent(displayTitle: title))
|
self.init(
|
||||||
|
data,
|
||||||
|
parent: TitledLibraryParent(
|
||||||
|
displayTitle: title,
|
||||||
|
id: id
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// paging
|
// paging
|
||||||
|
@ -120,6 +136,19 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
||||||
if let filters {
|
if let filters {
|
||||||
|
var filters = filters
|
||||||
|
|
||||||
|
if let id = parent?.id, Defaults[.Customization.Library.rememberSort] {
|
||||||
|
// TODO: see `StoredValues.User.libraryFilters` for TODO
|
||||||
|
// on remembering other filters
|
||||||
|
|
||||||
|
let storedFilters = StoredValues[.User.libraryFilters(parentID: id)]
|
||||||
|
|
||||||
|
filters = filters
|
||||||
|
.mutating(\.sortBy, with: storedFilters.sortBy)
|
||||||
|
.mutating(\.sortOrder, with: storedFilters.sortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
self.filterViewModel = .init(
|
self.filterViewModel = .init(
|
||||||
parent: parent,
|
parent: parent,
|
||||||
currentFilters: filters
|
currentFilters: filters
|
||||||
|
@ -148,11 +177,15 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
convenience init(
|
convenience init(
|
||||||
title: String,
|
title: String,
|
||||||
filters: ItemFilterCollection = .default,
|
id: String?,
|
||||||
|
filters: ItemFilterCollection? = nil,
|
||||||
pageSize: Int = DefaultPageSize
|
pageSize: Int = DefaultPageSize
|
||||||
) {
|
) {
|
||||||
self.init(
|
self.init(
|
||||||
parent: TitledLibraryParent(displayTitle: title),
|
parent: TitledLibraryParent(
|
||||||
|
displayTitle: title,
|
||||||
|
id: id
|
||||||
|
),
|
||||||
filters: filters,
|
filters: filters,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
)
|
)
|
||||||
|
|
|
@ -17,10 +17,11 @@ final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
|
||||||
// Necessary because this is paginated and also used on home view
|
// Necessary because this is paginated and also used on home view
|
||||||
init(customPageSize: Int? = nil) {
|
init(customPageSize: Int? = nil) {
|
||||||
|
|
||||||
|
// Why doesn't `super.init(title:id:pageSize)` init work?
|
||||||
if let customPageSize {
|
if let customPageSize {
|
||||||
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded), pageSize: customPageSize)
|
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded"), pageSize: customPageSize)
|
||||||
} else {
|
} else {
|
||||||
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded))
|
super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
class ServerDetailViewModel: ViewModel {
|
class EditServerViewModel: ViewModel {
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var server: ServerState
|
var server: ServerState
|
||||||
|
@ -19,36 +19,59 @@ class ServerDetailViewModel: ViewModel {
|
||||||
self.server = server
|
self.server = server
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCurrentServerURL(to url: URL) {
|
// TODO: this could probably be cleaner
|
||||||
|
func delete() {
|
||||||
|
|
||||||
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
|
guard let storedServer = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
|
||||||
From<ServerModel>(),
|
logger.critical("Unable to find server to delete")
|
||||||
[Where<ServerModel>("id == %@", server.id)]
|
|
||||||
) else {
|
|
||||||
logger.error("Unable to find server")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard storedServer.urls.contains(url) else {
|
let userStates = storedServer.users.map(\.state)
|
||||||
logger.error("Server did not have matching URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let transaction = SwiftfinStore.dataStack.beginUnsafe()
|
|
||||||
|
|
||||||
guard let editServer = transaction.edit(storedServer) else {
|
|
||||||
logger.error("Unable to create edit server instance")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
editServer.currentURL = url
|
|
||||||
|
|
||||||
|
// Note: don't use Server/UserState.delete() to have
|
||||||
|
// all deletions in a single transaction
|
||||||
do {
|
do {
|
||||||
try transaction.commitAndWait()
|
try dataStack.perform { transaction in
|
||||||
|
|
||||||
Notifications[.didChangeCurrentServerURL].post(object: editServer.state)
|
/// Delete stored data for all users
|
||||||
|
for user in storedServer.users {
|
||||||
|
let storedDataClause = AnyStoredData.fetchClause(ownerID: user.id)
|
||||||
|
let storedData = try transaction.fetchAll(storedDataClause)
|
||||||
|
|
||||||
|
transaction.delete(storedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.delete(storedServer.users)
|
||||||
|
transaction.delete(storedServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in userStates {
|
||||||
|
UserDefaults.userSuite(id: user.id).removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
Notifications[.didDeleteServer].post(object: server)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Unable to edit server")
|
logger.critical("Unable to delete server: \(server.name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentURL(to url: URL) {
|
||||||
|
do {
|
||||||
|
let newState = try dataStack.perform { transaction in
|
||||||
|
guard let storedServer = try transaction.fetchOne(From<ServerModel>().where(\.$id == self.server.id)) else {
|
||||||
|
throw JellyfinAPIError("Unable to find server for URL change: \(self.server.name)")
|
||||||
|
}
|
||||||
|
storedServer.currentURL = url
|
||||||
|
|
||||||
|
return storedServer.state
|
||||||
|
}
|
||||||
|
|
||||||
|
Notifications[.didChangeCurrentServerURL].post(object: newState)
|
||||||
|
|
||||||
|
self.server = newState
|
||||||
|
} catch {
|
||||||
|
logger.critical("\(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
@Published
|
||||||
var currentAppIcon: any AppIcon = PrimaryAppIcon.primary
|
var currentAppIcon: any AppIcon = PrimaryAppIcon.primary
|
||||||
|
@Published
|
||||||
|
var servers: [ServerState] = []
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
|
|
||||||
|
@ -28,35 +30,31 @@ final class SettingsViewModel: ViewModel {
|
||||||
|
|
||||||
if let appicon = PrimaryAppIcon.createCase(iconName: iconName) {
|
if let appicon = PrimaryAppIcon.createCase(iconName: iconName) {
|
||||||
currentAppIcon = appicon
|
currentAppIcon = appicon
|
||||||
super.init()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let appicon = DarkAppIcon.createCase(iconName: iconName) {
|
if let appicon = DarkAppIcon.createCase(iconName: iconName) {
|
||||||
currentAppIcon = appicon
|
currentAppIcon = appicon
|
||||||
super.init()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let appicon = InvertedDarkAppIcon.createCase(iconName: iconName) {
|
if let appicon = InvertedDarkAppIcon.createCase(iconName: iconName) {
|
||||||
currentAppIcon = appicon
|
currentAppIcon = appicon
|
||||||
super.init()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let appicon = InvertedLightAppIcon.createCase(iconName: iconName) {
|
if let appicon = InvertedLightAppIcon.createCase(iconName: iconName) {
|
||||||
currentAppIcon = appicon
|
currentAppIcon = appicon
|
||||||
super.init()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let appicon = LightAppIcon.createCase(iconName: iconName) {
|
if let appicon = LightAppIcon.createCase(iconName: iconName) {
|
||||||
currentAppIcon = appicon
|
currentAppIcon = appicon
|
||||||
super.init()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
|
do {
|
||||||
|
servers = try getServers()
|
||||||
|
} catch {
|
||||||
|
logger.critical("Could not retrieve servers")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(icon: any AppIcon) {
|
func select(icon: any AppIcon) {
|
||||||
|
@ -78,21 +76,17 @@ final class SettingsViewModel: ViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getServers() throws -> [ServerState] {
|
||||||
|
try SwiftfinStore
|
||||||
|
.dataStack
|
||||||
|
.fetchAll(From<ServerModel>())
|
||||||
|
.map(\.state)
|
||||||
|
.sorted(using: \.name)
|
||||||
|
}
|
||||||
|
|
||||||
func signOut() {
|
func signOut() {
|
||||||
Defaults[.lastServerUserID] = nil
|
Defaults[.lastSignedInUserID] = nil
|
||||||
Container.userSession.reset()
|
UserSession.current.reset()
|
||||||
Notifications[.didSignOut].post()
|
Notifications[.didSignOut].post()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetUserSettings() {
|
|
||||||
UserDefaults.generalSuite.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAllServers() {
|
|
||||||
guard let allServers = try? SwiftfinStore.dataStack.fetchAll(From<ServerModel>()) else { return }
|
|
||||||
|
|
||||||
try? SwiftfinStore.dataStack.perform { transaction in
|
|
||||||
transaction.delete(allServers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import CoreStore
|
import CoreStore
|
||||||
import Defaults
|
|
||||||
import Factory
|
import Factory
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Get
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import Pulse
|
import KeychainSwift
|
||||||
|
import OrderedCollections
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
final class UserSignInViewModel: ViewModel, Stateful {
|
// TODO: instead of just signing in duplicate user, send event for alert
|
||||||
|
// to override existing user access token?
|
||||||
|
// - won't require deleting and re-signing in user for password changes
|
||||||
|
// - account for local device auth required
|
||||||
|
// TODO: ignore NSURLErrorDomain Code=-999 cancelled error on sign in
|
||||||
|
// - need to make NSError wrappres anyways
|
||||||
|
|
||||||
|
// Note: UserDto in StoredValues so that it doesn't need to be passed
|
||||||
|
// around along with the user UserState. Was just easy
|
||||||
|
|
||||||
|
final class UserSignInViewModel: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
|
// MARK: Event
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
case duplicateUser(UserState)
|
||||||
|
case error(JellyfinAPIError)
|
||||||
|
case signedIn(UserState)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Action
|
// MARK: Action
|
||||||
|
|
||||||
enum Action: Equatable {
|
enum Action: Equatable {
|
||||||
case signInWithUserPass(username: String, password: String)
|
case getPublicData
|
||||||
case signInWithQuickConnect(authSecret: String)
|
case signIn(username: String, password: String, policy: UserAccessPolicy)
|
||||||
case cancelSignIn
|
case signInDuplicate(UserState, replace: Bool)
|
||||||
|
case signInQuickConnect(secret: String, policy: UserAccessPolicy)
|
||||||
|
case cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BackgroundState: Hashable {
|
||||||
|
case gettingPublicData
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: State
|
// MARK: State
|
||||||
|
@ -28,180 +55,299 @@ final class UserSignInViewModel: ViewModel, Stateful {
|
||||||
enum State: Hashable {
|
enum State: Hashable {
|
||||||
case initial
|
case initial
|
||||||
case signingIn
|
case signingIn
|
||||||
case signedIn
|
|
||||||
case error(SignInError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Add more detailed errors
|
|
||||||
enum SignInError: Error {
|
|
||||||
case unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var backgroundStates: OrderedSet<BackgroundState> = []
|
||||||
|
@Published
|
||||||
|
var isQuickConnectEnabled = false
|
||||||
|
@Published
|
||||||
|
var publicUsers: [UserDto] = []
|
||||||
|
@Published
|
||||||
|
var serverDisclaimer: String? = nil
|
||||||
@Published
|
@Published
|
||||||
var state: State = .initial
|
var state: State = .initial
|
||||||
var lastAction: Action? = nil
|
|
||||||
|
|
||||||
@Published
|
var events: AnyPublisher<Event, Never> {
|
||||||
private(set) var publicUsers: [UserDto] = []
|
eventSubject
|
||||||
@Published
|
.receive(on: RunLoop.main)
|
||||||
private(set) var quickConnectEnabled = false
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
private var signInTask: Task<Void, Never>?
|
let quickConnect: QuickConnect
|
||||||
|
let server: ServerState
|
||||||
|
|
||||||
let quickConnectViewModel: QuickConnectViewModel
|
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||||
|
private var signInTask: AnyCancellable?
|
||||||
let client: JellyfinClient
|
|
||||||
let server: SwiftfinStore.State.Server
|
|
||||||
|
|
||||||
init(server: ServerState) {
|
init(server: ServerState) {
|
||||||
self.client = JellyfinClient(
|
|
||||||
configuration: .swiftfinConfiguration(url: server.currentURL),
|
|
||||||
sessionDelegate: URLSessionProxyDelegate()
|
|
||||||
)
|
|
||||||
self.server = server
|
self.server = server
|
||||||
self.quickConnectViewModel = .init(client: client)
|
self.quickConnect = QuickConnect(client: server.client)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
|
quickConnect.$state
|
||||||
|
.sink { [weak self] state in
|
||||||
|
if case let QuickConnect.State.authenticated(secret: secret) = state {
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await self.send(.signInQuickConnect(secret: secret, policy: StoredValues[.Temp.userSignInPolicy]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func respond(to action: Action) -> State {
|
func respond(to action: Action) -> State {
|
||||||
switch action {
|
switch action {
|
||||||
case let .signInWithUserPass(username, password):
|
case .getPublicData:
|
||||||
guard state != .signingIn else { return .signingIn }
|
Task { [weak self] in
|
||||||
Task {
|
|
||||||
do {
|
do {
|
||||||
try await signIn(username: username, password: password)
|
|
||||||
|
await MainActor.run {
|
||||||
|
let _ = self?.backgroundStates.append(.gettingPublicData)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isQuickConnectEnabled = try await self?.retrieveQuickConnectEnabled()
|
||||||
|
let publicUsers = try await self?.retrievePublicUsers()
|
||||||
|
let serverMessage = try await self?.retrieveServerDisclaimer()
|
||||||
|
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.backgroundStates.remove(.gettingPublicData)
|
||||||
|
self.isQuickConnectEnabled = isQuickConnectEnabled ?? false
|
||||||
|
self.publicUsers = publicUsers ?? []
|
||||||
|
self.serverDisclaimer = serverMessage
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self?.backgroundStates.remove(.gettingPublicData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
return state
|
||||||
|
case let .signIn(username, password, policy):
|
||||||
|
signInTask?.cancel()
|
||||||
|
|
||||||
|
signInTask = Task {
|
||||||
|
do {
|
||||||
|
let user = try await signIn(username: username, password: password, policy: policy)
|
||||||
|
|
||||||
|
if isDuplicate(user: user) {
|
||||||
|
await MainActor.run {
|
||||||
|
// user has same id, but new access token
|
||||||
|
self.eventSubject.send(.duplicateUser(user))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try await save(user: user)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.eventSubject.send(.signedIn(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.state = .initial
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancel doesn't matter
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
state = .error(.unknown)
|
self.eventSubject.send(.error(.init(error.localizedDescription)))
|
||||||
|
self.state = .initial
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
|
||||||
return .signingIn
|
return .signingIn
|
||||||
case let .signInWithQuickConnect(authSecret):
|
case let .signInDuplicate(duplicateUser, replace):
|
||||||
guard state != .signingIn else { return .signingIn }
|
if replace {
|
||||||
Task {
|
setNewAccessToken(user: duplicateUser)
|
||||||
|
} else {
|
||||||
|
// just need the id, even though this has a different
|
||||||
|
// access token than stored
|
||||||
|
eventSubject.send(.signedIn(duplicateUser))
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
case let .signInQuickConnect(secret, policy):
|
||||||
|
signInTask?.cancel()
|
||||||
|
|
||||||
|
signInTask = Task {
|
||||||
do {
|
do {
|
||||||
try await signIn(quickConnectSecret: authSecret)
|
let user = try await signIn(secret: secret, policy: policy)
|
||||||
|
|
||||||
|
if isDuplicate(user: user) {
|
||||||
|
await MainActor.run {
|
||||||
|
// user has same id, but new access token
|
||||||
|
self.eventSubject.send(.duplicateUser(user))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try await save(user: user)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.eventSubject.send(.signedIn(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.state = .initial
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancel doesn't matter
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
state = .error(.unknown)
|
self.eventSubject.send(.error(.init(error.localizedDescription)))
|
||||||
|
self.state = .initial
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
|
||||||
return .signingIn
|
return .signingIn
|
||||||
case .cancelSignIn:
|
case .cancel:
|
||||||
self.signInTask?.cancel()
|
signInTask?.cancel()
|
||||||
|
|
||||||
return .initial
|
return .initial
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func signIn(username: String, password: String) async throws {
|
private func signIn(username: String, password: String, policy: UserAccessPolicy) async throws -> UserState {
|
||||||
let username = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
let username = username
|
||||||
.trimmingCharacters(in: .objectReplacement)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.trimmingCharacters(in: .objectReplacement)
|
.trimmingCharacters(in: .objectReplacement)
|
||||||
|
|
||||||
let response = try await client.signIn(username: username, password: password)
|
let password = password
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.trimmingCharacters(in: .objectReplacement)
|
||||||
|
|
||||||
let user: UserState
|
let response = try await server.client.signIn(username: username, password: password)
|
||||||
|
|
||||||
do {
|
guard let accessToken = response.accessToken,
|
||||||
user = try await createLocalUser(response: response)
|
let userData = response.user,
|
||||||
} catch {
|
let id = userData.id,
|
||||||
if case let SwiftfinStore.Error.existingUser(existingUser) = error {
|
let username = userData.name
|
||||||
user = existingUser
|
else {
|
||||||
} else {
|
logger.critical("Missing user data from network call")
|
||||||
throw error
|
throw JellyfinAPIError("An internal error has occurred")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Defaults[.lastServerUserID] = user.id
|
StoredValues[.Temp.userData] = userData
|
||||||
Container.userSession.reset()
|
StoredValues[.Temp.userSignInPolicy] = policy
|
||||||
Notifications[.didSignIn].post()
|
|
||||||
|
let newState = UserState(
|
||||||
|
id: id,
|
||||||
|
serverID: server.id,
|
||||||
|
username: username
|
||||||
|
)
|
||||||
|
|
||||||
|
newState.accessToken = accessToken
|
||||||
|
|
||||||
|
return newState
|
||||||
}
|
}
|
||||||
|
|
||||||
private func signIn(quickConnectSecret: String) async throws {
|
private func signIn(secret: String, policy: UserAccessPolicy) async throws -> UserState {
|
||||||
let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret))
|
|
||||||
let response = try await client.send(quickConnectPath)
|
|
||||||
|
|
||||||
let user: UserState
|
let response = try await server.client.signIn(quickConnectSecret: secret)
|
||||||
|
|
||||||
do {
|
guard let accessToken = response.accessToken,
|
||||||
user = try await createLocalUser(response: response.value)
|
let userData = response.user,
|
||||||
} catch {
|
let id = userData.id,
|
||||||
if case let SwiftfinStore.Error.existingUser(existingUser) = error {
|
let username = userData.name
|
||||||
user = existingUser
|
else {
|
||||||
} else {
|
logger.critical("Missing user data from network call")
|
||||||
throw error
|
throw JellyfinAPIError("An internal error has occurred")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Defaults[.lastServerUserID] = user.id
|
StoredValues[.Temp.userData] = userData
|
||||||
Container.userSession.reset()
|
StoredValues[.Temp.userSignInPolicy] = policy
|
||||||
Notifications[.didSignIn].post()
|
|
||||||
|
let newState = UserState(
|
||||||
|
id: id,
|
||||||
|
serverID: server.id,
|
||||||
|
username: username
|
||||||
|
)
|
||||||
|
|
||||||
|
newState.accessToken = accessToken
|
||||||
|
|
||||||
|
return newState
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPublicUsers() async throws {
|
private func isDuplicate(user: UserState) -> Bool {
|
||||||
let publicUsersPath = Paths.getPublicUsers
|
let existingUser = try? SwiftfinStore
|
||||||
let response = try await client.send(publicUsersPath)
|
.dataStack
|
||||||
|
.fetchOne(From<UserModel>().where(\.$id == user.id))
|
||||||
await MainActor.run {
|
return existingUser != nil
|
||||||
publicUsers = response.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func createLocalUser(response: AuthenticationResult) async throws -> UserState {
|
private func save(user: UserState) async throws {
|
||||||
guard let accessToken = response.accessToken,
|
|
||||||
let username = response.user?.name,
|
|
||||||
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
|
|
||||||
|
|
||||||
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
|
guard let serverModel = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
|
||||||
From<UserModel>(),
|
logger.critical("Unable to find server to save user")
|
||||||
[Where<UserModel>(
|
throw JellyfinAPIError("An internal error has occurred")
|
||||||
"id == %@",
|
|
||||||
id
|
|
||||||
)]
|
|
||||||
) {
|
|
||||||
throw SwiftfinStore.Error.existingUser(existingUser.state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
|
let user = try dataStack.perform { transaction in
|
||||||
From<SwiftfinStore.Models.StoredServer>(),
|
|
||||||
[
|
|
||||||
Where<SwiftfinStore.Models.StoredServer>(
|
|
||||||
"id == %@",
|
|
||||||
server.id
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else { fatalError("No stored server associated with given state server?") }
|
|
||||||
|
|
||||||
let user = try SwiftfinStore.dataStack.perform { transaction in
|
|
||||||
let newUser = transaction.create(Into<UserModel>())
|
let newUser = transaction.create(Into<UserModel>())
|
||||||
|
|
||||||
newUser.accessToken = accessToken
|
newUser.id = user.id
|
||||||
newUser.appleTVID = ""
|
newUser.username = user.username
|
||||||
newUser.id = id
|
|
||||||
newUser.username = username
|
|
||||||
|
|
||||||
let editServer = transaction.edit(storedServer)!
|
let editServer = transaction.edit(serverModel)!
|
||||||
editServer.users.insert(newUser)
|
editServer.users.insert(newUser)
|
||||||
|
|
||||||
return newUser.state
|
return newUser.state
|
||||||
}
|
}
|
||||||
|
|
||||||
return user
|
user.data = StoredValues[.Temp.userData]
|
||||||
|
user.signInPolicy = StoredValues[.Temp.userSignInPolicy]
|
||||||
|
|
||||||
|
keychain.set(StoredValues[.Temp.userLocalPin], forKey: "\(user.id)-pin")
|
||||||
|
user.pinHint = StoredValues[.Temp.userLocalPinHint]
|
||||||
|
|
||||||
|
// TODO: remove when implemented periodic cleanup elsewhere
|
||||||
|
StoredValues[.Temp.userSignInPolicy] = .none
|
||||||
|
StoredValues[.Temp.userLocalPin] = ""
|
||||||
|
StoredValues[.Temp.userLocalPinHint] = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkQuickConnect() async throws {
|
private func retrievePublicUsers() async throws -> [UserDto] {
|
||||||
let quickConnectEnabledPath = Paths.getEnabled
|
let request = Paths.getPublicUsers
|
||||||
let response = try await client.send(quickConnectEnabledPath)
|
let response = try await server.client.send(request)
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let isEnabled = try? decoder.decode(Bool.self, from: response.value)
|
|
||||||
|
|
||||||
await MainActor.run {
|
return response.value
|
||||||
quickConnectEnabled = isEnabled ?? false
|
}
|
||||||
|
|
||||||
|
private func retrieveServerDisclaimer() async throws -> String? {
|
||||||
|
let request = Paths.getBrandingOptions
|
||||||
|
let response = try await server.client.send(request)
|
||||||
|
|
||||||
|
guard let disclaimer = response.value.loginDisclaimer, disclaimer.isNotEmpty else { return nil }
|
||||||
|
|
||||||
|
return disclaimer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retrieveQuickConnectEnabled() async throws -> Bool {
|
||||||
|
let request = Paths.getEnabled
|
||||||
|
let response = try await server.client.send(request)
|
||||||
|
|
||||||
|
let isEnabled = try? JSONDecoder().decode(Bool.self, from: response.value)
|
||||||
|
return isEnabled ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// server has same id, but new access token
|
||||||
|
private func setNewAccessToken(user: UserState) {
|
||||||
|
do {
|
||||||
|
guard let existingUser = try dataStack.fetchOne(From<UserModel>().where(\.$id == user.id)) else { return }
|
||||||
|
existingUser.state.accessToken = user.accessToken
|
||||||
|
|
||||||
|
eventSubject.send(.signedIn(existingUser.state))
|
||||||
|
} catch {
|
||||||
|
logger.critical("\(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,8 +30,6 @@ class VideoPlayerViewModel: ViewModel {
|
||||||
|
|
||||||
var hlsPlaybackURL: URL {
|
var hlsPlaybackURL: URL {
|
||||||
|
|
||||||
let userSession = Container.userSession()
|
|
||||||
|
|
||||||
let parameters = Paths.GetMasterHlsVideoPlaylistParameters(
|
let parameters = Paths.GetMasterHlsVideoPlaylistParameters(
|
||||||
isStatic: true,
|
isStatic: true,
|
||||||
tag: mediaSource.eTag,
|
tag: mediaSource.eTag,
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue