From 74b8b286c79aff1ee43a563336fe4127f4cd83ba Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 14 May 2024 23:42:41 -0600 Subject: [PATCH] User/Server Sign In Redesign (#1045) --- Shared/Components/BulletedList.swift | 92 +++ .../PosterIndicators/WatchedIndicator.swift | 3 +- Shared/Components/SelectorView.swift | 9 +- .../Components/SystemImageContentView.swift | 54 +- Shared/Components/WrappedView.swift | 4 + ...tor.swift => AppSettingsCoordinator.swift} | 16 +- .../ConnectToServerCoodinator.swift | 30 - Shared/Coordinators/FilterCoordinator.swift | 2 +- Shared/Coordinators/HomeCoordinator.swift | 6 - Shared/Coordinators/ItemCoordinator.swift | 8 +- .../MainCoordinator/iOSMainCoordinator.swift | 92 ++- .../MainCoordinator/tvOSMainCoordinator.swift | 63 +- .../QuickConnectCoordinator.swift | 32 - .../Coordinators/SelectUserCoordinator.swift | 78 +++ .../Coordinators/ServerListCoordinator.swift | 43 -- Shared/Coordinators/SettingsCoordinator.swift | 67 +- Shared/Coordinators/UserListCoordinator.swift | 42 -- .../Coordinators/UserSignInCoordinator.swift | 35 +- Shared/Errors/NetworkError.swift | 2 + Shared/Extensions/Array.swift | 4 - Shared/Extensions/Color.swift | 9 +- Shared/Extensions/EdgeInsets.swift | 2 +- .../EnvironmentValue+Keys.swift | 12 + .../EnvironmentValue+Values.swift | 15 + Shared/Extensions/FormatStyle.swift | 24 + Shared/Extensions/HorizontalAlignment.swift | 9 +- .../BaseItemDto/BaseItemDto+Images.swift | 4 +- .../BaseItemDto+VideoPlayerViewModel.swift | 4 +- .../JellyfinAPI/BaseItemDto/BaseItemDto.swift | 23 +- .../BaseItemPerson+Poster.swift | 3 +- .../BaseItemPerson/BaseItemPerson.swift | 2 +- .../JellyfinAPI/JellyfinClient.swift | 29 +- ...aSourceInfo+ItemVideoPlayerViewModel.swift | 5 +- .../Extensions/JellyfinAPI/MediaStream.swift | 14 +- ...er.swift => SortOrder+ItemSortOrder.swift} | 0 Shared/Extensions/JellyfinAPI/UserDto.swift | 29 +- .../Extensions/NavigationCoordinatable.swift | 7 - Shared/Extensions/Sequence.swift | 4 + .../Set.swift} | 20 +- Shared/Extensions/String.swift | 1 + Shared/Extensions/UIDevice.swift | 4 + .../Extensions/URLSessionConfiguration.swift | 18 + .../Extensions/ViewExtensions/Backport.swift | 34 + .../OnReceiveNotificationModifier.swift | 6 +- .../Modifiers/OnSizeChangedModifier.swift | 20 + .../PaletteOverlayRenderingModifier.swift | 28 - .../ScrollIfLargerThanModifier.swift | 27 + .../Modifiers/ScrollViewOffsetModifier.swift | 2 +- .../ViewExtensions/ViewExtensions.swift | 58 +- Shared/Objects/CaseIterablePicker.swift | 59 +- .../Objects/ItemFilter/ItemFilterType.swift | 1 - Shared/Objects/LibraryDisplayType.swift | 2 +- .../LibraryParent/TitledLibraryParent.swift | 7 +- Shared/Objects/PosterDisplayType.swift | 11 +- .../Objects/SelectUserServerSelection.swift | 34 + Shared/Objects/Stateful.swift | 6 + Shared/Objects/Storable.swift | 16 + Shared/Objects/UserAccessPolicy.swift | 30 + Shared/ServerDiscovery/ServerDiscovery.swift | 87 +-- Shared/ServerDiscovery/ServerResponse.swift | 59 ++ Shared/Services/DownloadTask.swift | 4 +- Shared/Services/Keychain.swift | 19 + Shared/Services/NewSessionManager.swift | 126 ---- Shared/Services/SwiftfinDefaults.swift | 311 +++++---- Shared/Services/SwiftfinNotifications.swift | 8 +- Shared/Services/SwiftfinStore.swift | 230 ------- Shared/Services/UserSession.swift | 70 ++ .../StoredValue/StoredValue.swift | 204 ++++++ .../StoredValue/StoredValues+Server.swift | 58 ++ .../StoredValue/StoredValues+Temp.swift | 73 ++ .../StoredValue/StoredValues+User.swift | 136 ++++ .../SwiftfinStore+Mappings.swift | 52 ++ .../SwiftfinStore+ServerState.swift | 80 +++ Shared/SwiftfinStore/SwiftfinStore.swift | 77 +++ .../SwiftinStore+UserState.swift | 162 +++++ .../V1Schema/SwiftfinStore+V1.swift | 35 + .../V1Schema/V1ServerModel.swift | 47 ++ .../SwiftfinStore/V1Schema/V1UserModel.swift | 40 ++ .../V2Schema/SwiftfinStore+V2.swift | 29 + Shared/SwiftfinStore/V2Schema/V2AnyData.swift | 169 +++++ .../V2Schema/V2ServerModel.swift | 43 ++ .../SwiftfinStore/V2Schema/V2UserModel.swift | 37 + .../ViewModels/ConnectToServerViewModel.swift | 234 ++++--- Shared/ViewModels/HomeViewModel.swift | 20 +- .../NextUpLibraryViewModel.swift | 2 +- .../PagingLibraryViewModel.swift | 41 +- .../RecentlyAddedViewModel.swift | 5 +- .../QuickConnectAuthorizeViewModel.swift | 92 +++ .../QuickConnectSettingsViewModel.swift | 25 - Shared/ViewModels/QuickConnectViewModel.swift | 173 ----- .../ResetUserPasswordViewModel.swift | 86 +++ Shared/ViewModels/SelectUserViewModel.swift | 114 +++ Shared/ViewModels/ServerCheckViewModel.swift | 57 ++ Shared/ViewModels/ServerDetailViewModel.swift | 69 +- Shared/ViewModels/ServerListViewModel.swift | 82 --- Shared/ViewModels/SettingsViewModel.swift | 42 +- Shared/ViewModels/UserListViewModel.swift | 84 --- .../UserLocalSecurityViewModel.swift | 81 +++ Shared/ViewModels/UserSignInViewModel.swift | 378 ++++++---- Shared/ViewModels/VideoPlayerViewModel.swift | 2 - Shared/ViewModels/ViewModel.swift | 20 +- Swiftfin tvOS/App/SwiftfinApp.swift | 18 +- Swiftfin tvOS/Components/FullScreenMenu.swift | 51 ++ Swiftfin tvOS/Components/ListRowButton.swift | 37 + .../Components/PagingLibraryView.swift | 4 +- .../Components/UserProfileButton.swift | 64 -- Swiftfin tvOS/Views/AppLoadingView.swift | 29 + .../Views/BasicAppSettingsView.swift | 137 ++-- .../Components/WideChannelGridItem.swift | 3 +- Swiftfin tvOS/Views/ConnectToServerView.swift | 294 ++++---- .../Views/HomeView/HomeErrorView.swift | 79 ++- Swiftfin tvOS/Views/HomeView/HomeView.swift | 1 - .../Components/ActionButtonHStack.swift | 6 +- .../EpisodeSelector/EpisodeSelector.swift | 1 - Swiftfin tvOS/Views/ItemView/ItemView.swift | 1 - Swiftfin tvOS/Views/MediaView/MediaView.swift | 1 + Swiftfin tvOS/Views/QuickConnectView.swift | 116 ++-- Swiftfin tvOS/Views/SelectServerView.swift | 126 ++++ .../Components/AddUserButton.swift | 78 +++ .../Components/ServerSelectionMenu.swift | 75 ++ .../Components/UserGridButton.swift | 126 ++++ .../Views/SelectUserView/SelectUserView.swift | 342 +++++++++ Swiftfin tvOS/Views/ServerDetailView.swift | 56 +- Swiftfin tvOS/Views/ServerListView.swift | 136 ---- .../SettingsView/CustomizeViewsSettings.swift | 2 +- .../ExperimentalSettingsView.swift | 7 +- .../Views/SettingsView/SettingsView.swift | 4 +- Swiftfin tvOS/Views/UserListView.swift | 111 --- Swiftfin tvOS/Views/UserSignInView.swift | 173 ----- .../Components/PublicUserRow.swift | 80 +++ .../Views/UserSignInView/UserSignInView.swift | 212 ++++++ Swiftfin.xcodeproj/project.pbxproj | 633 +++++++++++++---- .../xcshareddata/swiftpm/Package.resolved | 25 +- .../App/SwiftfinApp+ValueObservation.swift | 122 ++++ Swiftfin/App/SwiftfinApp.swift | 35 +- Swiftfin/Components/ErrorView.swift | 20 +- Swiftfin/Components/HourMinutePicker.swift | 51 ++ Swiftfin/Components/ListRowButton.swift | 45 ++ Swiftfin/Components/PrimaryButton.swift | 2 +- Swiftfin/Components/SettingsBarButton.swift | 55 ++ Swiftfin/Components/UnmaskSecureField.swift | 51 +- Swiftfin/Components/UserProfileButton.swift | 63 -- Swiftfin/Extensions/Label-iOS.swift | 18 - .../Modifiers/NavigationBarCloseButton.swift | 37 + Swiftfin/Extensions/View/View-iOS.swift | 28 +- Swiftfin/Resources/Info.plist | 4 +- Swiftfin/Views/AboutAppView.swift | 23 +- Swiftfin/Views/AppIconSelectorView.swift | 9 +- Swiftfin/Views/AppLoadingView.swift | 39 ++ .../AppSettingsView/AppSettingsView.swift | 100 +++ .../Components/SignOutIntervalSection.swift | 71 ++ Swiftfin/Views/BasicAppSettingsView.swift | 95 --- .../Components/CompactChannelView.swift | 3 +- .../Components/DetailedChannelView.swift | 3 +- Swiftfin/Views/ConnectToServerView.swift | 262 ++++--- Swiftfin/Views/FilterView.swift | 4 +- Swiftfin/Views/HomeView/HomeView.swift | 17 +- Swiftfin/Views/ItemOverviewView.swift | 5 +- .../Components/AboutView/AboutView.swift | 5 +- .../AboutView/Components/RatingsCard.swift | 3 - .../Components/EpisodeCard.swift | 3 +- .../ItemView/Components/GenresHStack.swift | 6 +- Swiftfin/Views/ItemView/ItemView.swift | 2 - .../CollectionItemContentView.swift | 1 + .../iOS/ScrollViews/CinematicScrollView.swift | 8 - .../iPadOSMovieItemContentView.swift | 3 - .../iPadOSCinematicScrollView.swift | 3 - .../iPadOSSeriesItemContentView.swift | 3 - Swiftfin/Views/MediaView/MediaView.swift | 8 +- .../Components/LibraryViewTypeToggle.swift | 4 + .../PagingLibraryView/PagingLibraryView.swift | 206 +++++- Swiftfin/Views/QuickConnectView.swift | 91 ++- Swiftfin/Views/SearchView.swift | 6 +- .../Components/AddUserButton.swift | 110 +++ .../Components/AddUserRow.swift | 115 ++++ .../Components/ServerSelectionMenu.swift | 103 +++ .../Components/UserGridButton.swift | 133 ++++ .../SelectUserView/Components/UserRow.swift | 170 +++++ .../Views/SelectUserView/SelectUserView.swift | 649 ++++++++++++++++++ Swiftfin/Views/ServerCheckView.swift | 77 +++ Swiftfin/Views/ServerDetailView.swift | 54 +- Swiftfin/Views/ServerListView.swift | 131 ---- .../SettingsView/CustomizeViewsSettings.swift | 42 +- .../ExperimentalSettingsView.swift | 2 - .../QuickConnectSettingsView.swift | 68 -- .../Components/UserProfileRow.swift | 56 ++ .../{ => SettingsView}/SettingsView.swift | 75 +- .../QuickConnectAuthorizeView.swift | 114 +++ .../ResetUserPasswordView.swift | 152 ++++ .../UserLocalSecurityView.swift | 278 ++++++++ .../UserProfileSettingsView.swift | 85 +++ Swiftfin/Views/UserListView.swift | 88 --- .../Components/PublicUserRow.swift | 91 +++ .../Components/PublicUserSignInView.swift | 47 -- .../Components/UserSignInSecurityView.swift | 115 ++++ .../Views/UserSignInView/UserSignInView.swift | 403 +++++++++-- .../Components/LiveBottomBarView.swift | 2 +- .../Overlays/Components/BottomBarView.swift | 2 +- 198 files changed, 9106 insertions(+), 3622 deletions(-) create mode 100644 Shared/Components/BulletedList.swift rename Shared/Coordinators/{BasicAppSettingsCoordinator.swift => AppSettingsCoordinator.swift} (70%) delete mode 100644 Shared/Coordinators/ConnectToServerCoodinator.swift delete mode 100644 Shared/Coordinators/QuickConnectCoordinator.swift create mode 100644 Shared/Coordinators/SelectUserCoordinator.swift delete mode 100644 Shared/Coordinators/ServerListCoordinator.swift delete mode 100644 Shared/Coordinators/UserListCoordinator.swift create mode 100644 Shared/Extensions/FormatStyle.swift rename Shared/Extensions/JellyfinAPI/{SortOrder.swift => SortOrder+ItemSortOrder.swift} (100%) rename Shared/{Errors/ErrorMessage.swift => Extensions/Set.swift} (50%) create mode 100644 Shared/Extensions/URLSessionConfiguration.swift delete mode 100644 Shared/Extensions/ViewExtensions/Modifiers/PaletteOverlayRenderingModifier.swift create mode 100644 Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanModifier.swift create mode 100644 Shared/Objects/SelectUserServerSelection.swift create mode 100644 Shared/Objects/Storable.swift create mode 100644 Shared/Objects/UserAccessPolicy.swift create mode 100644 Shared/ServerDiscovery/ServerResponse.swift create mode 100644 Shared/Services/Keychain.swift delete mode 100644 Shared/Services/NewSessionManager.swift delete mode 100644 Shared/Services/SwiftfinStore.swift create mode 100644 Shared/Services/UserSession.swift create mode 100644 Shared/SwiftfinStore/StoredValue/StoredValue.swift create mode 100644 Shared/SwiftfinStore/StoredValue/StoredValues+Server.swift create mode 100644 Shared/SwiftfinStore/StoredValue/StoredValues+Temp.swift create mode 100644 Shared/SwiftfinStore/StoredValue/StoredValues+User.swift create mode 100644 Shared/SwiftfinStore/SwiftfinStore+Mappings.swift create mode 100644 Shared/SwiftfinStore/SwiftfinStore+ServerState.swift create mode 100644 Shared/SwiftfinStore/SwiftfinStore.swift create mode 100644 Shared/SwiftfinStore/SwiftinStore+UserState.swift create mode 100644 Shared/SwiftfinStore/V1Schema/SwiftfinStore+V1.swift create mode 100644 Shared/SwiftfinStore/V1Schema/V1ServerModel.swift create mode 100644 Shared/SwiftfinStore/V1Schema/V1UserModel.swift create mode 100644 Shared/SwiftfinStore/V2Schema/SwiftfinStore+V2.swift create mode 100644 Shared/SwiftfinStore/V2Schema/V2AnyData.swift create mode 100644 Shared/SwiftfinStore/V2Schema/V2ServerModel.swift create mode 100644 Shared/SwiftfinStore/V2Schema/V2UserModel.swift create mode 100644 Shared/ViewModels/QuickConnectAuthorizeViewModel.swift delete mode 100644 Shared/ViewModels/QuickConnectSettingsViewModel.swift delete mode 100644 Shared/ViewModels/QuickConnectViewModel.swift create mode 100644 Shared/ViewModels/ResetUserPasswordViewModel.swift create mode 100644 Shared/ViewModels/SelectUserViewModel.swift create mode 100644 Shared/ViewModels/ServerCheckViewModel.swift delete mode 100644 Shared/ViewModels/ServerListViewModel.swift delete mode 100644 Shared/ViewModels/UserListViewModel.swift create mode 100644 Shared/ViewModels/UserLocalSecurityViewModel.swift create mode 100644 Swiftfin tvOS/Components/FullScreenMenu.swift create mode 100644 Swiftfin tvOS/Components/ListRowButton.swift delete mode 100644 Swiftfin tvOS/Components/UserProfileButton.swift create mode 100644 Swiftfin tvOS/Views/AppLoadingView.swift create mode 100644 Swiftfin tvOS/Views/SelectServerView.swift create mode 100644 Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift create mode 100644 Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift create mode 100644 Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift create mode 100644 Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift delete mode 100644 Swiftfin tvOS/Views/ServerListView.swift delete mode 100644 Swiftfin tvOS/Views/UserListView.swift delete mode 100644 Swiftfin tvOS/Views/UserSignInView.swift create mode 100644 Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift create mode 100644 Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift create mode 100644 Swiftfin/App/SwiftfinApp+ValueObservation.swift create mode 100644 Swiftfin/Components/HourMinutePicker.swift create mode 100644 Swiftfin/Components/ListRowButton.swift create mode 100644 Swiftfin/Components/SettingsBarButton.swift delete mode 100644 Swiftfin/Components/UserProfileButton.swift create mode 100644 Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift create mode 100644 Swiftfin/Views/AppLoadingView.swift create mode 100644 Swiftfin/Views/AppSettingsView/AppSettingsView.swift create mode 100644 Swiftfin/Views/AppSettingsView/Components/SignOutIntervalSection.swift delete mode 100644 Swiftfin/Views/BasicAppSettingsView.swift create mode 100644 Swiftfin/Views/SelectUserView/Components/AddUserButton.swift create mode 100644 Swiftfin/Views/SelectUserView/Components/AddUserRow.swift create mode 100644 Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift create mode 100644 Swiftfin/Views/SelectUserView/Components/UserGridButton.swift create mode 100644 Swiftfin/Views/SelectUserView/Components/UserRow.swift create mode 100644 Swiftfin/Views/SelectUserView/SelectUserView.swift create mode 100644 Swiftfin/Views/ServerCheckView.swift delete mode 100644 Swiftfin/Views/ServerListView.swift delete mode 100644 Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift create mode 100644 Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift rename Swiftfin/Views/SettingsView/{ => SettingsView}/SettingsView.swift (61%) create mode 100644 Swiftfin/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift create mode 100644 Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift create mode 100644 Swiftfin/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift create mode 100644 Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift delete mode 100644 Swiftfin/Views/UserListView.swift create mode 100644 Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift delete mode 100644 Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift create mode 100644 Swiftfin/Views/UserSignInView/Components/UserSignInSecurityView.swift diff --git a/Shared/Components/BulletedList.swift b/Shared/Components/BulletedList.swift new file mode 100644 index 00000000..8a760a08 --- /dev/null +++ b/Shared/Components/BulletedList.swift @@ -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: 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: 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) + } + } + } + } +} diff --git a/Shared/Components/PosterIndicators/WatchedIndicator.swift b/Shared/Components/PosterIndicators/WatchedIndicator.swift index 46b83c34..3d4b3d9d 100644 --- a/Shared/Components/PosterIndicators/WatchedIndicator.swift +++ b/Shared/Components/PosterIndicators/WatchedIndicator.swift @@ -19,7 +19,8 @@ struct WatchedIndicator: View { Image(systemName: "checkmark.circle.fill") .resizable() .frame(width: size, height: size) - .paletteOverlayRendering(color: .white) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.jellyfinPurple) .padding(3) } } diff --git a/Shared/Components/SelectorView.swift b/Shared/Components/SelectorView.swift index 18633007..fe4fe582 100644 --- a/Shared/Components/SelectorView.swift +++ b/Shared/Components/SelectorView.swift @@ -47,9 +47,12 @@ struct SelectorView: View { if selection.contains(element) { Image(systemName: "checkmark.circle.fill") .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .paletteOverlayRendering() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) } } } diff --git a/Shared/Components/SystemImageContentView.swift b/Shared/Components/SystemImageContentView.swift index 0662ad89..cd9ff06a 100644 --- a/Shared/Components/SystemImageContentView.swift +++ b/Shared/Components/SystemImageContentView.swift @@ -8,8 +8,39 @@ import SwiftUI -// TODO: is the background color setting really the best way? +// TODO: bottom view can probably just be cleaned up and change +// usages to use local background views +struct RelativeSystemImageView: View { + + @State + private var contentSize: CGSize = .zero + + private let systemName: String + private let ratio: CGFloat + + init( + systemName: String, + ratio: CGFloat = 0.5 + ) { + self.systemName = systemName + self.ratio = ratio + } + + var body: some View { + AlternateLayoutView { + Color.clear + .trackingSize($contentSize) + } content: { + Image(systemName: systemName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: contentSize.width * ratio, height: contentSize.height * ratio) + } + } +} + +// TODO: cleanup and become the failure view for poster buttons struct SystemImageContentView: View { @State @@ -18,17 +49,15 @@ struct SystemImageContentView: View { private var labelSize: CGSize = .zero private var backgroundColor: Color - private var heightRatio: CGFloat + private var ratio: CGFloat private let systemName: String private let title: String? - private var widthRatio: CGFloat - init(title: String? = nil, systemName: String?) { + init(title: String? = nil, systemName: String?, ratio: CGFloat = 0.3) { self.backgroundColor = Color.secondarySystemFill - self.heightRatio = 3 + self.ratio = ratio self.systemName = systemName ?? "circle" self.title = title - self.widthRatio = 3.5 } private var imageView: some View { @@ -36,8 +65,7 @@ struct SystemImageContentView: View { .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.secondary) - .accessibilityHidden(true) - .frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio) + .frame(width: contentSize.width * ratio, height: contentSize.height * ratio) } @ViewBuilder @@ -47,7 +75,7 @@ struct SystemImageContentView: View { .lineLimit(2) .multilineTextAlignment(.center) .font(.footnote.weight(.regular)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .trackingSize($labelSize) } } @@ -55,7 +83,6 @@ struct SystemImageContentView: View { var body: some View { ZStack { backgroundColor - .opacity(0.5) imageView .frame(width: contentSize.width) @@ -71,12 +98,7 @@ struct SystemImageContentView: View { extension SystemImageContentView { - func background(color: Color = Color.secondarySystemFill) -> Self { + func background(color: Color) -> Self { copy(modifying: \.backgroundColor, with: color) } - - func imageFrameRatio(width: CGFloat = 3.5, height: CGFloat = 3) -> Self { - copy(modifying: \.heightRatio, with: height) - .copy(modifying: \.widthRatio, with: width) - } } diff --git a/Shared/Components/WrappedView.swift b/Shared/Components/WrappedView.swift index bb9c2d90..eaca6e7e 100644 --- a/Shared/Components/WrappedView.swift +++ b/Shared/Components/WrappedView.swift @@ -8,6 +8,10 @@ import SwiftUI +// TODO: mainly used as a view to hold views for states +// but doesn't work with animations/transitions. +// Look at alternative with just ZStack and remove + struct WrappedView: View { @ViewBuilder diff --git a/Shared/Coordinators/BasicAppSettingsCoordinator.swift b/Shared/Coordinators/AppSettingsCoordinator.swift similarity index 70% rename from Shared/Coordinators/BasicAppSettingsCoordinator.swift rename to Shared/Coordinators/AppSettingsCoordinator.swift index 0bb65b1f..8cc419a7 100644 --- a/Shared/Coordinators/BasicAppSettingsCoordinator.swift +++ b/Shared/Coordinators/AppSettingsCoordinator.swift @@ -10,9 +10,9 @@ import PulseUI import Stinsen import SwiftUI -final class BasicAppSettingsCoordinator: NavigationCoordinatable { +final class AppSettingsCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start) + let stack = NavigationStack(initial: \AppSettingsCoordinator.start) @Root var start = makeStart @@ -31,20 +31,16 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable { var log = makeLog #endif - private let viewModel: SettingsViewModel - - init() { - viewModel = .init() - } + init() {} #if os(iOS) @ViewBuilder - func makeAbout() -> some View { + func makeAbout(viewModel: SettingsViewModel) -> some View { AboutAppView(viewModel: viewModel) } @ViewBuilder - func makeAppIconSelector() -> some View { + func makeAppIconSelector(viewModel: SettingsViewModel) -> some View { AppIconSelectorView(viewModel: viewModel) } #endif @@ -56,6 +52,6 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { - BasicAppSettingsView(viewModel: viewModel) + AppSettingsView() } } diff --git a/Shared/Coordinators/ConnectToServerCoodinator.swift b/Shared/Coordinators/ConnectToServerCoodinator.swift deleted file mode 100644 index 72d175c2..00000000 --- a/Shared/Coordinators/ConnectToServerCoodinator.swift +++ /dev/null @@ -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()) - } -} diff --git a/Shared/Coordinators/FilterCoordinator.swift b/Shared/Coordinators/FilterCoordinator.swift index 9855be32..be8c8fae 100644 --- a/Shared/Coordinators/FilterCoordinator.swift +++ b/Shared/Coordinators/FilterCoordinator.swift @@ -32,7 +32,7 @@ final class FilterCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { #if os(tvOS) - Text(verbatim: .emptyDash) + AssertionFailureView("Not implemented") #else FilterView(viewModel: parameters.viewModel, type: parameters.type) #endif diff --git a/Shared/Coordinators/HomeCoordinator.swift b/Shared/Coordinators/HomeCoordinator.swift index 337dd2be..d4fc91f4 100644 --- a/Shared/Coordinators/HomeCoordinator.swift +++ b/Shared/Coordinators/HomeCoordinator.swift @@ -17,8 +17,6 @@ final class HomeCoordinator: NavigationCoordinatable { @Root var start = makeStart - @Route(.modal) - var settings = makeSettings #if os(tvOS) @Route(.modal) @@ -32,10 +30,6 @@ final class HomeCoordinator: NavigationCoordinatable { var library = makeLibrary #endif - func makeSettings() -> NavigationViewCoordinator { - NavigationViewCoordinator(SettingsCoordinator()) - } - #if os(tvOS) func makeItem(item: BaseItemDto) -> NavigationViewCoordinator { NavigationViewCoordinator(ItemCoordinator(item: item)) diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index 6199e440..fc73e711 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -57,7 +57,13 @@ final class ItemCoordinator: NavigationCoordinatable { } func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator { - 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) } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index 6a33e3a2..f968fb19 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -14,7 +14,10 @@ import JellyfinAPI import Nuke import Stinsen import SwiftUI -import WidgetKit + +// TODO: could possibly clean up +// - only go to loading if migrations necessary +// - account for other migrations (Defaults) final class MainCoordinator: NavigationCoordinatable { @@ -23,30 +26,55 @@ final class MainCoordinator: NavigationCoordinatable { var stack: Stinsen.NavigationStack + @Root + var loading = makeLoading @Root var mainTab = makeMainTab @Root - var serverList = makeServerList - @Route(.fullScreen) - var videoPlayer = makeVideoPlayer + var selectUser = makeSelectUser + @Root + var serverCheck = makeServerCheck + @Route(.fullScreen) var liveVideoPlayer = makeLiveVideoPlayer - - private var cancellables = Set() + @Route(.modal) + var settings = makeSettings + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer init() { - if Container.userSession().authenticated { - stack = NavigationStack(initial: \MainCoordinator.mainTab) - } else { - stack = NavigationStack(initial: \MainCoordinator.serverList) + stack = NavigationStack(initial: \.loading) + + Task { + do { + try await SwiftfinStore.setupDataStack() + + if UserSession.current() != nil, !Defaults[.signOutOnClose] { + await MainActor.run { + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.serverCheck) + } + } + } else { + await MainActor.run { + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.selectUser) + } + } + } + + } catch { + await MainActor.run { + logger.critical("\(error.localizedDescription)") + Notifications[.didFailMigration].post() + } + } } - ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory - DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk + // TODO: move these to the App instead? - WidgetCenter.shared.reloadAllTimelines() - UIScrollView.appearance().keyboardDismissMode = .onDrag + ImageCache.shared.costLimit = 1000 * 1024 * 1024 // 125MB // Notification setup for state Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) @@ -55,16 +83,24 @@ final class MainCoordinator: NavigationCoordinatable { Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:))) } + private func didFinishMigration() {} + @objc func didSignIn() { logger.info("Signed in") - root(\.mainTab) + + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.serverCheck) + } } @objc func didSignOut() { logger.info("Signed out") - root(\.serverList) + + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.selectUser) + } } @objc @@ -84,18 +120,34 @@ final class MainCoordinator: NavigationCoordinatable { @objc func didChangeCurrentServerURL(_ notification: Notification) { - guard Container.userSession().authenticated else { return } + guard UserSession.current() != nil else { return } - Container.userSession.reset() + UserSession.current.reset() Notifications[.didSignIn].post() } + func makeLoading() -> NavigationViewCoordinator { + NavigationViewCoordinator { + AppLoadingView() + } + } + + func makeSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(SettingsCoordinator()) + } + func makeMainTab() -> MainTabCoordinator { MainTabCoordinator() } - func makeServerList() -> NavigationViewCoordinator { - NavigationViewCoordinator(ServerListCoordinator()) + func makeSelectUser() -> NavigationViewCoordinator { + NavigationViewCoordinator(SelectUserCoordinator()) + } + + func makeServerCheck() -> NavigationViewCoordinator { + NavigationViewCoordinator { + ServerCheckView() + } } func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator { diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 2dc441b8..63c29115 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -12,6 +12,10 @@ import Nuke import Stinsen import SwiftUI +// TODO: clean up like iOS +// - move some things to App +// TODO: server check flow + final class MainCoordinator: NavigationCoordinatable { @Injected(LogManager.service) @@ -19,21 +23,44 @@ final class MainCoordinator: NavigationCoordinatable { var stack: Stinsen.NavigationStack + @Root + var loading = makeLoading @Root var mainTab = makeMainTab @Root - var serverList = makeServerList + var selectUser = makeSelectUser init() { - if Container.userSession().authenticated { - stack = NavigationStack(initial: \MainCoordinator.mainTab) - } else { - stack = NavigationStack(initial: \MainCoordinator.serverList) + stack = NavigationStack(initial: \.loading) + + Task { + do { + try await SwiftfinStore.setupDataStack() + + if UserSession.current() != nil { + await MainActor.run { + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.mainTab) + } + } + } else { + await MainActor.run { + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.selectUser) + } + } + } + + } catch { + await MainActor.run { + logger.critical("\(error.localizedDescription)") + Notifications[.didFailMigration].post() + } + } } ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory - DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] @@ -44,21 +71,33 @@ final class MainCoordinator: NavigationCoordinatable { @objc func didSignIn() { - logger.info("Received `didSignIn` from NSNotificationCenter.") - root(\.mainTab) + logger.info("Signed in") + + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.mainTab) + } } @objc func didSignOut() { - logger.info("Received `didSignOut` from NSNotificationCenter.") - root(\.serverList) + logger.info("Signed out") + + withAnimation(.linear(duration: 0.1)) { + let _ = root(\.selectUser) + } + } + + func makeLoading() -> NavigationViewCoordinator { + NavigationViewCoordinator { + AppLoadingView() + } } func makeMainTab() -> MainTabCoordinator { MainTabCoordinator() } - func makeServerList() -> NavigationViewCoordinator { - NavigationViewCoordinator(ServerListCoordinator()) + func makeSelectUser() -> NavigationViewCoordinator { + NavigationViewCoordinator(SelectUserCoordinator()) } } diff --git a/Shared/Coordinators/QuickConnectCoordinator.swift b/Shared/Coordinators/QuickConnectCoordinator.swift deleted file mode 100644 index ad1eccc4..00000000 --- a/Shared/Coordinators/QuickConnectCoordinator.swift +++ /dev/null @@ -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)) - }) - } -} diff --git a/Shared/Coordinators/SelectUserCoordinator.swift b/Shared/Coordinators/SelectUserCoordinator.swift new file mode 100644 index 00000000..c2fecfbe --- /dev/null +++ b/Shared/Coordinators/SelectUserCoordinator.swift @@ -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 + 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 { + NavigationViewCoordinator(AppSettingsCoordinator()) + } + + func makeConnectToServer() -> NavigationViewCoordinator { + NavigationViewCoordinator { + ConnectToServerView() + } + } + + func makeEditServer(server: ServerState) -> NavigationViewCoordinator { + NavigationViewCoordinator { + EditServerView(server: server) + .environment(\.isEditing, true) + #if os(iOS) + .navigationBarCloseButton { + self.popLast() + } + #endif + } + } + + func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator { + 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() + } +} diff --git a/Shared/Coordinators/ServerListCoordinator.swift b/Shared/Coordinators/ServerListCoordinator.swift deleted file mode 100644 index e16ac17a..00000000 --- a/Shared/Coordinators/ServerListCoordinator.swift +++ /dev/null @@ -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 { - NavigationViewCoordinator(BasicAppSettingsCoordinator()) - } - - @ViewBuilder - func makeStart() -> some View { - ServerListView(viewModel: ServerListViewModel()) - } -} diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index c10097c5..0168dd9b 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -19,15 +19,17 @@ final class SettingsCoordinator: NavigationCoordinatable { #if os(iOS) @Route(.push) - var about = makeAbout - @Route(.push) - var appIconSelector = makeAppIconSelector - @Route(.push) var log = makeLog @Route(.push) var nativePlayerSettings = makeNativePlayerSettings @Route(.push) - var quickConnect = makeQuickConnectSettings + var quickConnect = makeQuickConnectAuthorize + @Route(.push) + var resetUserPassword = makeResetUserPassword + @Route(.push) + var localSecurity = makeLocalSecurity + @Route(.push) + var userProfile = makeUserProfileSettings @Route(.push) var customizeViewsSettings = makeCustomizeViewsSettings @@ -63,31 +65,30 @@ final class SettingsCoordinator: NavigationCoordinatable { var videoPlayerSettings = makeVideoPlayerSettings #endif - private let viewModel: SettingsViewModel - - init() { - viewModel = .init() - } - #if os(iOS) - @ViewBuilder - func makeAbout() -> some View { - AboutAppView(viewModel: viewModel) - } - - @ViewBuilder - func makeAppIconSelector() -> some View { - AppIconSelectorView(viewModel: viewModel) - } - @ViewBuilder func makeNativePlayerSettings() -> some View { NativeVideoPlayerSettingsView() } @ViewBuilder - func makeQuickConnectSettings() -> some View { - QuickConnectSettingsView(viewModel: .init()) + func makeQuickConnectAuthorize() -> some View { + QuickConnectAuthorizeView() + } + + @ViewBuilder + func makeResetUserPassword() -> some View { + ResetUserPasswordView() + } + + @ViewBuilder + func makeLocalSecurity() -> some View { + UserLocalSecurityView() + } + + @ViewBuilder + func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View { + UserProfileSettingsView(viewModel: viewModel) } @ViewBuilder @@ -107,7 +108,7 @@ final class SettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeServerDetail(server: ServerState) -> some View { - ServerDetailView(server: server) + EditServerView(server: server) } #if DEBUG @@ -145,19 +146,15 @@ final class SettingsCoordinator: NavigationCoordinatable { } func makeIndicatorSettings() -> NavigationViewCoordinator { - NavigationViewCoordinator( - BasicNavigationViewCoordinator { - IndicatorSettingsView() - } - ) + NavigationViewCoordinator { + IndicatorSettingsView() + } } func makeServerDetail(server: ServerState) -> NavigationViewCoordinator { - NavigationViewCoordinator( - BasicNavigationViewCoordinator { - ServerDetailView(server: server) - } - ) + NavigationViewCoordinator { + EditServerView(server: server) + } } func makeVideoPlayerSettings() -> NavigationViewCoordinator { @@ -172,6 +169,6 @@ final class SettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { - SettingsView(viewModel: viewModel) + SettingsView() } } diff --git a/Shared/Coordinators/UserListCoordinator.swift b/Shared/Coordinators/UserListCoordinator.swift deleted file mode 100644 index 31b339a1..00000000 --- a/Shared/Coordinators/UserListCoordinator.swift +++ /dev/null @@ -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) - } -} diff --git a/Shared/Coordinators/UserSignInCoordinator.swift b/Shared/Coordinators/UserSignInCoordinator.swift index 740f594c..52264128 100644 --- a/Shared/Coordinators/UserSignInCoordinator.swift +++ b/Shared/Coordinators/UserSignInCoordinator.swift @@ -7,34 +7,55 @@ // import Foundation +import JellyfinAPI import Stinsen import SwiftUI final class UserSignInCoordinator: NavigationCoordinatable { + struct SecurityParameters { + let pinHint: Binding + let signInPolicy: Binding + } + let stack = NavigationStack(initial: \UserSignInCoordinator.start) @Root var start = makeStart - #if os(iOS) + @Route(.modal) var quickConnect = makeQuickConnect + + #if os(iOS) + @Route(.modal) + var security = makeSecurity #endif - let viewModel: UserSignInViewModel + private let server: ServerState - init(viewModel: UserSignInViewModel) { - self.viewModel = viewModel + init(server: ServerState) { + self.server = server + } + + func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator { + NavigationViewCoordinator { + QuickConnectView(quickConnect: quickConnect) + } } #if os(iOS) - func makeQuickConnect() -> NavigationViewCoordinator { - NavigationViewCoordinator(QuickConnectCoordinator(viewModel: viewModel)) + func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator { + NavigationViewCoordinator { + UserSignInView.SecurityView( + pinHint: parameters.pinHint, + signInPolicy: parameters.signInPolicy + ) + } } #endif @ViewBuilder func makeStart() -> some View { - UserSignInView(viewModel: viewModel) + UserSignInView(server: server) } } diff --git a/Shared/Errors/NetworkError.swift b/Shared/Errors/NetworkError.swift index 5b53a7f1..8644e452 100644 --- a/Shared/Errors/NetworkError.swift +++ b/Shared/Errors/NetworkError.swift @@ -9,6 +9,8 @@ import Foundation import JellyfinAPI +// This is only kept as reference until more strongly-typed errors are implemented. + // enum NetworkError: Error { // // /// For the case that the ErrorResponse object has a code of -1 diff --git a/Shared/Extensions/Array.swift b/Shared/Extensions/Array.swift index 4a6cb6b0..f38d6b3d 100644 --- a/Shared/Extensions/Array.swift +++ b/Shared/Extensions/Array.swift @@ -30,10 +30,6 @@ extension Array { try filter(predicate).count } - func oneSatisfies(_ predicate: (Element) throws -> Bool) rethrows -> Bool { - try contains(where: predicate) - } - func prepending(_ element: Element) -> [Element] { [element] + self } diff --git a/Shared/Extensions/Color.swift b/Shared/Extensions/Color.swift index 11a52b8f..66e0db53 100644 --- a/Shared/Extensions/Color.swift +++ b/Shared/Extensions/Color.swift @@ -8,6 +8,9 @@ import SwiftUI +// TODO: add all other missing colors from UIColor and fix usages +// - move row dividers to divider color + extension Color { static let jellyfinPurple = Color(uiColor: .jellyfinPurple) @@ -26,9 +29,13 @@ extension Color { static let secondarySystemFill = Color(UIColor.gray) static let tertiarySystemFill = Color(UIColor.black) static let lightGray = Color(UIColor.lightGray) + #else - static let systemFill = Color(UIColor.systemFill) static let systemBackground = Color(UIColor.systemBackground) + static let secondarySystemBackground = Color(UIColor.secondarySystemBackground) + static let tertiarySystemBackground = Color(UIColor.tertiarySystemBackground) + + static let systemFill = Color(UIColor.systemFill) static let secondarySystemFill = Color(UIColor.secondarySystemFill) static let tertiarySystemFill = Color(UIColor.tertiarySystemFill) #endif diff --git a/Shared/Extensions/EdgeInsets.swift b/Shared/Extensions/EdgeInsets.swift index 5e555e1c..65a2a671 100644 --- a/Shared/Extensions/EdgeInsets.swift +++ b/Shared/Extensions/EdgeInsets.swift @@ -15,7 +15,7 @@ extension EdgeInsets { /// typically the edges of the View's scene static let edgePadding: CGFloat = { #if os(tvOS) - 50 + 44 #else if UIDevice.isPad { 24 diff --git a/Shared/Extensions/EnvironmentValue/EnvironmentValue+Keys.swift b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Keys.swift index 1cb36545..f39ae453 100644 --- a/Shared/Extensions/EnvironmentValue/EnvironmentValue+Keys.swift +++ b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Keys.swift @@ -11,6 +11,10 @@ import SwiftUI extension EnvironmentValues { + struct AccentColor: EnvironmentKey { + static let defaultValue: Binding = .constant(Color.jellyfinPurple) + } + struct AudioOffsetKey: EnvironmentKey { static let defaultValue: Binding = .constant(0) } @@ -23,10 +27,18 @@ extension EnvironmentValues { static let defaultValue: Binding = .constant(.main) } + struct IsEditingKey: EnvironmentKey { + static let defaultValue: Bool = false + } + struct IsScrubbingKey: EnvironmentKey { static let defaultValue: Binding = .constant(false) } + struct IsSelectedKey: EnvironmentKey { + static let defaultValue: Bool = false + } + struct PlaybackSpeedKey: EnvironmentKey { static let defaultValue: Binding = .constant(1) } diff --git a/Shared/Extensions/EnvironmentValue/EnvironmentValue+Values.swift b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Values.swift index a9425cc5..6a0df59c 100644 --- a/Shared/Extensions/EnvironmentValue/EnvironmentValue+Values.swift +++ b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Values.swift @@ -10,6 +10,11 @@ import SwiftUI extension EnvironmentValues { + var accentColor: Binding { + get { self[AccentColor.self] } + set { self[AccentColor.self] = newValue } + } + var audioOffset: Binding { get { self[AudioOffsetKey.self] } set { self[AudioOffsetKey.self] = newValue } @@ -25,6 +30,11 @@ extension EnvironmentValues { set { self[CurrentOverlayTypeKey.self] = newValue } } + var isEditing: Bool { + get { self[IsEditingKey.self] } + set { self[IsEditingKey.self] = newValue } + } + var isPresentingOverlay: Binding { get { self[IsPresentingOverlayKey.self] } set { self[IsPresentingOverlayKey.self] = newValue } @@ -35,6 +45,11 @@ extension EnvironmentValues { set { self[IsScrubbingKey.self] = newValue } } + var isSelected: Bool { + get { self[IsSelectedKey.self] } + set { self[IsSelectedKey.self] = newValue } + } + var playbackSpeed: Binding { get { self[PlaybackSpeedKey.self] } set { self[PlaybackSpeedKey.self] = newValue } diff --git a/Shared/Extensions/FormatStyle.swift b/Shared/Extensions/FormatStyle.swift new file mode 100644 index 00000000..344d845c --- /dev/null +++ b/Shared/Extensions/FormatStyle.swift @@ -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() } +} diff --git a/Shared/Extensions/HorizontalAlignment.swift b/Shared/Extensions/HorizontalAlignment.swift index af0c947e..3cce5dd3 100644 --- a/Shared/Extensions/HorizontalAlignment.swift +++ b/Shared/Extensions/HorizontalAlignment.swift @@ -8,6 +8,7 @@ import SwiftUI +// TODO: remove and just use overlay + offset extension HorizontalAlignment { struct VideoPlayerTitleAlignment: AlignmentID { @@ -17,12 +18,4 @@ extension HorizontalAlignment { } static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self) - - struct LibraryRowContentAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[HorizontalAlignment.leading] - } - } - - static let LeadingLibraryRowContentAlignmentGuide = HorizontalAlignment(LibraryRowContentAlignment.self) } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift index 29a348f1..16673362 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift @@ -97,7 +97,9 @@ extension BaseItemDto { return nil } - let client = Container.userSession().client + // TODO: client passing for widget/shared group views? + guard let client = UserSession.current()?.client else { return nil } + let parameters = Paths.GetItemImageParameters( maxWidth: scaleWidth, maxHeight: scaleHeight, diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift index d70913b3..4cc0fc3b 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift @@ -21,7 +21,7 @@ extension BaseItemDto { let tempOverkillBitrate = 360_000_000 let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate) - let userSession = Container.userSession() + let userSession = UserSession.current()! let playbackInfo = PlaybackInfoDto(deviceProfile: profile) let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters( @@ -56,7 +56,7 @@ extension BaseItemDto { profile.directPlayProfiles = [DirectPlayProfile(type: .video)] } - let userSession = Container.userSession.callAsFunction() + let userSession = UserSession.current()! let playbackInfo = PlaybackInfoDto(deviceProfile: profile) let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters( diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index 51a4fb03..3ecd5786 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -85,20 +85,6 @@ extension BaseItemDto { return formatter.string(from: .init(remainingSeconds)) } - func getLiveStartTimeString(formatter: DateFormatter) -> String { - if let startDate = self.startDate { - return formatter.string(from: startDate) - } - return " " - } - - func getLiveEndTimeString(formatter: DateFormatter) -> String { - if let endDate = self.endDate { - return formatter.string(from: endDate) - } - return " " - } - var programDuration: TimeInterval? { guard let startDate, let endDate else { return nil } return endDate.timeIntervalSince(startDate) @@ -174,7 +160,10 @@ extension BaseItemDto { } var hasRatings: Bool { - [criticRating, communityRating].oneSatisfies { $0 != nil } + [ + criticRating, + communityRating, + ].contains { $0 != nil } } // MARK: Chapter Images @@ -204,8 +193,8 @@ extension BaseItemDto { parameters: parameters ) - let imageURL = Container - .userSession() + let imageURL = UserSession + .current()! .client .fullURL(with: request) diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift index bd3cbcd6..b1c480a3 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift @@ -23,11 +23,12 @@ extension BaseItemPerson: Poster { func portraitImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] { + guard let client = UserSession.current()?.client else { return [] } + // TODO: figure out what to do about screen scaling with .main being deprecated // - maxWidth assume already scaled? let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) - let client = Container.userSession().client let imageRequestParameters = Paths.GetItemImageParameters( maxWidth: scaleWidth ?? Int(maxWidth), tag: primaryImageTag diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift index a64cd0a3..f21d27bb 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift @@ -12,7 +12,7 @@ import UIKit extension BaseItemPerson: Displayable { var displayTitle: String { - self.name ?? .emptyDash + name ?? .emptyDash } } diff --git a/Shared/Extensions/JellyfinAPI/JellyfinClient.swift b/Shared/Extensions/JellyfinAPI/JellyfinClient.swift index 253d8907..81af9d38 100644 --- a/Shared/Extensions/JellyfinAPI/JellyfinClient.swift +++ b/Shared/Extensions/JellyfinAPI/JellyfinClient.swift @@ -9,6 +9,7 @@ import Foundation import Get import JellyfinAPI +import UIKit extension JellyfinClient { @@ -26,8 +27,30 @@ extension JellyfinClient { /// Appends the path to the current configuration `URL`, assuming that the path begins with a leading `/`. /// Returns `nil` if the new `URL` is malformed. func fullURL(with path: String) -> URL? { - guard let fullPath = URL(string: configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path) - else { return nil } - return fullPath + let fullPath = configuration.url.absoluteString.trimmingCharacters(in: ["/"]) + path + return URL(string: fullPath) + } +} + +extension JellyfinClient.Configuration { + + static func swiftfinConfiguration(url: URL) -> Self { + + let client = "Swiftfin \(UIDevice.platform)" + let deviceName = UIDevice.current.name + .folding(options: .diacriticInsensitive, locale: .current) + .unicodeScalars + .filter { CharacterSet.urlQueryAllowed.contains($0) } + .description + let deviceID = "\(UIDevice.platform)_\(UIDevice.vendorUUIDString)" + let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.1" + + return .init( + url: url, + client: client, + deviceName: deviceName, + deviceID: deviceID, + version: version + ) } } diff --git a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift index f67f43a8..5b0e6e82 100644 --- a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift @@ -18,7 +18,7 @@ extension MediaSourceInfo { func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel { - let userSession = Container.userSession() + let userSession: UserSession! = UserSession.current() let playbackURL: URL let streamType: StreamType @@ -67,7 +67,8 @@ extension MediaSourceInfo { } func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel { - let userSession = Container.userSession.callAsFunction() + + let userSession: UserSession! = UserSession.current() let playbackURL: URL let streamType: StreamType diff --git a/Shared/Extensions/JellyfinAPI/MediaStream.swift b/Shared/Extensions/JellyfinAPI/MediaStream.swift index b2405d1d..7e5a366b 100644 --- a/Shared/Extensions/JellyfinAPI/MediaStream.swift +++ b/Shared/Extensions/JellyfinAPI/MediaStream.swift @@ -16,8 +16,8 @@ extension MediaStream { static var none: MediaStream = .init(displayTitle: L10n.none, index: -1) var asPlaybackChild: VLCVideoPlayer.PlaybackChild? { - guard let deliveryURL else { return nil } - let client = Container.userSession().client + guard let deliveryURL, let client = UserSession.current()?.client else { return nil } + let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/") guard let fullURL = client.fullURL(with: deliveryPath) else { return nil } @@ -250,22 +250,22 @@ extension [MediaStream] { } var has4KVideo: Bool { - oneSatisfies { $0.is4kVideo } + contains { $0.is4kVideo } } var has51AudioChannelLayout: Bool { - oneSatisfies { $0.is51AudioChannelLayout } + contains { $0.is51AudioChannelLayout } } var has71AudioChannelLayout: Bool { - oneSatisfies { $0.is71AudioChannelLayout } + contains { $0.is71AudioChannelLayout } } var hasHDVideo: Bool { - oneSatisfies { $0.isHDVideo } + contains { $0.isHDVideo } } var hasSubtitles: Bool { - oneSatisfies { $0.type == .subtitle } + contains { $0.type == .subtitle } } } diff --git a/Shared/Extensions/JellyfinAPI/SortOrder.swift b/Shared/Extensions/JellyfinAPI/SortOrder+ItemSortOrder.swift similarity index 100% rename from Shared/Extensions/JellyfinAPI/SortOrder.swift rename to Shared/Extensions/JellyfinAPI/SortOrder+ItemSortOrder.swift diff --git a/Shared/Extensions/JellyfinAPI/UserDto.swift b/Shared/Extensions/JellyfinAPI/UserDto.swift index 3c4fbfa3..3e8ba6dc 100644 --- a/Shared/Extensions/JellyfinAPI/UserDto.swift +++ b/Shared/Extensions/JellyfinAPI/UserDto.swift @@ -6,26 +6,25 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Factory import Foundation -import Get import JellyfinAPI -import UIKit extension UserDto { - func profileImageSource(client: JellyfinClient, maxWidth: CGFloat, maxHeight: CGFloat) -> ImageSource { - let scaleWidth = UIScreen.main.scale(maxWidth) - let scaleHeight = UIScreen.main.scale(maxHeight) - - let request = Paths.getUserImage( - userID: id ?? "", - imageType: "Primary", - parameters: .init(maxWidth: scaleWidth, maxHeight: scaleHeight) + func profileImageSource( + client: JellyfinClient, + maxWidth: CGFloat? = nil, + maxHeight: CGFloat? = nil + ) -> ImageSource { + UserState( + id: id ?? "", + serverID: "", + username: "" + ) + .profileImageSource( + client: client, + maxWidth: maxWidth, + maxHeight: maxHeight ) - - let profileImageURL = client.fullURL(with: request) - - return ImageSource(url: profileImageURL) } } diff --git a/Shared/Extensions/NavigationCoordinatable.swift b/Shared/Extensions/NavigationCoordinatable.swift index c199d4ea..dd327f7e 100644 --- a/Shared/Extensions/NavigationCoordinatable.swift +++ b/Shared/Extensions/NavigationCoordinatable.swift @@ -9,13 +9,6 @@ import Stinsen import SwiftUI -extension NavigationCoordinatable { - - func inNavigationViewCoordinator() -> NavigationViewCoordinator { - NavigationViewCoordinator(self) - } -} - extension NavigationViewCoordinator { convenience init(@ViewBuilder content: @escaping () -> Content) { diff --git a/Shared/Extensions/Sequence.swift b/Shared/Extensions/Sequence.swift index 0e99c9f7..05c9c920 100644 --- a/Shared/Extensions/Sequence.swift +++ b/Shared/Extensions/Sequence.swift @@ -48,6 +48,10 @@ extension Sequence { func subtracting(_ other: some Sequence, using keyPath: KeyPath) -> [Element] { filter { !other.contains($0[keyPath: keyPath]) } } + + func zipped(map mapToOther: (Element) throws -> Value) rethrows -> [(Element, Value)] { + try map { try ($0, mapToOther($0)) } + } } extension Sequence where Element: Equatable { diff --git a/Shared/Errors/ErrorMessage.swift b/Shared/Extensions/Set.swift similarity index 50% rename from Shared/Errors/ErrorMessage.swift rename to Shared/Extensions/Set.swift index 23ebbf2f..bc6b9531 100644 --- a/Shared/Errors/ErrorMessage.swift +++ b/Shared/Extensions/Set.swift @@ -7,20 +7,14 @@ // import Foundation -import JellyfinAPI -// TODO: remove -struct ErrorMessage: Hashable, Identifiable { +extension Set { - let code: Int? - let message: String - - var id: Int { - hashValue - } - - init(message: String, code: Int? = nil) { - self.code = code - self.message = message + mutating func toggle(value: Element) { + if contains(value) { + remove(value) + } else { + insert(value) + } } } diff --git a/Shared/Extensions/String.swift b/Shared/Extensions/String.swift index 1c1b4a2b..cdd9abbc 100644 --- a/Shared/Extensions/String.swift +++ b/Shared/Extensions/String.swift @@ -118,5 +118,6 @@ extension String { extension CharacterSet { + // Character that appears on tvOS with voice input static var objectReplacement: CharacterSet = .init(charactersIn: "\u{fffc}") } diff --git a/Shared/Extensions/UIDevice.swift b/Shared/Extensions/UIDevice.swift index 24678877..2668ebcf 100644 --- a/Shared/Extensions/UIDevice.swift +++ b/Shared/Extensions/UIDevice.swift @@ -49,11 +49,15 @@ extension UIDevice { } static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) { + #if os(iOS) UINotificationFeedbackGenerator().notificationOccurred(type) + #endif } static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) { + #if os(iOS) UIImpactFeedbackGenerator(style: type).impactOccurred() + #endif } #endif } diff --git a/Shared/Extensions/URLSessionConfiguration.swift b/Shared/Extensions/URLSessionConfiguration.swift new file mode 100644 index 00000000..e683d1bf --- /dev/null +++ b/Shared/Extensions/URLSessionConfiguration.swift @@ -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) + }() +} diff --git a/Shared/Extensions/ViewExtensions/Backport.swift b/Shared/Extensions/ViewExtensions/Backport.swift index 8a7873fb..7bbd9929 100644 --- a/Shared/Extensions/ViewExtensions/Backport.swift +++ b/Shared/Extensions/ViewExtensions/Backport.swift @@ -15,6 +15,16 @@ struct Backport { extension Backport where Content: View { + /// Note: has no effect on iOS/tvOS 15 + @ViewBuilder + func fontWeight(_ weight: Font.Weight?) -> some View { + if #available(iOS 16, tvOS 16, *) { + content.fontWeight(weight) + } else { + content + } + } + @ViewBuilder func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View { if #available(iOS 16, tvOS 16, *) { @@ -30,6 +40,17 @@ extension Backport where Content: View { } } + @ViewBuilder + func scrollDisabled(_ disabled: Bool) -> some View { + if #available(iOS 16, tvOS 16, *) { + content.scrollDisabled(disabled) + } else { + content.introspect(.scrollView, on: .iOS(.v15), .tvOS(.v15)) { scrollView in + scrollView.isScrollEnabled = !disabled + } + } + } + #if os(iOS) // TODO: - remove comment when migrated away from Stinsen @@ -62,3 +83,16 @@ extension Backport where Content: View { } #endif } + +// MARK: ButtonBorderShape + +extension ButtonBorderShape { + + static let circleBackport: ButtonBorderShape = { + if #available(iOS 17, tvOS 16.4, *) { + return ButtonBorderShape.circle + } else { + return ButtonBorderShape.roundedRectangle + } + }() +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift index fa974e22..ec2606bd 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift @@ -11,12 +11,12 @@ import SwiftUI struct OnReceiveNotificationModifier: ViewModifier { let notification: NSNotification.Name - let onReceive: () -> Void + let onReceive: (Notification) -> Void func body(content: Content) -> some View { content - .onReceive(NotificationCenter.default.publisher(for: notification)) { _ in - onReceive() + .onReceive(NotificationCenter.default.publisher(for: notification)) { + onReceive($0) } } } diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift index 5fe84f02..6212f48c 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift @@ -21,3 +21,23 @@ struct OnSizeChangedModifier: ViewModifier { .trackingSize($size) } } + +struct EnvironmentModifier: ViewModifier { + + @Environment + var environmentValue: Value + + @ViewBuilder + var wrapped: (Value) -> Wrapped + + init(_ keyPath: KeyPath, @ViewBuilder wrapped: @escaping (Value) -> Wrapped) { + self._environmentValue = Environment(keyPath) + self.wrapped = wrapped + } + + func body(content: Content) -> some View { + wrapped(environmentValue) + +// wrapped(content) + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/PaletteOverlayRenderingModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/PaletteOverlayRenderingModifier.swift deleted file mode 100644 index 2d654f20..00000000 --- a/Shared/Extensions/ViewExtensions/Modifiers/PaletteOverlayRenderingModifier.swift +++ /dev/null @@ -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) - } -} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanModifier.swift new file mode 100644 index 00000000..2ea99797 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanModifier.swift @@ -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) + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift index 87946e45..9d08e91a 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift @@ -19,7 +19,7 @@ struct ScrollViewOffsetModifier: ViewModifier { } func body(content: Content) -> some View { - content.introspect(.scrollView, on: .iOS(.v15), .iOS(.v16), .iOS(.v17)) { scrollView in + content.introspect(.scrollView, on: .iOS(.v15), .iOS(.v16), .iOS(.v17), .tvOS(.v15), .tvOS(.v16), .tvOS(.v17)) { scrollView in scrollView.delegate = scrollViewDelegate } } diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index caec9e4b..643d2c5f 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -196,8 +196,6 @@ extension View { } } - // TODO: have width/height tracked binding - func onSizeChanged(perform action: @escaping (CGSize) -> Void) -> some View { onSizeChanged { size, _ in action(size) @@ -246,15 +244,6 @@ extension View { } } - /// Applies the `.palette` symbol rendering mode and a foreground style - /// where the primary style is the passed `Color`'s `overlayColor` and the - /// secondary style is the passed `Color`. - /// - /// If `color == nil`, then `accentColor` from the environment is used. - func paletteOverlayRendering(color: Color? = nil) -> some View { - modifier(PaletteOverlayRenderingModifier(color: color)) - } - @ViewBuilder func navigationBarHidden() -> some View { if #available(iOS 16, tvOS 16, *) { @@ -321,7 +310,7 @@ extension View { } } - func onNotification(_ name: NSNotification.Name, perform action: @escaping () -> Void) -> some View { + func onNotification(_ name: NSNotification.Name, perform action: @escaping (Notification) -> Void) -> some View { modifier( OnReceiveNotificationModifier( notification: name, @@ -330,13 +319,50 @@ extension View { ) } + func onNotification(_ swiftfinNotification: Notifications.Key, perform action: @escaping (Notification) -> Void) -> some View { + modifier( + OnReceiveNotificationModifier( + notification: swiftfinNotification.underlyingNotification.name, + onReceive: action + ) + ) + } + + func scroll(ifLargerThan height: CGFloat) -> some View { + modifier(ScrollIfLargerThanModifier(height: height)) + } + + // MARK: debug + + // Useful modifiers during development for layout + #if DEBUG - // Useful modifier during development - func debugBackground(_ color: Color = Color.red, opacity: CGFloat = 0.5) -> some View { + func debugBackground(_ fill: S = .red.opacity(0.5)) -> some View { background { - color - .opacity(opacity) + Rectangle() + .fill(fill) } } + + func debugVLine(_ fill: S) -> some View { + overlay { + Rectangle() + .fill(fill) + .frame(width: 4) + } + } + + func debugHLine(_ fill: S) -> some View { + overlay { + Rectangle() + .fill(fill) + .frame(height: 4) + } + } + + func debugCross(_ fill: S = .red) -> some View { + debugVLine(fill) + .debugHLine(fill) + } #endif } diff --git a/Shared/Objects/CaseIterablePicker.swift b/Shared/Objects/CaseIterablePicker.swift index c7f71548..2c47479c 100644 --- a/Shared/Objects/CaseIterablePicker.swift +++ b/Shared/Objects/CaseIterablePicker.swift @@ -99,32 +99,35 @@ extension CaseIterablePicker { // MARK: Label -extension CaseIterablePicker where Element: SystemImageable { +// TODO: I didn't entirely like the forced label design that this +// uses, decide whether to actually keep - init(title: String, selection: Binding) { - self.init( - selection: selection, - label: { Label($0.displayTitle, systemImage: $0.systemImage) }, - title: title, - hasNone: true, - noneStyle: .text - ) - } - - init(title: String, selection: Binding) { - let binding = Binding { - selection.wrappedValue - } set: { newValue, _ in - precondition(newValue != nil, "Should not have nil new value with non-optional binding") - selection.wrappedValue = newValue! - } - - self.init( - selection: binding, - label: { Label($0.displayTitle, systemImage: $0.systemImage) }, - title: title, - hasNone: false, - noneStyle: .text - ) - } -} +// extension CaseIterablePicker where Element: SystemImageable { +// +// init(title: String, selection: Binding) { +// self.init( +// selection: selection, +// label: { Label($0.displayTitle, systemImage: $0.systemImage) }, +// title: title, +// hasNone: true, +// noneStyle: .text +// ) +// } +// +// init(title: String, selection: Binding) { +// let binding = Binding { +// selection.wrappedValue +// } set: { newValue, _ in +// precondition(newValue != nil, "Should not have nil new value with non-optional binding") +// selection.wrappedValue = newValue! +// } +// +// self.init( +// selection: binding, +// label: { Label($0.displayTitle, systemImage: $0.systemImage) }, +// title: title, +// hasNone: false, +// noneStyle: .text +// ) +// } +// } diff --git a/Shared/Objects/ItemFilter/ItemFilterType.swift b/Shared/Objects/ItemFilter/ItemFilterType.swift index b37ec030..448bbd96 100644 --- a/Shared/Objects/ItemFilter/ItemFilterType.swift +++ b/Shared/Objects/ItemFilter/ItemFilterType.swift @@ -19,7 +19,6 @@ enum ItemFilterType: String, CaseIterable, Defaults.Serializable { case traits case years - // TODO: rename to something indicating plurality instead of concrete type? var selectorType: SelectorType { switch self { case .genres, .tags, .traits, .years: diff --git a/Shared/Objects/LibraryDisplayType.swift b/Shared/Objects/LibraryDisplayType.swift index 15a48dcb..175856b4 100644 --- a/Shared/Objects/LibraryDisplayType.swift +++ b/Shared/Objects/LibraryDisplayType.swift @@ -10,7 +10,7 @@ import Defaults import Foundation import UIKit -enum LibraryDisplayType: String, CaseIterable, Displayable, Defaults.Serializable, SystemImageable { +enum LibraryDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable { case grid case list diff --git a/Shared/Objects/LibraryParent/TitledLibraryParent.swift b/Shared/Objects/LibraryParent/TitledLibraryParent.swift index 9e4e3cde..53101f8f 100644 --- a/Shared/Objects/LibraryParent/TitledLibraryParent.swift +++ b/Shared/Objects/LibraryParent/TitledLibraryParent.swift @@ -13,6 +13,11 @@ import JellyfinAPI struct TitledLibraryParent: LibraryParent { let displayTitle: String - let id: String? = nil + let id: String? let libraryType: BaseItemKind? = nil + + init(displayTitle: String, id: String? = nil) { + self.displayTitle = displayTitle + self.id = id + } } diff --git a/Shared/Objects/PosterDisplayType.swift b/Shared/Objects/PosterDisplayType.swift index 6cbea47c..71ef7232 100644 --- a/Shared/Objects/PosterDisplayType.swift +++ b/Shared/Objects/PosterDisplayType.swift @@ -9,7 +9,7 @@ import Defaults import SwiftUI -enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable { +enum PosterDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable { case landscape case portrait @@ -23,4 +23,13 @@ enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable "Portrait" } } + + var systemImage: String { + switch self { + case .landscape: + "rectangle.fill" + case .portrait: + "rectangle.portrait.fill" + } + } } diff --git a/Shared/Objects/SelectUserServerSelection.swift b/Shared/Objects/SelectUserServerSelection.swift new file mode 100644 index 00000000..a1b83b05 --- /dev/null +++ b/Shared/Objects/SelectUserServerSelection.swift @@ -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) + } + } +} diff --git a/Shared/Objects/Stateful.swift b/Shared/Objects/Stateful.swift index e1315158..4647ed8a 100644 --- a/Shared/Objects/Stateful.swift +++ b/Shared/Objects/Stateful.swift @@ -16,6 +16,7 @@ import OrderedCollections // parent class actions // TODO: official way for a cleaner `respond` method so it doesn't have all Task // construction and get bloated +// TODO: make Action: Hashable just for consistency protocol Stateful: AnyObject { @@ -43,6 +44,11 @@ protocol Stateful: AnyObject { extension Stateful { + var lastAction: Action? { + get { nil } + set {} + } + @MainActor func send(_ action: Action) { state = respond(to: action) diff --git a/Shared/Objects/Storable.swift b/Shared/Objects/Storable.swift new file mode 100644 index 00000000..887bba6d --- /dev/null +++ b/Shared/Objects/Storable.swift @@ -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 {} diff --git a/Shared/Objects/UserAccessPolicy.swift b/Shared/Objects/UserAccessPolicy.swift new file mode 100644 index 00000000..ce811718 --- /dev/null +++ b/Shared/Objects/UserAccessPolicy.swift @@ -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" + } + } +} diff --git a/Shared/ServerDiscovery/ServerDiscovery.swift b/Shared/ServerDiscovery/ServerDiscovery.swift index d7d261b2..08128fae 100644 --- a/Shared/ServerDiscovery/ServerDiscovery.swift +++ b/Shared/ServerDiscovery/ServerDiscovery.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Combine import Factory import Foundation import UDPBroadcast @@ -15,69 +16,45 @@ class ServerDiscovery { @Injected(LogManager.service) private var logger - struct ServerLookupResponse: Codable, Hashable, Identifiable { - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - private let address: String - let id: String - let name: String - - var url: URL { - URL(string: self.address)! - } - - var host: String { - let components = URLComponents(string: self.address) - if let host = components?.host { - return host - } - return self.address - } - - var port: Int { - let components = URLComponents(string: self.address) - if let port = components?.port { - return port - } - return 7359 - } - - enum CodingKeys: String, CodingKey { - case address = "Address" - case id = "Id" - case name = "Name" - } - } - private var connection: UDPBroadcastConnection? - init() {} + init() { + connection = try? UDPBroadcastConnection( + port: 7359, + handler: handleServerResponse, + errorHandler: handleError + ) + } - func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) { + var discoveredServers: AnyPublisher { + discoveredServersPublisher + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } - func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) { - do { - let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data) - logger.debug("Received JellyfinServer from \"\(response.name)\"") - completion(response) - } catch { - completion(nil) - } - } + private var discoveredServersPublisher = PassthroughSubject() - func errorHandler(error: UDPBroadcastConnection.ConnectionError) { - logger.error("Error handling response: \(error.localizedDescription)") - } + func broadcast() { + try? connection?.sendBroadcast("Who is JellyfinServer?") + } + func close() { + connection?.closeConnection() + discoveredServersPublisher.send(completion: .finished) + } + + private func handleServerResponse(_ ipAddress: String, _ port: Int, data: Data) { do { - self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) - try self.connection?.sendBroadcast("Who is JellyfinServer?") - logger.debug("Discovery broadcast sent") + let response = try JSONDecoder().decode(ServerResponse.self, from: data) + discoveredServersPublisher.send(response) + + logger.debug("Found local server: \"\(response.name)\" at: \(response.url.absoluteString)") } catch { - logger.error("Error sending discovery broadcast") + logger.debug("Unable to decode local server response from: \(ipAddress):\(port)") } } + + private func handleError(_ error: UDPBroadcastConnection.ConnectionError) { + logger.debug("Error handling response: \(error.localizedDescription)") + } } diff --git a/Shared/ServerDiscovery/ServerResponse.swift b/Shared/ServerDiscovery/ServerResponse.swift new file mode 100644 index 00000000..dae85d03 --- /dev/null +++ b/Shared/ServerDiscovery/ServerResponse.swift @@ -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" + } + } +} diff --git a/Shared/Services/DownloadTask.swift b/Shared/Services/DownloadTask.swift index 399d9a9d..bcf04e72 100644 --- a/Shared/Services/DownloadTask.swift +++ b/Shared/Services/DownloadTask.swift @@ -40,8 +40,8 @@ class DownloadTask: NSObject, ObservableObject { @Injected(LogManager.service) private var logger - @Injected(Container.userSession) - private var userSession + @Injected(UserSession.current) + private var userSession: UserSession! @Published var state: State = .ready diff --git a/Shared/Services/Keychain.swift b/Shared/Services/Keychain.swift new file mode 100644 index 00000000..ead6c4c4 --- /dev/null +++ b/Shared/Services/Keychain.swift @@ -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(scope: .singleton) { + KeychainSwift() + } +} diff --git a/Shared/Services/NewSessionManager.swift b/Shared/Services/NewSessionManager.swift deleted file mode 100644 index bf8cea1f..00000000 --- a/Shared/Services/NewSessionManager.swift +++ /dev/null @@ -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(scope: .basicServerSessionScope) { - .init(server: $0) - } - - static let userSession = Factory(scope: .userSessionScope) { - - if let lastUserID = Defaults[.lastServerUserID], - let user = try? SwiftfinStore.dataStack.fetchOne( - From(), - [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, - 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 - ) - } -} diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift index bf72acdc..ec15a00d 100644 --- a/Shared/Services/SwiftfinDefaults.swift +++ b/Shared/Services/SwiftfinDefaults.swift @@ -7,236 +7,229 @@ // import Defaults +import Factory import Foundation import SwiftUI import UIKit -// TODO: Organize +// TODO: organize +// TODO: all user settings could be moved to `StoredValues`? + +// Note: Only use Defaults for basic single-value settings. +// For larger data types and collections, use `StoredValue` instead. + +// MARK: Suites extension UserDefaults { - static let generalSuite = UserDefaults(suiteName: "swiftfinstore-general-defaults")! - static let universalSuite = UserDefaults(suiteName: "swiftfinstore-universal-defaults")! + + // MARK: App + + /// Settings that should apply to the app + static let appSuite = UserDefaults(suiteName: "swiftfinApp")! + + // MARK: Usser + + // TODO: the Factory resolver cannot be used because it would cause freezes, but + // the Defaults value should always be in sync with the latest user and what + // views properly expect. However, this feels like a hack and should be changed? + static var currentUserSuite: UserDefaults { + userSuite(id: Defaults[.lastSignedInUserID] ?? "default") + } + + static func userSuite(id: String) -> UserDefaults { + UserDefaults(suiteName: id)! + } } +private extension Defaults.Keys { + + static func AppKey(_ name: String) -> Key { + Key(name, suite: .appSuite) + } + + static func AppKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static func UserKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .currentUserSuite) + } +} + +// MARK: App + extension Defaults.Keys { - // Universal settings - static let accentColor: Key = .init("accentColor", default: .jellyfinPurple, suite: .universalSuite) - static let appAppearance = Key("appAppearance", default: .system, suite: .universalSuite) - static let hapticFeedback: Key = .init("hapticFeedback", default: true, suite: .universalSuite) - static let lastServerUserID = Defaults.Key("lastServerUserID", suite: .universalSuite) + /// The _real_ accent color key to be used. + /// + /// This is set externally whenever the app or user accent colors change, + /// depending on the current app state. + static var accentColor: Key = AppKey("accentColor", default: .jellyfinPurple) - // TODO: Replace with a cache - static let libraryFilterStore = Key<[String: ItemFilterCollection]>("libraryFilterStore", default: [:], suite: .generalSuite) + /// The _real_ appearance key to be used. + /// + /// This is set externally whenever the app or user appearances change, + /// depending on the current app state. + static let appearance: Key = AppKey("appearance", default: .system) + + /// The accent color default for non-user contexts. + /// Only use for `set`, use `accentColor` for `get`. + static let appAccentColor: Key = AppKey("appAccentColor", default: .jellyfinPurple) + + /// The appearance default for non-user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static let appAppearance: Key = AppKey("appAppearance", default: .system) + + static let backgroundSignOutInterval: Key = AppKey("backgroundSignOutInterval", default: 3600) + static let backgroundTimeStamp: Key = AppKey("backgroundTimeStamp", default: Date.now) + static let lastSignedInUserID: Key = AppKey("lastSignedInUserID") + + static let selectUserDisplayType: Key = AppKey("selectUserDisplayType", default: .grid) + static let selectUserServerSelection: Key = AppKey("selectUserServerSelection", default: .all) + static let selectUserAllServersSplashscreen: Key = AppKey("selectUserAllServersSplashscreen", default: .all) + static let selectUserUseSplashscreen: Key = AppKey("selectUserUseSplashscreen", default: true) + + static let signOutOnBackground: Key = AppKey("signOutOnBackground", default: true) + static let signOutOnClose: Key = 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 { UserKey("userAccentColor", default: .jellyfinPurple) } + + /// The appearance default for user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static var userAppearance: Key { UserKey("userAppearance", default: .system) } enum Customization { - static let itemViewType = Key("itemViewType", default: .compactLogo, suite: .generalSuite) + static let itemViewType: Key = UserKey("itemViewType", default: .compactLogo) - static let showPosterLabels = Key("showPosterLabels", default: true, suite: .generalSuite) - static let nextUpPosterType = Key("nextUpPosterType", default: .portrait, suite: .generalSuite) - static let recentlyAddedPosterType = Key("recentlyAddedPosterType", default: .portrait, suite: .generalSuite) - static let latestInLibraryPosterType = Key("latestInLibraryPosterType", default: .portrait, suite: .generalSuite) - static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: .generalSuite) - static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: .generalSuite) - static let similarPosterType = Key("similarPosterType", default: .portrait, suite: .generalSuite) + static let showPosterLabels: Key = UserKey("showPosterLabels", default: true) + static let nextUpPosterType: Key = UserKey("nextUpPosterType", default: .portrait) + static let recentlyAddedPosterType: Key = UserKey("recentlyAddedPosterType", default: .portrait) + static let latestInLibraryPosterType: Key = UserKey("latestInLibraryPosterType", default: .portrait) + static let shouldShowMissingSeasons: Key = UserKey("shouldShowMissingSeasons", default: true) + static let shouldShowMissingEpisodes: Key = UserKey("shouldShowMissingEpisodes", default: true) + static let similarPosterType: Key = UserKey("similarPosterType", default: .portrait) // TODO: have search poster type by types of items if applicable - static let searchPosterType = Key("searchPosterType", default: .portrait, suite: .generalSuite) + static let searchPosterType: Key = UserKey("searchPosterType", default: .portrait) enum CinematicItemViewType { - static let usePrimaryImage: Key = .init("cinematicItemViewTypeUsePrimaryImage", default: false, suite: .generalSuite) + static let usePrimaryImage: Key = UserKey("cinematicItemViewTypeUsePrimaryImage", default: false) } enum Episodes { - static let useSeriesLandscapeBackdrop = Key("useSeriesBackdrop", default: true, suite: .generalSuite) + static let useSeriesLandscapeBackdrop: Key = UserKey("useSeriesBackdrop", default: true) } enum Indicators { - static let showFavorited: Key = .init("showFavoritedIndicator", default: true, suite: .generalSuite) - static let showProgress: Key = .init("showProgressIndicator", default: true, suite: .generalSuite) - static let showUnplayed: Key = .init("showUnplayedIndicator", default: true, suite: .generalSuite) - static let showPlayed: Key = .init("showPlayedIndicator", default: true, suite: .generalSuite) + static let showFavorited: Key = UserKey("showFavoritedIndicator", default: true) + static let showProgress: Key = UserKey("showProgressIndicator", default: true) + static let showUnplayed: Key = UserKey("showUnplayedIndicator", default: true) + static let showPlayed: Key = UserKey("showPlayedIndicator", default: true) } enum Library { - static let cinematicBackground: Key = .init( - "Customization.Library.cinematicBackground", - default: true, - suite: .generalSuite - ) - static let enabledDrawerFilters: Key<[ItemFilterType]> = .init( + static let cinematicBackground: Key = UserKey("Customization.Library.cinematicBackground", default: true) + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( "Library.enabledDrawerFilters", - default: ItemFilterType.allCases, - suite: .generalSuite - ) - static let viewType = Key( - "libraryViewType", - default: .grid, - suite: .generalSuite - ) - static let posterType = Key( - "libraryPosterType", - default: .portrait, - suite: .generalSuite - ) - static let listColumnCount = Key( - "listColumnCount", - default: 1, - suite: .generalSuite - ) - static let randomImage: Key = .init( - "libraryRandomImage", - default: true, - suite: .generalSuite - ) - static let showFavorites: Key = .init( - "libraryShowFavorites", - default: true, - suite: .generalSuite + default: ItemFilterType.allCases ) + static let displayType: Key = UserKey("libraryViewType", default: .grid) + static let posterType: Key = UserKey("libraryPosterType", default: .portrait) + static let listColumnCount: Key = UserKey("listColumnCount", default: 1) + static let randomImage: Key = UserKey("libraryRandomImage", default: true) + static let showFavorites: Key = UserKey("libraryShowFavorites", default: true) + + static let rememberLayout: Key = UserKey("libraryRememberLayout", default: false) + static let rememberSort: Key = UserKey("libraryRememberSort", default: false) } enum Search { - static let enabledDrawerFilters: Key<[ItemFilterType]> = .init( + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( "Search.enabledDrawerFilters", - default: ItemFilterType.allCases, - suite: .generalSuite + default: ItemFilterType.allCases ) } } enum VideoPlayer { - static let autoPlayEnabled: Key = .init("autoPlayEnabled", default: true, suite: .generalSuite) - static let barActionButtons: Key<[VideoPlayerActionButton]> = .init( + static let autoPlayEnabled: Key = UserKey("autoPlayEnabled", default: true) + static let barActionButtons: Key<[VideoPlayerActionButton]> = UserKey( "barActionButtons", - default: VideoPlayerActionButton.defaultBarActionButtons, - suite: .generalSuite + default: VideoPlayerActionButton.defaultBarActionButtons ) - static let jumpBackwardLength: Key = .init( - "jumpBackwardLength", - default: .fifteen, - suite: .generalSuite - ) - static let jumpForwardLength: Key = .init( - "jumpForwardLength", - default: .fifteen, - suite: .generalSuite - ) - static let menuActionButtons: Key<[VideoPlayerActionButton]> = .init( + static let jumpBackwardLength: Key = UserKey("jumpBackwardLength", default: .fifteen) + static let jumpForwardLength: Key = UserKey("jumpForwardLength", default: .fifteen) + static let menuActionButtons: Key<[VideoPlayerActionButton]> = UserKey( "menuActionButtons", - default: VideoPlayerActionButton.defaultMenuActionButtons, - suite: .generalSuite + default: VideoPlayerActionButton.defaultMenuActionButtons ) - static let resumeOffset: Key = .init("resumeOffset", default: 0, suite: .generalSuite) - static let showJumpButtons: Key = .init("showJumpButtons", default: true, suite: .generalSuite) - static let videoPlayerType: Key = .init("videoPlayerType", default: .swiftfin, suite: .generalSuite) + static let resumeOffset: Key = UserKey("resumeOffset", default: 0) + static let showJumpButtons: Key = UserKey("showJumpButtons", default: true) + static let videoPlayerType: Key = UserKey("videoPlayerType", default: .swiftfin) enum Gesture { - static let horizontalPanGesture: Key = .init( - "videoPlayerHorizontalPanGesture", - default: .none, - suite: .generalSuite - ) - static let horizontalSwipeGesture: Key = .init( - "videoPlayerHorizontalSwipeGesture", - default: .none, - suite: .generalSuite - ) - static let longPressGesture: Key = .init( - "videoPlayerLongPressGesture", - default: .gestureLock, - suite: .generalSuite - ) - static let multiTapGesture: Key = .init("videoPlayerMultiTapGesture", default: .none, suite: .generalSuite) - static let doubleTouchGesture: Key = .init( - "videoPlayerDoubleTouchGesture", - default: .none, - suite: .generalSuite - ) - static let pinchGesture: Key = .init("videoPlayerSwipeGesture", default: .aspectFill, suite: .generalSuite) - static let verticalPanGestureLeft: Key = .init( - "videoPlayerVerticalPanGestureLeft", - default: .none, - suite: .generalSuite - ) - static let verticalPanGestureRight: Key = .init( - "videoPlayerVerticalPanGestureRight", - default: .none, - suite: .generalSuite - ) + static let horizontalPanGesture: Key = UserKey("videoPlayerHorizontalPanGesture", default: .none) + static let horizontalSwipeGesture: Key = UserKey("videoPlayerHorizontalSwipeGesture", default: .none) + static let longPressGesture: Key = UserKey("videoPlayerLongPressGesture", default: .gestureLock) + static let multiTapGesture: Key = UserKey("videoPlayerMultiTapGesture", default: .none) + static let doubleTouchGesture: Key = UserKey("videoPlayerDoubleTouchGesture", default: .none) + static let pinchGesture: Key = UserKey("videoPlayerSwipeGesture", default: .aspectFill) + static let verticalPanGestureLeft: Key = UserKey("videoPlayerVerticalPanGestureLeft", default: .none) + static let verticalPanGestureRight: Key = UserKey("videoPlayerVerticalPanGestureRight", default: .none) } enum Overlay { - static let chapterSlider: Key = .init("chapterSlider", default: true, suite: .generalSuite) - static let playbackButtonType: Key = .init( - "videoPlayerPlaybackButtonLocation", - default: .large, - suite: .generalSuite - ) - static let sliderColor: Key = .init("sliderColor", default: Color.white, suite: .generalSuite) - static let sliderType: Key = .init("sliderType", default: .capsule, suite: .generalSuite) + static let chapterSlider: Key = UserKey("chapterSlider", default: true) + static let playbackButtonType: Key = UserKey("videoPlayerPlaybackButtonLocation", default: .large) + static let sliderColor: Key = UserKey("sliderColor", default: Color.white) + static let sliderType: Key = UserKey("sliderType", default: .capsule) // Timestamp - static let trailingTimestampType: Key = .init( - "trailingTimestamp", - default: .timeLeft, - suite: .generalSuite - ) - static let showCurrentTimeWhileScrubbing: Key = .init( - "showCurrentTimeWhileScrubbing", - default: true, - suite: .generalSuite - ) - static let timestampType: Key = .init("timestampType", default: .split, suite: .generalSuite) + static let trailingTimestampType: Key = UserKey("trailingTimestamp", default: .timeLeft) + static let showCurrentTimeWhileScrubbing: Key = UserKey("showCurrentTimeWhileScrubbing", default: true) + static let timestampType: Key = UserKey("timestampType", default: .split) } enum Subtitle { - static let subtitleColor: Key = .init( - "subtitleColor", - default: .white, - suite: .generalSuite - ) - static let subtitleFontName: Key = .init( - "subtitleFontName", - default: UIFont.systemFont(ofSize: 14).fontName, - suite: .generalSuite - ) - static let subtitleSize: Key = .init("subtitleSize", default: 16, suite: .generalSuite) + static let subtitleColor: Key = UserKey("subtitleColor", default: .white) + static let subtitleFontName: Key = UserKey("subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName) + static let subtitleSize: Key = UserKey("subtitleSize", default: 16) } enum Transition { - static let pauseOnBackground: Key = .init("pauseOnBackground", default: false, suite: .generalSuite) - static let playOnActive: Key = .init("playOnActive", default: false, suite: .generalSuite) + static let pauseOnBackground: Key = UserKey("pauseOnBackground", default: false) + static let playOnActive: Key = UserKey("playOnActive", default: false) } } // Experimental settings enum Experimental { - static let downloads: Key = .init("experimentalDownloads", default: false, suite: .generalSuite) - static let syncSubtitleStateWithAdjacent = Key( - "experimentalSyncSubtitleState", - default: false, - suite: .generalSuite - ) - static let forceDirectPlay = Key("forceDirectPlay", default: false, suite: .generalSuite) - - static let liveTVForceDirectPlay = Key("liveTVForceDirectPlay", default: false, suite: .generalSuite) + static let downloads: Key = UserKey("experimentalDownloads", default: false) + static let forceDirectPlay: Key = UserKey("forceDirectPlay", default: false) + static let liveTVForceDirectPlay: Key = UserKey("liveTVForceDirectPlay", default: false) } // tvos specific - static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: .generalSuite) - static let confirmClose = Key("confirmClose", default: false, suite: .generalSuite) + static let downActionShowsMenu: Key = UserKey("downActionShowsMenu", default: true) + static let confirmClose: Key = UserKey("confirmClose", default: false) } // MARK: Debug @@ -250,6 +243,10 @@ extension UserDefaults { extension Defaults.Keys { - static let sendProgressReports: Key = .init("sendProgressReports", default: true, suite: .debugSuite) + static func DebugKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static let sendProgressReports: Key = DebugKey("sendProgressReports", default: true) } #endif diff --git a/Shared/Services/SwiftfinNotifications.swift b/Shared/Services/SwiftfinNotifications.swift index f97807d5..8de2e338 100644 --- a/Shared/Services/SwiftfinNotifications.swift +++ b/Shared/Services/SwiftfinNotifications.swift @@ -14,7 +14,7 @@ class SwiftfinNotification { @Injected(Notifications.service) private var notificationService - private let name: Notification.Name + let name: Notification.Name fileprivate init(_ notificationName: Notification.Name) { self.name = notificationName @@ -39,7 +39,7 @@ class SwiftfinNotification { enum Notifications { - static let service = Factory(scope: .singleton) { NotificationCenter() } + static let service = Factory(scope: .singleton) { NotificationCenter.default } struct Key: Hashable { @@ -76,6 +76,10 @@ extension Notifications.Key { static let didChangeCurrentServerURL = NotificationKey("didChangeCurrentServerURL") static let didSendStopReport = NotificationKey("didSendStopReport") static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh") + static let didFailMigration = NotificationKey("didFailMigration") static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange") + + static let didConnectToServer = NotificationKey("didConnectToServer") + static let didDeleteServer = NotificationKey("didDeleteServer") } diff --git a/Shared/Services/SwiftfinStore.swift b/Shared/Services/SwiftfinStore.swift deleted file mode 100644 index bd22b0d2..00000000 --- a/Shared/Services/SwiftfinStore.swift +++ /dev/null @@ -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 - let currentURL: URL - let name: String - let id: String - let os: String - let version: String - let userIDs: [String] - - init( - urls: Set, - 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 = [] - - @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 - - 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("Server"), - Entity("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) - } - } -} diff --git a/Shared/Services/UserSession.swift b/Shared/Services/UserSession.swift new file mode 100644 index 00000000..7d97eaa3 --- /dev/null +++ b/Shared/Services/UserSession.swift @@ -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(scope: .userSessionScope) { + + if let lastUserID = Defaults[.lastSignedInUserID], + let user = try? SwiftfinStore.dataStack.fetchOne( + From().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 + } +} diff --git a/Shared/SwiftfinStore/StoredValue/StoredValue.swift b/Shared/SwiftfinStore/StoredValue/StoredValue.swift new file mode 100644 index 00000000..2debae8f --- /dev/null +++ b/Shared/SwiftfinStore/StoredValue/StoredValue.swift @@ -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: DynamicProperty { + + @ObservedObject + private var observable: Observable + + let key: StoredValues.Key + + var projectedValue: Binding { + $observable.value + } + + var wrappedValue: Value { + get { + observable.value + } + nonmutating set { + observable.value = newValue + } + } + + init(_ key: StoredValues.Key) { + self.key = key + self.observable = .init(key: key) + } + + mutating func update() { + _observable.update() + } +} + +extension StoredValue { + + final class Observable: ObservableObject { + + let key: StoredValues.Key + + let objectWillChange = ObservableObjectPublisher() + private var objectPublisher: ObjectPublisher? + 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) { + self.key = key + self.objectPublisher = makeObjectPublisher() + } + + private func makeObjectPublisher() -> ObjectPublisher? { + + guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return nil } + + let domain = key.domain ?? "none" + + let clause = From() + .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: _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(key: Key) -> 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 ?? "" + ) + } + } +} diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+Server.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+Server.swift new file mode 100644 index 00000000..edf39eb3 --- /dev/null +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+Server.swift @@ -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( + _ name: String?, + ownerID: String, + domain: String, + default defaultValue: Value + ) -> Key { + guard let name else { + return Key(always: defaultValue) + } + + return Key( + name, + ownerID: ownerID, + domain: domain, + default: defaultValue + ) + } + + static func ServerKey(always: Value) -> Key { + Key(always: always) + } +} + +// MARK: values + +extension StoredValues.Keys { + + enum Server { + + static func publicInfo(id: String) -> Key { + ServerKey( + "publicInfo", + ownerID: id, + domain: "publicInfo", + default: .init() + ) + } + } +} diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+Temp.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+Temp.swift new file mode 100644 index 00000000..ee2df5a4 --- /dev/null +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+Temp.swift @@ -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( + _ name: String?, + ownerID: String, + domain: String, + default defaultValue: Value + ) -> Key { + 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 = TempKey( + "userSignInPolicy", + ownerID: "temporary", + domain: "userSignInPolicy", + default: .none + ) + + static let userLocalPin: Key = TempKey( + "userLocalPin", + ownerID: "temporary", + domain: "userLocalPin", + default: "" + ) + + static let userLocalPinHint: Key = TempKey( + "userLocalPinHint", + ownerID: "temporary", + domain: "userLocalPinHint", + default: "" + ) + + static let userData: Key = TempKey( + "tempUserData", + ownerID: "temporary", + domain: "tempUserData", + default: .init() + ) + } +} diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift new file mode 100644 index 00000000..58a7121c --- /dev/null +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -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( + _ name: String?, + domain: String, + default defaultValue: Value + ) -> Key { + 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( + _ name: String?, + ownerID: String, + domain: String, + default defaultValue: Value + ) -> Key { + guard let name else { + return Key(always: defaultValue) + } + + return Key( + name, + ownerID: ownerID, + domain: domain, + default: defaultValue + ) + } + + static func UserKey(always: Value) -> Key { + 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 { + 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 { + UserKey( + "userData", + ownerID: id, + domain: "userData", + default: .init() + ) + } + + static func libraryDisplayType(parentID: String?) -> Key { + CurrentUserKey( + parentID, + domain: "libraryDisplayType", + default: Defaults[.Customization.Library.displayType] + ) + } + + static func libraryListColumnCount(parentID: String?) -> Key { + CurrentUserKey( + parentID, + domain: "libraryListColumnCount", + default: Defaults[.Customization.Library.listColumnCount] + ) + } + + static func libraryPosterType(parentID: String?) -> Key { + 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 { + CurrentUserKey( + parentID, + domain: "libraryFilters", + default: ItemFilterCollection.default + ) + } + + static func pinHint(id: String) -> Key { + UserKey( + "pinHint", + ownerID: id, + domain: "pinHint", + default: "" + ) + } + } +} diff --git a/Shared/SwiftfinStore/SwiftfinStore+Mappings.swift b/Shared/SwiftfinStore/SwiftfinStore+Mappings.swift new file mode 100644 index 00000000..e08275e7 --- /dev/null +++ b/Shared/SwiftfinStore/SwiftfinStore+Mappings.swift @@ -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] + } + } + } + ), + ] + ) + }() +} diff --git a/Shared/SwiftfinStore/SwiftfinStore+ServerState.swift b/Shared/SwiftfinStore/SwiftfinStore+ServerState.swift new file mode 100644 index 00000000..6c9cc90a --- /dev/null +++ b/Shared/SwiftfinStore/SwiftfinStore+ServerState.swift @@ -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 + let currentURL: URL + let name: String + let id: String + let userIDs: [String] + + init( + urls: Set, + 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().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)) + } +} diff --git a/Shared/SwiftfinStore/SwiftfinStore.swift b/Shared/SwiftfinStore/SwiftfinStore.swift new file mode 100644 index 00000000..23a507a0 --- /dev/null +++ b/Shared/SwiftfinStore/SwiftfinStore.swift @@ -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(scope: .singleton) { + SwiftfinStore.dataStack + } +} diff --git a/Shared/SwiftfinStore/SwiftinStore+UserState.swift b/Shared/SwiftfinStore/SwiftinStore+UserState.swift new file mode 100644 index 00000000..c89dd70f --- /dev/null +++ b/Shared/SwiftfinStore/SwiftinStore+UserState.swift @@ -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().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() + .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) + } +} diff --git a/Shared/SwiftfinStore/V1Schema/SwiftfinStore+V1.swift b/Shared/SwiftfinStore/V1Schema/SwiftfinStore+V1.swift new file mode 100644 index 00000000..316a4776 --- /dev/null +++ b/Shared/SwiftfinStore/V1Schema/SwiftfinStore+V1.swift @@ -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("Server"), + Entity("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, + ], + ] + ) +} diff --git a/Shared/SwiftfinStore/V1Schema/V1ServerModel.swift b/Shared/SwiftfinStore/V1Schema/V1ServerModel.swift new file mode 100644 index 00000000..de961b8c --- /dev/null +++ b/Shared/SwiftfinStore/V1Schema/V1ServerModel.swift @@ -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 = [] + + @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 + + var state: ServerState { + .init( + urls: urls, + currentURL: currentURL, + name: name, + id: id, + usersIDs: users.map(\.id) + ) + } + } +} diff --git a/Shared/SwiftfinStore/V1Schema/V1UserModel.swift b/Shared/SwiftfinStore/V1Schema/V1UserModel.swift new file mode 100644 index 00000000..2618a689 --- /dev/null +++ b/Shared/SwiftfinStore/V1Schema/V1UserModel.swift @@ -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 + ) + } + } +} diff --git a/Shared/SwiftfinStore/V2Schema/SwiftfinStore+V2.swift b/Shared/SwiftfinStore/V2Schema/SwiftfinStore+V2.swift new file mode 100644 index 00000000..7d269ac9 --- /dev/null +++ b/Shared/SwiftfinStore/V2Schema/SwiftfinStore+V2.swift @@ -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("Server"), + Entity("User"), + Entity("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], + ] + ) +} diff --git a/Shared/SwiftfinStore/V2Schema/V2AnyData.swift b/Shared/SwiftfinStore/V2Schema/V2AnyData.swift new file mode 100644 index 00000000..6a70e936 --- /dev/null +++ b/Shared/SwiftfinStore/V2Schema/V2AnyData.swift @@ -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(_ key: String, ownerID: String, domain: String? = nil) throws -> Value? { + + let domain = domain ?? "none" + + let clause = From() + .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: Value, key: String, ownerID: String, domain: String? = nil) throws { + + let domain = domain ?? "none" + + let clause = From() + .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()) + + 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 { + From() + .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 { + let domain = domain ?? "none" + + return From() + .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 { + let domain = domain ?? "none" + + return From() + .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) + } + } +} diff --git a/Shared/SwiftfinStore/V2Schema/V2ServerModel.swift b/Shared/SwiftfinStore/V2Schema/V2ServerModel.swift new file mode 100644 index 00000000..c416f12d --- /dev/null +++ b/Shared/SwiftfinStore/V2Schema/V2ServerModel.swift @@ -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 = [] + + @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 + + var state: ServerState { + .init( + urls: urls, + currentURL: currentURL, + name: name, + id: id, + usersIDs: users.map(\.id) + ) + } + } +} diff --git a/Shared/SwiftfinStore/V2Schema/V2UserModel.swift b/Shared/SwiftfinStore/V2Schema/V2UserModel.swift new file mode 100644 index 00000000..931f143a --- /dev/null +++ b/Shared/SwiftfinStore/V2Schema/V2UserModel.swift @@ -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 + ) + } + } +} diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 808a1806..9f232bd9 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -6,45 +6,137 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Combine import CoreStore -import CryptoKit -import Defaults -import Factory import Foundation import Get import JellyfinAPI +import OrderedCollections import Pulse -import UIKit -final class ConnectToServerViewModel: ViewModel { +final class ConnectToServerViewModel: ViewModel, Eventful, Stateful { + + // MARK: Event + + enum Event { + case connected(ServerState) + case duplicateServer(ServerState) + case error(JellyfinAPIError) + } + + // MARK: Action + + enum Action: Equatable { + case addNewURL(ServerState) + case cancel + case connect(String) + case searchForServers + } + + // MARK: BackgroundState + + enum BackgroundState: Hashable { + case searching + } + + // MARK: State + + enum State: Hashable { + case connecting + case initial + } @Published - private(set) var discoveredServers: [ServerState] = [] + var backgroundStates: OrderedSet = [] + // no longer-found servers are not cleared, but not an issue @Published - private(set) var isSearching = false + var localServers: OrderedSet = [] + @Published + var state: State = .initial + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var connectTask: AnyCancellable? = nil private let discovery = ServerDiscovery() + private var eventSubject: PassthroughSubject = .init() - var connectToServerTask: Task? + deinit { + discovery.close() + } - func connectToServer(url: String) async throws -> (server: ServerState, url: URL) { + override init() { + super.init() - #if os(iOS) - // shhhh - // TODO: remove - if let data = url.data(using: .utf8) { - var sha = SHA256() - sha.update(data: data) - let digest = sha.finalize() - let urlHash = digest.compactMap { String(format: "%02x", $0) }.joined() - if urlHash == "7499aced43869b27f505701e4edc737f0cc346add1240d4ba86fbfa251e0fc35" { - Defaults[.Experimental.downloads] = true + Task { [weak self] in + guard let self else { return } - await UIDevice.feedback(.success) + for await response in discovery.discoveredServers.values { + await MainActor.run { + let _ = self.localServers.append(response.asServerState) + } } } - #endif + .store(in: &cancellables) + } + + func respond(to action: Action) -> State { + switch action { + case let .addNewURL(server): + addNewURL(server: server) + + return state + case .cancel: + connectTask?.cancel() + + return .initial + case let .connect(url): + connectTask?.cancel() + + connectTask = Task { + do { + let server = try await connectToServer(url: url) + + if isDuplicate(server: server) { + await MainActor.run { + // server has same id, but (possible) new URL + self.eventSubject.send(.duplicateServer(server)) + } + } else { + try await save(server: server) + + await MainActor.run { + self.eventSubject.send(.connected(server)) + } + } + + await MainActor.run { + self.state = .initial + } + } catch is CancellationError { + // cancel doesn't matter + } catch { + await MainActor.run { + self.eventSubject.send(.error(.init(error.localizedDescription))) + self.state = .initial + } + } + } + .asAnyCancellable() + + return .connecting + case .searchForServers: + discovery.broadcast() + + return state + } + } + + private func connectToServer(url: String) async throws -> ServerState { let formattedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .objectReplacement) @@ -54,37 +146,35 @@ final class ConnectToServerViewModel: ViewModel { let client = JellyfinClient( configuration: .swiftfinConfiguration(url: url), - sessionDelegate: URLSessionProxyDelegate() + sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger()) ) let response = try await client.send(Paths.getPublicSystemInfo) guard let name = response.value.serverName, - let id = response.value.id, - let os = response.value.operatingSystem, - let version = response.value.version + let id = response.value.id else { - throw JellyfinAPIError("Missing server data from network call") + logger.critical("Missing server data from network call") + throw JellyfinAPIError("An internal error has occurred") } - // in case of redirects, we must process the new URL - - let connectionURL = processConnectionURL(initial: url, response: response.response.url) + let connectionURL = processConnectionURL( + initial: url, + response: response.response.url + ) let newServerState = ServerState( urls: [connectionURL], currentURL: connectionURL, name: name, id: id, - os: os, - version: version, usersIDs: [] ) - return (newServerState, url) + return newServerState } - // TODO: this probably isn't the best way to properly handle this, fix if necessary + // In the event of redirects, get the new host URL from response private func processConnectionURL(initial url: URL, response: URL?) -> URL { guard let response else { return url } @@ -105,72 +195,48 @@ final class ConnectToServerViewModel: ViewModel { return url } - func isDuplicate(server: ServerState) -> Bool { - if let _ = try? SwiftfinStore.dataStack.fetchOne( - From(), - [Where( - "id == %@", - server.id - )] - ) { - return true - } - return false + private func isDuplicate(server: ServerState) -> Bool { + let existingServer = try? SwiftfinStore + .dataStack + .fetchOne(From().where(\.$id == server.id)) + return existingServer != nil } - func save(server: ServerState) throws { - try SwiftfinStore.dataStack.perform { transaction in - let newServer = transaction.create(Into()) + private func save(server: ServerState) async throws { + try dataStack.perform { transaction in + let newServer = transaction.create(Into()) newServer.urls = server.urls newServer.currentURL = server.currentURL newServer.name = server.name newServer.id = server.id - newServer.os = server.os - newServer.version = server.version newServer.users = [] } + + let publicInfo = try await server.getPublicSystemInfo() + + StoredValues[.Server.publicInfo(id: server.id)] = publicInfo } - func discoverServers() { - isSearching = true - discoveredServers.removeAll() + // server has same id, but (possible) new URL + private func addNewURL(server: ServerState) { + do { + let newState = try dataStack.perform { transaction in + let existingServer = try self.dataStack.fetchOne(From().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 = [] + editServer.urls.insert(server.currentURL) + editServer.currentURL = server.currentURL - discovery.locateServer { server in - if let server = server { - _discoveredServers.insert(.init( - urls: [], - currentURL: server.url, - name: server.name, - id: server.id, - os: "", - version: "", - usersIDs: [] - )) + return editServer.state } - } - // Timeout after 3 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.isSearching = false - self.discoveredServers = _discoveredServers.sorted(by: { $0.name < $1.name }) - } - } - - func add(url: URL, server: ServerState) { - try! SwiftfinStore.dataStack.perform { transaction in - let existingServer = try! SwiftfinStore.dataStack.fetchOne( - From(), - [Where( - "id == %@", - server.id - )] - ) - - let editServer = transaction.edit(existingServer)! - editServer.urls.insert(url) + Notifications[.didChangeCurrentServerURL].post(object: newState) + } catch { + logger.critical("\(error.localizedDescription)") } } } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 31a11c9f..5e843a5b 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -85,24 +85,26 @@ final class HomeViewModel: ViewModel, Stateful { backgroundStates.append(.refresh) backgroundRefreshTask = Task { [weak self] in - guard let self else { return } do { + self?.nextUpViewModel.send(.refresh) + self?.recentlyAddedViewModel.send(.refresh) - nextUpViewModel.send(.refresh) - recentlyAddedViewModel.send(.refresh) - - let resumeItems = try await getResumeItems() + let resumeItems = try await self?.getResumeItems() ?? [] guard !Task.isCancelled else { return } await MainActor.run { + guard let self else { return } self.resumeItems.elements = resumeItems self.backgroundStates.remove(.refresh) } + } catch is CancellationError { + // cancelled } catch { guard !Task.isCancelled else { return } await MainActor.run { + guard let self else { return } self.backgroundStates.remove(.refresh) self.send(.error(.init(error.localizedDescription))) } @@ -127,20 +129,22 @@ final class HomeViewModel: ViewModel, Stateful { refreshTask?.cancel() refreshTask = Task { [weak self] in - guard let self else { return } do { - - try await self.refresh() + try await self?.refresh() guard !Task.isCancelled else { return } await MainActor.run { + guard let self else { return } self.state = .content } + } catch is CancellationError { + // cancelled } catch { guard !Task.isCancelled else { return } await MainActor.run { + guard let self else { return } self.send(.error(.init(error.localizedDescription))) } } diff --git a/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift index dc2dac4a..ef733ff3 100644 --- a/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift @@ -13,7 +13,7 @@ import JellyfinAPI final class NextUpLibraryViewModel: PagingLibraryViewModel { 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] { diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift index b7538d31..7bd88e7e 100644 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -7,6 +7,7 @@ // import Combine +import Defaults import Foundation import Get import JellyfinAPI @@ -23,11 +24,19 @@ private let DefaultPageSize = 50 // on refresh. Should make bidirectional/offset index start? // - use startIndex/index ranges instead of pages // - source of data doesn't guarantee that all items in 0 ..< startIndex exist + +/* + Note: if `rememberSort == true`, then will override given filters with stored sorts + for parent ID. This was just easy. See `PagingLibraryView` notes for lack of + `rememberSort` observation and `StoredValues.User.libraryFilters` for TODO + on remembering other filters. + */ + class PagingLibraryViewModel: ViewModel, Eventful, Stateful { // MARK: Event - enum Event: Equatable { + enum Event { case gotRandomItem(Element) } @@ -103,9 +112,16 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { convenience init( title: String, + id: String?, _ data: some Collection ) { - self.init(data, parent: TitledLibraryParent(displayTitle: title)) + self.init( + data, + parent: TitledLibraryParent( + displayTitle: title, + id: id + ) + ) } // paging @@ -120,6 +136,19 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { self.parent = parent if let filters { + var filters = filters + + if let id = parent?.id, Defaults[.Customization.Library.rememberSort] { + // TODO: see `StoredValues.User.libraryFilters` for TODO + // on remembering other filters + + let storedFilters = StoredValues[.User.libraryFilters(parentID: id)] + + filters = filters + .mutating(\.sortBy, with: storedFilters.sortBy) + .mutating(\.sortOrder, with: storedFilters.sortOrder) + } + self.filterViewModel = .init( parent: parent, currentFilters: filters @@ -148,11 +177,15 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { convenience init( title: String, - filters: ItemFilterCollection = .default, + id: String?, + filters: ItemFilterCollection? = nil, pageSize: Int = DefaultPageSize ) { self.init( - parent: TitledLibraryParent(displayTitle: title), + parent: TitledLibraryParent( + displayTitle: title, + id: id + ), filters: filters, pageSize: pageSize ) diff --git a/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift b/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift index 7ea9bbaa..59347b78 100644 --- a/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift @@ -17,10 +17,11 @@ final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel { // Necessary because this is paginated and also used on home view init(customPageSize: Int? = nil) { + // Why doesn't `super.init(title:id:pageSize)` init work? if let customPageSize { - super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded), pageSize: customPageSize) + super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded"), pageSize: customPageSize) } else { - super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded)) + super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded")) } } diff --git a/Shared/ViewModels/QuickConnectAuthorizeViewModel.swift b/Shared/ViewModels/QuickConnectAuthorizeViewModel.swift new file mode 100644 index 00000000..293258fb --- /dev/null +++ b/Shared/ViewModels/QuickConnectAuthorizeViewModel.swift @@ -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 { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var authorizeTask: AnyCancellable? + private var eventSubject: PassthroughSubject = .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") + } + } +} diff --git a/Shared/ViewModels/QuickConnectSettingsViewModel.swift b/Shared/ViewModels/QuickConnectSettingsViewModel.swift deleted file mode 100644 index a1dad9b7..00000000 --- a/Shared/ViewModels/QuickConnectSettingsViewModel.swift +++ /dev/null @@ -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") - } - } -} diff --git a/Shared/ViewModels/QuickConnectViewModel.swift b/Shared/ViewModels/QuickConnectViewModel.swift deleted file mode 100644 index f7a833ca..00000000 --- a/Shared/ViewModels/QuickConnectViewModel.swift +++ /dev/null @@ -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? - - 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() - } -} diff --git a/Shared/ViewModels/ResetUserPasswordViewModel.swift b/Shared/ViewModels/ResetUserPasswordViewModel.swift new file mode 100644 index 00000000..0d00bf24 --- /dev/null +++ b/Shared/ViewModels/ResetUserPasswordViewModel.swift @@ -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 { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var resetTask: AnyCancellable? + private var eventSubject: PassthroughSubject = .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) + } +} diff --git a/Shared/ViewModels/SelectUserViewModel.swift b/Shared/ViewModels/SelectUserViewModel.swift new file mode 100644 index 00000000..21bbb38c --- /dev/null +++ b/Shared/ViewModels/SelectUserViewModel.swift @@ -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 = [:] + @Published + var state: State = .content + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var eventSubject: PassthroughSubject = .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()) { 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()) + .map(\.state) + .sorted(using: \.name) + } + + private func getUsers(for server: ServerState) throws -> [UserState] { + guard let storedServer = try? dataStack.fetchOne(From().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() + } +} diff --git a/Shared/ViewModels/ServerCheckViewModel.swift b/Shared/ViewModels/ServerCheckViewModel.swift new file mode 100644 index 00000000..a2596390 --- /dev/null +++ b/Shared/ViewModels/ServerCheckViewModel.swift @@ -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 + } + } +} diff --git a/Shared/ViewModels/ServerDetailViewModel.swift b/Shared/ViewModels/ServerDetailViewModel.swift index 2a54caf8..6d0591d1 100644 --- a/Shared/ViewModels/ServerDetailViewModel.swift +++ b/Shared/ViewModels/ServerDetailViewModel.swift @@ -10,7 +10,7 @@ import CoreStore import Foundation import JellyfinAPI -class ServerDetailViewModel: ViewModel { +class EditServerViewModel: ViewModel { @Published var server: ServerState @@ -19,36 +19,59 @@ class ServerDetailViewModel: ViewModel { self.server = server } - func setCurrentServerURL(to url: URL) { + // TODO: this could probably be cleaner + func delete() { - guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( - From(), - [Where("id == %@", server.id)] - ) else { - logger.error("Unable to find server") + guard let storedServer = try? dataStack.fetchOne(From().where(\.$id == server.id)) else { + logger.critical("Unable to find server to delete") return } - guard storedServer.urls.contains(url) else { - logger.error("Server did not have matching URL") - return - } - - let transaction = SwiftfinStore.dataStack.beginUnsafe() - - guard let editServer = transaction.edit(storedServer) else { - logger.error("Unable to create edit server instance") - return - } - - editServer.currentURL = url + let userStates = storedServer.users.map(\.state) + // Note: don't use Server/UserState.delete() to have + // all deletions in a single transaction do { - try transaction.commitAndWait() + try dataStack.perform { transaction in - Notifications[.didChangeCurrentServerURL].post(object: editServer.state) + /// Delete stored data for all users + for user in storedServer.users { + let storedDataClause = AnyStoredData.fetchClause(ownerID: user.id) + let storedData = try transaction.fetchAll(storedDataClause) + + transaction.delete(storedData) + } + + transaction.delete(storedServer.users) + transaction.delete(storedServer) + } + + for user in userStates { + UserDefaults.userSuite(id: user.id).removeAll() + } + + Notifications[.didDeleteServer].post(object: server) } catch { - logger.error("Unable to edit server") + logger.critical("Unable to delete server: \(server.name)") + } + } + + func setCurrentURL(to url: URL) { + do { + let newState = try dataStack.perform { transaction in + guard let storedServer = try transaction.fetchOne(From().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)") } } } diff --git a/Shared/ViewModels/ServerListViewModel.swift b/Shared/ViewModels/ServerListViewModel.swift deleted file mode 100644 index 21c014b5..00000000 --- a/Shared/ViewModels/ServerListViewModel.swift +++ /dev/null @@ -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()) - 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(), - [Where("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()) - - transaction.delete(users) - - let servers = try! transaction.fetchAll(From()) - - for server in servers { - transaction.delete(server.users) - } - - transaction.delete(servers) - } - - fetchServers() - - UserDefaults.generalSuite.removeAll() - UserDefaults.universalSuite.removeAll() - } -} diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 368bec96..3a808baf 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -17,6 +17,8 @@ final class SettingsViewModel: ViewModel { @Published var currentAppIcon: any AppIcon = PrimaryAppIcon.primary + @Published + var servers: [ServerState] = [] override init() { @@ -28,35 +30,31 @@ final class SettingsViewModel: ViewModel { if let appicon = PrimaryAppIcon.createCase(iconName: iconName) { currentAppIcon = appicon - super.init() - return } if let appicon = DarkAppIcon.createCase(iconName: iconName) { currentAppIcon = appicon - super.init() - return } if let appicon = InvertedDarkAppIcon.createCase(iconName: iconName) { currentAppIcon = appicon - super.init() - return } if let appicon = InvertedLightAppIcon.createCase(iconName: iconName) { currentAppIcon = appicon - super.init() - return } if let appicon = LightAppIcon.createCase(iconName: iconName) { currentAppIcon = appicon - super.init() - return } super.init() + + do { + servers = try getServers() + } catch { + logger.critical("Could not retrieve servers") + } } func select(icon: any AppIcon) { @@ -78,21 +76,17 @@ final class SettingsViewModel: ViewModel { } } + private func getServers() throws -> [ServerState] { + try SwiftfinStore + .dataStack + .fetchAll(From()) + .map(\.state) + .sorted(using: \.name) + } + func signOut() { - Defaults[.lastServerUserID] = nil - Container.userSession.reset() + Defaults[.lastSignedInUserID] = nil + UserSession.current.reset() Notifications[.didSignOut].post() } - - func resetUserSettings() { - UserDefaults.generalSuite.removeAll() - } - - func removeAllServers() { - guard let allServers = try? SwiftfinStore.dataStack.fetchAll(From()) else { return } - - try? SwiftfinStore.dataStack.perform { transaction in - transaction.delete(allServers) - } - } } diff --git a/Shared/ViewModels/UserListViewModel.swift b/Shared/ViewModels/UserListViewModel.swift deleted file mode 100644 index 6e1e012b..00000000 --- a/Shared/ViewModels/UserListViewModel.swift +++ /dev/null @@ -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(), - Where("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(), - [Where("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") - } - } -} diff --git a/Shared/ViewModels/UserLocalSecurityViewModel.swift b/Shared/ViewModels/UserLocalSecurityViewModel.swift new file mode 100644 index 00000000..53ce6b87 --- /dev/null +++ b/Shared/ViewModels/UserLocalSecurityViewModel.swift @@ -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 { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var eventSubject: PassthroughSubject = .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 + } +} diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index f8a558c2..f9d74278 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -6,21 +6,48 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Combine import CoreStore -import Defaults import Factory import Foundation +import Get import JellyfinAPI -import Pulse +import KeychainSwift +import OrderedCollections +import SwiftUI -final class UserSignInViewModel: ViewModel, Stateful { +// TODO: instead of just signing in duplicate user, send event for alert +// to override existing user access token? +// - won't require deleting and re-signing in user for password changes +// - account for local device auth required +// TODO: ignore NSURLErrorDomain Code=-999 cancelled error on sign in +// - need to make NSError wrappres anyways + +// Note: UserDto in StoredValues so that it doesn't need to be passed +// around along with the user UserState. Was just easy + +final class UserSignInViewModel: ViewModel, Eventful, Stateful { + + // MARK: Event + + enum Event { + case duplicateUser(UserState) + case error(JellyfinAPIError) + case signedIn(UserState) + } // MARK: Action enum Action: Equatable { - case signInWithUserPass(username: String, password: String) - case signInWithQuickConnect(authSecret: String) - case cancelSignIn + case getPublicData + case signIn(username: String, password: String, policy: UserAccessPolicy) + case signInDuplicate(UserState, replace: Bool) + case signInQuickConnect(secret: String, policy: UserAccessPolicy) + case cancel + } + + enum BackgroundState: Hashable { + case gettingPublicData } // MARK: State @@ -28,180 +55,299 @@ final class UserSignInViewModel: ViewModel, Stateful { enum State: Hashable { case initial case signingIn - case signedIn - case error(SignInError) - } - - // TODO: Add more detailed errors - enum SignInError: Error { - case unknown } + @Published + var backgroundStates: OrderedSet = [] + @Published + var isQuickConnectEnabled = false + @Published + var publicUsers: [UserDto] = [] + @Published + var serverDisclaimer: String? = nil @Published var state: State = .initial - var lastAction: Action? = nil - @Published - private(set) var publicUsers: [UserDto] = [] - @Published - private(set) var quickConnectEnabled = false + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } - private var signInTask: Task? + let quickConnect: QuickConnect + let server: ServerState - let quickConnectViewModel: QuickConnectViewModel - - let client: JellyfinClient - let server: SwiftfinStore.State.Server + private var eventSubject: PassthroughSubject = .init() + private var signInTask: AnyCancellable? init(server: ServerState) { - self.client = JellyfinClient( - configuration: .swiftfinConfiguration(url: server.currentURL), - sessionDelegate: URLSessionProxyDelegate() - ) self.server = server - self.quickConnectViewModel = .init(client: client) + self.quickConnect = QuickConnect(client: server.client) + super.init() + + quickConnect.$state + .sink { [weak self] state in + if case let QuickConnect.State.authenticated(secret: secret) = state { + guard let self else { return } + + Task { + await self.send(.signInQuickConnect(secret: secret, policy: StoredValues[.Temp.userSignInPolicy])) + } + } + } + .store(in: &cancellables) } func respond(to action: Action) -> State { switch action { - case let .signInWithUserPass(username, password): - guard state != .signingIn else { return .signingIn } - Task { + case .getPublicData: + Task { [weak self] in do { - try await signIn(username: username, password: password) + + await MainActor.run { + let _ = self?.backgroundStates.append(.gettingPublicData) + } + + let isQuickConnectEnabled = try await self?.retrieveQuickConnectEnabled() + let publicUsers = try await self?.retrievePublicUsers() + let serverMessage = try await self?.retrieveServerDisclaimer() + + guard let self else { return } + + await MainActor.run { + self.backgroundStates.remove(.gettingPublicData) + self.isQuickConnectEnabled = isQuickConnectEnabled ?? false + self.publicUsers = publicUsers ?? [] + self.serverDisclaimer = serverMessage + } + } catch { + self?.backgroundStates.remove(.gettingPublicData) + } + } + .store(in: &cancellables) + + return state + case let .signIn(username, password, policy): + signInTask?.cancel() + + signInTask = Task { + do { + let user = try await signIn(username: username, password: password, policy: policy) + + if isDuplicate(user: user) { + await MainActor.run { + // user has same id, but new access token + self.eventSubject.send(.duplicateUser(user)) + } + } else { + try await save(user: user) + + await MainActor.run { + self.eventSubject.send(.signedIn(user)) + } + } + + await MainActor.run { + self.state = .initial + } + } catch is CancellationError { + // cancel doesn't matter } catch { await MainActor.run { - state = .error(.unknown) + self.eventSubject.send(.error(.init(error.localizedDescription))) + self.state = .initial } } } + .asAnyCancellable() + return .signingIn - case let .signInWithQuickConnect(authSecret): - guard state != .signingIn else { return .signingIn } - Task { + case let .signInDuplicate(duplicateUser, replace): + if replace { + setNewAccessToken(user: duplicateUser) + } else { + // just need the id, even though this has a different + // access token than stored + eventSubject.send(.signedIn(duplicateUser)) + } + + return state + case let .signInQuickConnect(secret, policy): + signInTask?.cancel() + + signInTask = Task { do { - try await signIn(quickConnectSecret: authSecret) + let user = try await signIn(secret: secret, policy: policy) + + if isDuplicate(user: user) { + await MainActor.run { + // user has same id, but new access token + self.eventSubject.send(.duplicateUser(user)) + } + } else { + try await save(user: user) + + await MainActor.run { + self.eventSubject.send(.signedIn(user)) + } + } + + await MainActor.run { + self.state = .initial + } + } catch is CancellationError { + // cancel doesn't matter } catch { await MainActor.run { - state = .error(.unknown) + self.eventSubject.send(.error(.init(error.localizedDescription))) + self.state = .initial } } } + .asAnyCancellable() + return .signingIn - case .cancelSignIn: - self.signInTask?.cancel() + case .cancel: + signInTask?.cancel() + return .initial } } - private func signIn(username: String, password: String) async throws { - let username = username.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: .objectReplacement) - let password = password.trimmingCharacters(in: .whitespacesAndNewlines) + private func signIn(username: String, password: String, policy: UserAccessPolicy) async throws -> UserState { + let username = username + .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .objectReplacement) - let response = try await client.signIn(username: username, password: password) + let password = password + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .objectReplacement) - let user: UserState + let response = try await server.client.signIn(username: username, password: password) - do { - user = try await createLocalUser(response: response) - } catch { - if case let SwiftfinStore.Error.existingUser(existingUser) = error { - user = existingUser - } else { - throw error - } + guard let accessToken = response.accessToken, + let userData = response.user, + let id = userData.id, + let username = userData.name + else { + logger.critical("Missing user data from network call") + throw JellyfinAPIError("An internal error has occurred") } - Defaults[.lastServerUserID] = user.id - Container.userSession.reset() - Notifications[.didSignIn].post() + StoredValues[.Temp.userData] = userData + StoredValues[.Temp.userSignInPolicy] = policy + + let newState = UserState( + id: id, + serverID: server.id, + username: username + ) + + newState.accessToken = accessToken + + return newState } - private func signIn(quickConnectSecret: String) async throws { - let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret)) - let response = try await client.send(quickConnectPath) + private func signIn(secret: String, policy: UserAccessPolicy) async throws -> UserState { - let user: UserState + let response = try await server.client.signIn(quickConnectSecret: secret) - do { - user = try await createLocalUser(response: response.value) - } catch { - if case let SwiftfinStore.Error.existingUser(existingUser) = error { - user = existingUser - } else { - throw error - } + guard let accessToken = response.accessToken, + let userData = response.user, + let id = userData.id, + let username = userData.name + else { + logger.critical("Missing user data from network call") + throw JellyfinAPIError("An internal error has occurred") } - Defaults[.lastServerUserID] = user.id - Container.userSession.reset() - Notifications[.didSignIn].post() + StoredValues[.Temp.userData] = userData + StoredValues[.Temp.userSignInPolicy] = policy + + let newState = UserState( + id: id, + serverID: server.id, + username: username + ) + + newState.accessToken = accessToken + + return newState } - func getPublicUsers() async throws { - let publicUsersPath = Paths.getPublicUsers - let response = try await client.send(publicUsersPath) - - await MainActor.run { - publicUsers = response.value - } + private func isDuplicate(user: UserState) -> Bool { + let existingUser = try? SwiftfinStore + .dataStack + .fetchOne(From().where(\.$id == user.id)) + return existingUser != nil } @MainActor - private func createLocalUser(response: AuthenticationResult) async throws -> UserState { - guard let accessToken = response.accessToken, - let username = response.user?.name, - let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } + private func save(user: UserState) async throws { - if let existingUser = try? SwiftfinStore.dataStack.fetchOne( - From(), - [Where( - "id == %@", - id - )] - ) { - throw SwiftfinStore.Error.existingUser(existingUser.state) + guard let serverModel = try? dataStack.fetchOne(From().where(\.$id == server.id)) else { + logger.critical("Unable to find server to save user") + throw JellyfinAPIError("An internal error has occurred") } - guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( - From(), - [ - Where( - "id == %@", - server.id - ), - ] - ) - else { fatalError("No stored server associated with given state server?") } - - let user = try SwiftfinStore.dataStack.perform { transaction in + let user = try dataStack.perform { transaction in let newUser = transaction.create(Into()) - newUser.accessToken = accessToken - newUser.appleTVID = "" - newUser.id = id - newUser.username = username + newUser.id = user.id + newUser.username = user.username - let editServer = transaction.edit(storedServer)! + let editServer = transaction.edit(serverModel)! editServer.users.insert(newUser) return newUser.state } - return user + user.data = StoredValues[.Temp.userData] + user.signInPolicy = StoredValues[.Temp.userSignInPolicy] + + keychain.set(StoredValues[.Temp.userLocalPin], forKey: "\(user.id)-pin") + user.pinHint = StoredValues[.Temp.userLocalPinHint] + + // TODO: remove when implemented periodic cleanup elsewhere + StoredValues[.Temp.userSignInPolicy] = .none + StoredValues[.Temp.userLocalPin] = "" + StoredValues[.Temp.userLocalPinHint] = "" } - func checkQuickConnect() async throws { - let quickConnectEnabledPath = Paths.getEnabled - let response = try await client.send(quickConnectEnabledPath) - let decoder = JSONDecoder() - let isEnabled = try? decoder.decode(Bool.self, from: response.value) + private func retrievePublicUsers() async throws -> [UserDto] { + let request = Paths.getPublicUsers + let response = try await server.client.send(request) - await MainActor.run { - quickConnectEnabled = isEnabled ?? false + return response.value + } + + private func retrieveServerDisclaimer() async throws -> String? { + let request = Paths.getBrandingOptions + let response = try await server.client.send(request) + + guard let disclaimer = response.value.loginDisclaimer, disclaimer.isNotEmpty else { return nil } + + return disclaimer + } + + private func retrieveQuickConnectEnabled() async throws -> Bool { + let request = Paths.getEnabled + let response = try await server.client.send(request) + + let isEnabled = try? JSONDecoder().decode(Bool.self, from: response.value) + return isEnabled ?? false + } + + // server has same id, but new access token + private func setNewAccessToken(user: UserState) { + do { + guard let existingUser = try dataStack.fetchOne(From().where(\.$id == user.id)) else { return } + existingUser.state.accessToken = user.accessToken + + eventSubject.send(.signedIn(existingUser.state)) + } catch { + logger.critical("\(error.localizedDescription)") } } } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 1a56caa7..e3636104 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -30,8 +30,6 @@ class VideoPlayerViewModel: ViewModel { var hlsPlaybackURL: URL { - let userSession = Container.userSession() - let parameters = Paths.GetMasterHlsVideoPlaylistParameters( isStatic: true, tag: mediaSource.eTag, diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index 79e81947..d8df8da9 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -9,22 +9,22 @@ import Combine import Factory import Foundation +import KeychainSwift class ViewModel: ObservableObject { + @Injected(SwiftfinStore.service) + var dataStack + + @Injected(Keychain.service) + var keychain + @Injected(LogManager.service) var logger - @Injected(Container.userSession) - var userSession - - // TODO: remove on transition to Stateful - @Published - var error: ErrorMessage? = nil - - // TODO: remove on transition to Stateful - @Published - var isLoading = false + /// The current *signed in* user session + @Injected(UserSession.current) + var userSession: UserSession! var cancellables = Set() diff --git a/Swiftfin tvOS/App/SwiftfinApp.swift b/Swiftfin tvOS/App/SwiftfinApp.swift index 0b7a8eef..bb20c6f7 100644 --- a/Swiftfin tvOS/App/SwiftfinApp.swift +++ b/Swiftfin tvOS/App/SwiftfinApp.swift @@ -7,6 +7,8 @@ // import CoreStore +import Defaults +import Factory import Logging import Pulse import PulseLogHandler @@ -35,7 +37,21 @@ struct SwiftfinApp: App { var body: some Scene { WindowGroup { - MainCoordinator().view() + MainCoordinator() + .view() + .onNotification(UIApplication.didEnterBackgroundNotification) { _ in + Defaults[.backgroundTimeStamp] = Date.now + } + .onNotification(UIApplication.willEnterForegroundNotification) { _ in + // TODO: needs to check if any background playback is happening + let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp]) + + if Defaults[.signOutOnBackground], backgroundedInterval > Defaults[.backgroundSignOutInterval] { + Defaults[.lastSignedInUserID] = nil + UserSession.current.reset() + Notifications[.didSignOut].post() + } + } } } } diff --git a/Swiftfin tvOS/Components/FullScreenMenu.swift b/Swiftfin tvOS/Components/FullScreenMenu.swift new file mode 100644 index 00000000..d035fc5f --- /dev/null +++ b/Swiftfin tvOS/Components/FullScreenMenu.swift @@ -0,0 +1,51 @@ +// +// 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 FullScreenMenu: View { + + private let content: () -> Content + private let title: String + + init(_ title: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.content = content + } + + var body: some View { + ZStack { + Color.black + .opacity(0.5) + + HStack { + Spacer() + + VStack { + Text(title) + .font(.title2) + .fontWeight(.bold) + + ScrollView { + VStack { + content() + } + .padding(.horizontal, 20) + } + .frame(width: 580) + } + .padding(.top, 20) + .background(Material.regular, in: RoundedRectangle(cornerRadius: 30)) + .frame(width: 620) + .padding(100) + .shadow(radius: 50) + } + } + .ignoresSafeArea() + } +} diff --git a/Swiftfin tvOS/Components/ListRowButton.swift b/Swiftfin tvOS/Components/ListRowButton.swift new file mode 100644 index 00000000..0c96b5b4 --- /dev/null +++ b/Swiftfin tvOS/Components/ListRowButton.swift @@ -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 SwiftUI + +struct ListRowButton: View { + + let title: String + let action: () -> Void + + init(_ title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + Button { + action() + } label: { + ZStack { + Rectangle() + .foregroundStyle(.secondary) + + Text(title) + .font(.body.weight(.bold)) + .foregroundStyle(.primary) + } + } + .buttonStyle(.card) + .frame(height: 75) + } +} diff --git a/Swiftfin tvOS/Components/PagingLibraryView.swift b/Swiftfin tvOS/Components/PagingLibraryView.swift index 0cddd13a..7937da2c 100644 --- a/Swiftfin tvOS/Components/PagingLibraryView.swift +++ b/Swiftfin tvOS/Components/PagingLibraryView.swift @@ -21,7 +21,7 @@ struct PagingLibraryView: View { private var cinematicBackground @Default(.Customization.Library.posterType) private var posterType - @Default(.Customization.Library.viewType) + @Default(.Customization.Library.displayType) private var viewType @Default(.Customization.showPosterLabels) private var showPosterLabels @@ -47,7 +47,7 @@ struct PagingLibraryView: View { self._viewModel = StateObject(wrappedValue: viewModel) let initialPosterType = Defaults[.Customization.Library.posterType] - let initialViewType = Defaults[.Customization.Library.viewType] + let initialViewType = Defaults[.Customization.Library.displayType] self._layout = State( initialValue: Self.makeLayout( diff --git a/Swiftfin tvOS/Components/UserProfileButton.swift b/Swiftfin tvOS/Components/UserProfileButton.swift deleted file mode 100644 index bc4edf88..00000000 --- a/Swiftfin tvOS/Components/UserProfileButton.swift +++ /dev/null @@ -1,64 +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 Factory -import JellyfinAPI -import SwiftUI - -struct UserProfileButton: View { - - @Injected(Container.userSession) - private var userSession - - @FocusState - private var isFocused: Bool - - let user: UserDto - private var action: () -> Void - - init(user: UserDto) { - self.user = user - self.action = {} - } - - init(user: UserState) { - self.init(user: .init(id: user.id, name: user.username)) - } - - var body: some View { - VStack(alignment: .center) { - Button { - action() - } label: { - ZStack { - Color.clear - - ImageView(user.profileImageSource(client: userSession.client, maxWidth: 250, maxHeight: 250)) - .failure { - Image(systemName: "person.fill") - .resizable() - .edgePadding() - } - } - .aspectRatio(1, contentMode: .fill) - } - .buttonStyle(.card) - .focused($isFocused) - - Text(user.name ?? .emptyDash) - .foregroundColor(isFocused ? .primary : .secondary) - } - } -} - -extension UserProfileButton { - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.action, with: action) - } -} diff --git a/Swiftfin tvOS/Views/AppLoadingView.swift b/Swiftfin tvOS/Views/AppLoadingView.swift new file mode 100644 index 00000000..00875233 --- /dev/null +++ b/Swiftfin tvOS/Views/AppLoadingView.swift @@ -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 SwiftUI + +/// The loading view for the app when migrations are taking place +struct AppLoadingView: View { + + @State + private var didFailMigration = false + + var body: some View { + ZStack { + Color.clear + + if didFailMigration { + Text("An internal error occurred.") + } + } + .onNotification(.didFailMigration) { _ in + didFailMigration = true + } + } +} diff --git a/Swiftfin tvOS/Views/BasicAppSettingsView.swift b/Swiftfin tvOS/Views/BasicAppSettingsView.swift index 0460540e..145467f6 100644 --- a/Swiftfin tvOS/Views/BasicAppSettingsView.swift +++ b/Swiftfin tvOS/Views/BasicAppSettingsView.swift @@ -10,72 +10,81 @@ import Defaults import Stinsen import SwiftUI -struct BasicAppSettingsView: View { +#warning("TODO: implement") - @EnvironmentObject - private var router: BasicAppSettingsCoordinator.Router - - @ObservedObject - var viewModel: SettingsViewModel - - @State - private var resetUserSettingsSelected: Bool = false - @State - private var removeAllServersSelected: Bool = false +struct AppSettingsView: View { var body: some View { - SplitFormWindowView() - .descriptionView { - Image(.jellyfinBlobBlue) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 400) - } - .contentView { - - Section { - - Button { - TextPairView( - leading: L10n.version, - trailing: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" - ) - } - - ChevronButton(title: "Logs") - .onSelect { - router.route(to: \.log) - } - } - - Section { - - Button { - resetUserSettingsSelected = true - } label: { - L10n.resetUserSettings.text - } - - Button { - removeAllServersSelected = true - } label: { - Text("Remove All Servers") - } - } - } - .withDescriptionTopPadding() - .navigationTitle(L10n.settings) - .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsSelected) { - Button(L10n.reset, role: .destructive) { - viewModel.resetUserSettings() - } - } message: { - Text("Reset all settings back to defaults.") - } - .alert("Remove All Servers", isPresented: $removeAllServersSelected) { - Button(L10n.reset, role: .destructive) { - viewModel.removeAllServers() - } - } + Text("TODO") } } + +// struct BasicAppSettingsView: View { +// +// @EnvironmentObject +// private var router: BasicAppSettingsCoordinator.Router +// +// @ObservedObject +// var viewModel: SettingsViewModel +// +// @State +// private var resetUserSettingsSelected: Bool = false +// @State +// private var removeAllServersSelected: Bool = false +// +// var body: some View { +// SplitFormWindowView() +// .descriptionView { +// Image(.jellyfinBlobBlue) +// .resizable() +// .aspectRatio(contentMode: .fit) +// .frame(maxWidth: 400) +// } +// .contentView { +// +// Section { +// +// Button { +// TextPairView( +// leading: L10n.version, +// trailing: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" +// ) +// } +// +// ChevronButton(title: "Logs") +// .onSelect { +// router.route(to: \.log) +// } +// } +// +// Section { +// +// Button { +// resetUserSettingsSelected = true +// } label: { +// L10n.resetUserSettings.text +// } +// +// Button { +// removeAllServersSelected = true +// } label: { +// Text("Remove All Servers") +// } +// } +// } +// .withDescriptionTopPadding() +// .navigationTitle(L10n.settings) +// .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsSelected) { +// Button(L10n.reset, role: .destructive) { +//// viewModel.resetUserSettings() +// } +// } message: { +// Text("Reset all settings back to defaults.") +// } +// .alert("Remove All Servers", isPresented: $removeAllServersSelected) { +// Button(L10n.reset, role: .destructive) { +//// viewModel.removeAllServers() +// } +// } +// } +// } diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift index 735cbacb..4b473a3f 100644 --- a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift +++ b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift @@ -35,9 +35,8 @@ extension ChannelLibraryView { $0.aspectRatio(contentMode: .fit) } .failure { - SystemImageContentView(systemName: channel.systemImage) + SystemImageContentView(systemName: channel.systemImage, ratio: 0.66) .background(color: .clear) - .imageFrameRatio(width: 1.5, height: 1.5) } .placeholder { _ in EmptyView() diff --git a/Swiftfin tvOS/Views/ConnectToServerView.swift b/Swiftfin tvOS/Views/ConnectToServerView.swift index bc515108..7fe228ad 100644 --- a/Swiftfin tvOS/Views/ConnectToServerView.swift +++ b/Swiftfin tvOS/Views/ConnectToServerView.swift @@ -6,194 +6,190 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Combine import Defaults -import Stinsen import SwiftUI struct ConnectToServerView: View { + @Default(.accentColor) + private var accentColor + @EnvironmentObject - private var router: ConnectToServerCoodinator.Router + private var router: SelectUserCoordinator.Router - @ObservedObject - var viewModel: ConnectToServerViewModel + @FocusState + private var isURLFocused: Bool @State - private var connectionError: Error? + private var duplicateServer: ServerState? = nil @State - private var connectionTask: Task? + private var error: Error? = nil @State - private var duplicateServer: (server: ServerState, url: URL)? - @State - private var isConnecting: Bool = false - @State - private var isPresentingConnectionError: Bool = false - @State - private var isPresentingDuplicateServerAlert: Bool = false + private var isPresentingDuplicateServer: Bool = false @State private var isPresentingError: Bool = false @State - private var url = "" + private var url: String = "" - private func connectToServer(at url: String) { - let task = Task { - isConnecting = true - connectionError = nil + @StateObject + private var viewModel = ConnectToServerViewModel() - do { - let serverConnection = try await viewModel.connectToServer(url: url) - - if viewModel.isDuplicate(server: serverConnection.server) { - duplicateServer = serverConnection - isPresentingDuplicateServerAlert = true - } else { - try viewModel.save(server: serverConnection.server) - router.route(to: \.userSignIn, serverConnection.server) - } - } catch { - connectionError = error - isPresentingConnectionError = true - } - - isConnecting = false - } - - connectionTask = task - } + private let timer = Timer.publish(every: 12, on: .main, in: .common).autoconnect() @ViewBuilder - private var connectForm: some View { - VStack(alignment: .leading) { - - L10n.connectToJellyfinServer.text - + private var connectSection: some View { + Section(L10n.connectToServer) { TextField(L10n.serverURL, text: $url) .disableAutocorrection(true) - .autocapitalization(.none) + .textInputAutocapitalization(.never) .keyboardType(.URL) + .focused($isURLFocused) + } - if isConnecting { - Button { - connectionTask?.cancel() - isConnecting = false - } label: { - L10n.cancel.text - .foregroundColor(.red) - .bold() - .font(.callout) - .frame(height: 75) - .frame(maxWidth: .infinity) - } - .buttonStyle(.card) - } else { - Button { - if !url.contains("://") { - url = "http://" + url - } - connectToServer(at: url) - } label: { - L10n.connect.text - .bold() - .font(.callout) - .frame(height: 75) - .frame(maxWidth: .infinity) - .background { - if isConnecting || url.isEmpty { - Color.secondary - } else { - Color.jellyfinPurple - } - } - } - .disabled(isConnecting || url.isEmpty) - .buttonStyle(.card) + if viewModel.state == .connecting { +// ListRowButton(L10n.cancel) { +// viewModel.send(.cancel) +// } + Button(L10n.cancel) { + viewModel.send(.cancel) } - - Spacer() + .foregroundStyle(.red, .red.opacity(0.2)) + } else { +// ListRowButton(L10n.connect) { +// isURLFocused = false +// viewModel.send(.connect(url)) +// } + Button(L10n.connect) { + isURLFocused = false + viewModel.send(.connect(url)) + } + .disabled(url.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .opacity(url.isEmpty ? 0.5 : 1) } } - @ViewBuilder - private var searchingDiscoverServers: some View { - HStack(spacing: 5) { - ProgressView() - - L10n.searchingDots.text - .foregroundColor(.secondary) - } - } - - @ViewBuilder - private var noLocalServersFound: some View { - L10n.noLocalServersFound.text - .font(.callout) - .foregroundColor(.secondary) - } - - @ViewBuilder - private var publicServers: some View { - VStack(alignment: .center) { - + private func localServerButton(for server: ServerState) -> some View { + Button { + url = server.currentURL.absoluteString + viewModel.send(.connect(server.currentURL.absoluteString)) + } label: { HStack { - L10n.localServers.text - .font(.title3) - .fontWeight(.semibold) + VStack(alignment: .leading) { + Text(server.name) + .font(.headline) + .fontWeight(.semibold) - SFSymbolButton(systemName: "arrow.clockwise") - .onSelect { - viewModel.discoverServers() - } - .frame(width: 30, height: 30) - .disabled(viewModel.isSearching || viewModel.isLoading) + Text(server.currentURL.absoluteString) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) } + } + .disabled(viewModel.state == .connecting) + .buttonStyle(.plain) + } - if viewModel.isSearching { - searchingDiscoverServers - .frame(maxHeight: .infinity) - } else if viewModel.discoveredServers.isEmpty { - noLocalServersFound - .frame(maxHeight: .infinity) + private var localServersSection: some View { + Section(L10n.localServers) { + if viewModel.localServers.isEmpty { + L10n.noLocalServersFound.text + .font(.callout) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) } else { - ScrollView { - VStack { - ForEach(viewModel.discoveredServers, id: \.id) { server in - ServerButton(server: server) - .onSelect { - connectToServer(at: server.currentURL.absoluteString) - } - } - } - .padding() + ForEach(viewModel.localServers) { server in + localServerButton(for: server) } } } } var body: some View { - HStack(alignment: .top) { - connectForm - .frame(maxWidth: .infinity) + VStack { + HStack { + Spacer() - publicServers - .frame(maxWidth: .infinity) + if viewModel.state == .connecting { + ProgressView() + } + } + .frame(height: 100) + .overlay { + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 100) + .edgePadding() + } + + HStack(alignment: .top) { + VStack(alignment: .leading) { + connectSection + } + + VStack(alignment: .leading) { + localServersSection + } + } + + Spacer() } - .navigationTitle(L10n.connect.text) - .onAppear { - viewModel.discoverServers() + .onFirstAppear { + isURLFocused = true + viewModel.send(.searchForServers) + } + .onReceive(viewModel.events) { event in + switch event { + case let .connected(server): + Notifications[.didConnectToServer].post(object: server) + router.popLast() + case let .duplicateServer(server): + duplicateServer = server + isPresentingDuplicateServer = true + case let .error(eventError): + error = eventError + isPresentingError = true + isURLFocused = true + } + } + .onReceive(timer) { _ in + guard viewModel.state != .connecting else { return } + + viewModel.send(.searchForServers) + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .destructive) + } message: { error in + Text(error.localizedDescription) + } + .alert( + L10n.server.text, + isPresented: $isPresentingDuplicateServer, + presenting: duplicateServer + ) { server in + Button(L10n.dismiss, role: .destructive) + + Button(L10n.addURL) { + viewModel.send(.addNewURL(server)) + router.popLast() + } + } message: { server in + Text("\(server.name) is already connected.") } -// .alert(item: $viewModel.errorMessage) { _ in -// Alert( -// title: Text(viewModel.alertTitle), -// message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), -// dismissButton: .cancel() -// ) -// } -// .alert(item: $viewModel.addServerURIPayload) { _ in -// Alert( -// title: L10n.existingServer.text, -// message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? .emptyDash).text, -// dismissButton: .cancel() -// ) -// } } } diff --git a/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift b/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift index 41ed5410..428f3cdc 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift @@ -10,6 +10,8 @@ import SwiftUI // TODO: make general `ErrorView` like iOS +#warning("TODO: implement") + extension HomeView { struct ErrorView: View { @@ -17,39 +19,52 @@ extension HomeView { @ObservedObject var viewModel: HomeViewModel - let errorMessage: ErrorMessage - var body: some View { - VStack { - if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 72)) - .foregroundColor(Color.red) - .frame(width: 100, height: 100) - } - -// Text("\(errorMessage.code)") - - Text(errorMessage.message) - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - Button { -// viewModel.refresh() - } label: { - L10n.retry.text - .bold() - .font(.callout) - .frame(width: 400, height: 75) - .background(Color.jellyfinPurple) - } - .buttonStyle(.card) - } - .offset(y: -50) + Text("TODO") } } } + +// extension HomeView { +// +// struct ErrorView: View { +// +// @ObservedObject +// var viewModel: HomeViewModel +// +// let errorMessage: ErrorMessage +// +// var body: some View { +// VStack { +// if viewModel.isLoading { +// ProgressView() +// .frame(width: 100, height: 100) +// .scaleEffect(2) +// } else { +// Image(systemName: "xmark.circle.fill") +// .font(.system(size: 72)) +// .foregroundColor(Color.red) +// .frame(width: 100, height: 100) +// } +// +//// Text("\(errorMessage.code)") +// +// Text(errorMessage.message) +// .frame(minWidth: 50, maxWidth: 240) +// .multilineTextAlignment(.center) +// +// Button { +//// viewModel.refresh() +// } label: { +// L10n.retry.text +// .bold() +// .font(.callout) +// .frame(width: 400, height: 75) +// .background(Color.jellyfinPurple) +// } +// .buttonStyle(.card) +// } +// .offset(y: -50) +// } +// } +// } diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift index 5dc48044..79884581 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeView.swift @@ -8,7 +8,6 @@ import Defaults import Foundation -import Introspect import JellyfinAPI import SwiftUI diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift index f33c2069..8886d7af 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift @@ -23,7 +23,11 @@ extension ItemView { Group { if viewModel.item.userData?.isPlayed ?? false { Image(systemName: "checkmark.circle.fill") - .paletteOverlayRendering(color: .white) + .symbolRenderingMode(.palette) + .foregroundStyle( + .primary, + Color.jellyfinPurple + ) } else { Image(systemName: "checkmark.circle") } diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift index 0b11e5b6..a0d9c96a 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift @@ -7,7 +7,6 @@ // import CollectionHStack -import Introspect import JellyfinAPI import SwiftUI diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift index 7bce0851..2a736e5b 100644 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/ItemView.swift @@ -7,7 +7,6 @@ // import Defaults -import Introspect import JellyfinAPI import SwiftUI diff --git a/Swiftfin tvOS/Views/MediaView/MediaView.swift b/Swiftfin tvOS/Views/MediaView/MediaView.swift index 6778606e..a43f0925 100644 --- a/Swiftfin tvOS/Views/MediaView/MediaView.swift +++ b/Swiftfin tvOS/Views/MediaView/MediaView.swift @@ -39,6 +39,7 @@ struct MediaView: View { case .favorites: let viewModel = ItemLibraryViewModel( title: L10n.favorites, + id: "favorites", filters: .favorites ) router.route(to: \.library, viewModel) diff --git a/Swiftfin tvOS/Views/QuickConnectView.swift b/Swiftfin tvOS/Views/QuickConnectView.swift index 2fa06703..a573eaf5 100644 --- a/Swiftfin tvOS/Views/QuickConnectView.swift +++ b/Swiftfin tvOS/Views/QuickConnectView.swift @@ -6,88 +6,70 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import SwiftUI +// TODO: change to a fullscreen alert-like view instead of a plain modal + struct QuickConnectView: View { + + @EnvironmentObject + private var router: UserSignInCoordinator.Router + @ObservedObject - var viewModel: QuickConnectViewModel - @Binding - var isPresentingQuickConnect: Bool - // Once the auth secret is fetched, run this and dismiss this view - var signIn: @MainActor (_: String) -> Void + private var viewModel: QuickConnect - func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { - Text(quickConnectCode) - .tracking(10) - .font(.title) - .monospacedDigit() - .frame(maxWidth: .infinity) + init(quickConnect: QuickConnect) { + self.viewModel = quickConnect } - var quickConnectFailed: some View { - Label { - Text("Failed to retrieve quick connect code") - } icon: { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - } - } - - var quickConnectLoading: some View { - ProgressView() - } - - @ViewBuilder - var quickConnectBody: some View { - switch viewModel.state { - case let .awaitingAuthentication(code): - quickConnectWaitingAuthentication(quickConnectCode: code) - case .initial, .fetchingSecret, .authenticated: - quickConnectLoading - case .error: - quickConnectFailed + private func pollingView(code: String) -> some View { + VStack(alignment: .leading, spacing: 20) { + + // TODO: change strings so that numbers are removed + // and use `BulletedList` + // - also probably rephrase/change steps + + L10n.quickConnectStep1.text + + L10n.quickConnectStep2.text + + L10n.quickConnectStep3.text + .padding(.bottom) + + Text(code) + .tracking(10) + .font(.largeTitle) + .monospacedDigit() + .frame(maxWidth: .infinity) + + Spacer() } + .frame(maxWidth: .infinity) + .edgePadding() } var body: some View { - VStack(alignment: .center) { - L10n.quickConnect.text - .font(.title3) - .fontWeight(.semibold) - - Group { - VStack(alignment: .leading, spacing: 20) { - L10n.quickConnectStep1.text - - L10n.quickConnectStep2.text - - L10n.quickConnectStep3.text - } - .padding(.vertical) - - quickConnectBody - } - .padding(.bottom) - - Button { - isPresentingQuickConnect = false - } label: { - L10n.close.text - .frame(width: 400, height: 75) - } - .buttonStyle(.plain) - } - .onChange(of: viewModel.state) { newState in - if case let .authenticated(secret: secret) = newState { - signIn(secret) - isPresentingQuickConnect = false + WrappedView { + switch viewModel.state { + case .idle, .authenticated: + Color.clear + case .retrievingCode: + ProgressView() + case let .polling(code): + pollingView(code: code) + case let .error(error): + Text(error.localizedDescription) +// ErrorView(error: error) } } - .onAppear { - viewModel.send(.startQuickConnect) + .edgePadding() + .navigationTitle(L10n.quickConnect) + .onFirstAppear { + viewModel.start() } .onDisappear { - viewModel.send(.cancelQuickConnect) + viewModel.stop() } } } diff --git a/Swiftfin tvOS/Views/SelectServerView.swift b/Swiftfin tvOS/Views/SelectServerView.swift new file mode 100644 index 00000000..2f00e174 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectServerView.swift @@ -0,0 +1,126 @@ +// +// 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 CollectionVGrid +import SwiftUI + +struct SelectServerView: View { + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @Binding + private var serverSelection: SelectUserServerSelection + + @ObservedObject + private var viewModel: SelectUserViewModel + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = viewModel.servers.keys.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + init( + selection: Binding, + viewModel: SelectUserViewModel + ) { + self._serverSelection = selection + self.viewModel = viewModel + } + + var body: some View { + FullScreenMenu("Servers") { + Section { + Button { + router.popLast { + router.route(to: \.connectToServer) + } + } label: { + HStack { + Text("Add Server") + + Spacer() + + Image(systemName: "plus") + } + } + + if let selectedServer { + Button { + router.popLast { + router.route(to: \.editServer, selectedServer) + } + } label: { + HStack { + Text("Edit Server") + + Spacer() + + Image(systemName: "server.rack") + } + } + } + } + + Section { + + if viewModel.servers.keys.count > 1 { + Button { + serverSelection = .all + router.popLast() + } label: { + HStack { + Text("All Servers") + + Spacer() + + if serverSelection == .all { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + + ForEach(viewModel.servers.keys.reversed()) { server in + Button { + serverSelection = .server(id: server.id) + router.popLast() + } label: { + HStack { + VStack(alignment: .leading) { + Text(server.name) + .font(.headline) + .fontWeight(.semibold) + + Text(server.currentURL.absoluteString) + .font(.subheadline) + .foregroundColor(.primary) + } + + Spacer() + + if selectedServer == server { + Image(systemName: "checkmark.circle.fill") + } + } + .padding() + } + .buttonStyle(.card) + } + } header: { + Text("Servers") + } + .headerProminence(.increased) + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift new file mode 100644 index 00000000..1391e34e --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift @@ -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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserButton: View { + + @Binding + private var serverSelection: SelectUserServerSelection + + @Environment(\.isEnabled) + private var isEnabled + + private let action: (ServerState) -> Void + private let servers: OrderedSet + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = servers.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + init( + serverSelection: Binding, + servers: OrderedSet, + action: @escaping (ServerState) -> Void + ) { + self._serverSelection = serverSelection + self.action = action + self.servers = servers + } + + var body: some View { + VStack { + Button { + if let selectedServer { + action(selectedServer) + } + } label: { + ZStack { + Color.secondarySystemFill + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + .buttonStyle(.card) + .buttonBorderShape(.circleBackport) + .disabled(!isEnabled) + + Text("Add User") + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + + if serverSelection == .all { + Text("Hidden") + .font(.footnote) + .hidden() + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift new file mode 100644 index 00000000..91cc45a9 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -0,0 +1,75 @@ +// +// 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 + +extension SelectUserView { + + struct ServerSelectionMenu: View { + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @Binding + private var serverSelection: SelectUserServerSelection + + @ObservedObject + private var viewModel: SelectUserViewModel + + @State + private var isPresentingServers: Bool = false + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = viewModel.servers.keys.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + init( + selection: Binding, + viewModel: SelectUserViewModel + ) { + self._serverSelection = selection + self.viewModel = viewModel + } + + var body: some View { + Button { + let parameters = SelectUserCoordinator.SelectServerParameters( + selection: _serverSelection, + viewModel: viewModel + ) + + router.route(to: \.selectServer, parameters) + } label: { + ZStack { + + Group { + switch serverSelection { + case .all: + Label("All Servers", systemImage: "person.2.fill") + case let .server(id): + if let server = viewModel.servers.keys.first(where: { $0.id == id }) { + Label(server.name, systemImage: "server.rack") + } + } + } + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + } + .frame(height: 50) + .frame(maxWidth: 400) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift new file mode 100644 index 00000000..615cfb00 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift @@ -0,0 +1,126 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension SelectUserView { + + struct UserGridButton: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + private let user: UserState + private let server: ServerState + private let showServer: Bool + private let action: () -> Void + private let onDelete: () -> Void + + init( + user: UserState, + server: ServerState, + showServer: Bool, + action: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.server = server + self.showServer = showServer + self.action = action + self.onDelete = onDelete + } + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + + return isSelected ? .primary : .secondary + } + + private var personView: some View { + ZStack { + Color.secondarySystemFill + + RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + + var body: some View { + VStack { + Button { + action() + } label: { + VStack(alignment: .center) { + ZStack { + Color.clear + + ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) + .image { image in + image + .posterBorder(ratio: 1 / 2, of: \.width) + } + .placeholder { _ in + personView + } + .failure { + personView + } + } + .aspectRatio(1, contentMode: .fill) + } + } + .buttonStyle(.card) + .buttonBorderShape(.circleBackport) + // .contextMenu { + // Button("Delete", role: .destructive) { + // onDelete() + // } + // } + + Text(user.username) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(labelForegroundStyle) + .lineLimit(1) + + if showServer { + Text(server.name) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .overlay { + if isEditing { + ZStack(alignment: .bottomTrailing) { + Color.black + .opacity(isSelected ? 0 : 0.5) + .clipShape(.circle) + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40, alignment: .bottomTrailing) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift new file mode 100644 index 00000000..27f55765 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift @@ -0,0 +1,342 @@ +// +// 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 CollectionVGrid +import Defaults +import Factory +import JellyfinAPI +import OrderedCollections +import SwiftUI + +// TODO: user deletion + +struct SelectUserView: View { + + private enum UserGridItem: Hashable { + case user(UserState, server: ServerState) + case addUser + } + + @Default(.selectUserServerSelection) + private var serverSelection + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @State + private var contentSize: CGSize = .zero + @State + private var error: Error? = nil + @State + private var gridItems: OrderedSet = [] + @State + private var gridItemSize: CGSize = .zero + @State + private var isPresentingError: Bool = false + @State + private var isPresentingServers: Bool = false + @State + private var padGridItemColumnCount: Int = 1 + @State + private var scrollViewOffset: CGFloat = 0 + @State + private var splashScreenImageSource: ImageSource? = nil + + @StateObject + private var viewModel = SelectUserViewModel() + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = viewModel.servers.keys.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + private func makeGridItems(for serverSelection: SelectUserServerSelection) -> OrderedSet { + switch serverSelection { + case .all: + let items = viewModel.servers + .map { server, users in + users.map { (server: server, user: $0) } + } + .flatMap { $0 } + .sorted(using: \.user.username) + .reversed() + .map { UserGridItem.user($0.user, server: $0.server) } + .appending(.addUser) + + return OrderedSet(items) + case let .server(id: id): + guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { + assertionFailure("server with ID not found?") + return [.addUser] + } + + let items = viewModel.servers[server]! + .sorted(using: \.username) + .map { UserGridItem.user($0, server: server) } + .appending(.addUser) + + return OrderedSet(items) + } + } + + // For all server selection, .all is random + private func makeSplashScreenImageSource( + serverSelection: SelectUserServerSelection, + allServersSelection: SelectUserServerSelection + ) -> ImageSource? { + switch (serverSelection, allServersSelection) { + case (.all, .all): + return viewModel + .servers + .keys + .randomElement()? + .splashScreenImageSource() + + // need to evaluate server with id selection first + case let (.server(id), _), let (.all, .server(id)): + return viewModel + .servers + .keys + .first { $0.id == id }? + .splashScreenImageSource() + } + } + + // MARK: grid + + private func gridItemOffset(index: Int) -> CGFloat { + let lastRowIndices = (gridItems.count - gridItems.count % padGridItemColumnCount ..< gridItems.count) + + guard lastRowIndices.contains(index) else { return 0 } + + let lastRowMissing = padGridItemColumnCount - gridItems.count % padGridItemColumnCount + return CGFloat(lastRowMissing) * (gridItemSize.width + EdgeInsets.edgePadding) / 2 + } + + @ViewBuilder + private var gridContentView: some View { + let columns = Array(repeating: GridItem(.flexible(), spacing: EdgeInsets.edgePadding), count: 5) + + LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) { + ForEach(Array(gridItems.enumerated().map(\.offset)), id: \.hashValue) { index in + let item = gridItems[index] + + gridItemView(for: item) + .trackingSize($gridItemSize) + .offset(x: gridItemOffset(index: index)) + } + } + .padding(EdgeInsets.edgePadding * 2.5) + .onChange(of: gridItemSize) { newValue in + let columns = Int(contentSize.width / (newValue.width + EdgeInsets.edgePadding)) + + padGridItemColumnCount = columns + } + } + + @ViewBuilder + private func gridItemView(for item: UserGridItem) -> some View { + switch item { + case let .user(user, server): + UserGridButton( + user: user, + server: server, + showServer: serverSelection == .all + ) { +// if isEditingUsers { +// selectedUsers.toggle(value: user) +// } else { + viewModel.send(.signIn(user, pin: "")) +// } + } onDelete: { +// selectedUsers.insert(user) +// isPresentingConfirmDeleteUsers = true + } +// .environment(\.isEditing, isEditingUsers) +// .environment(\.isSelected, selectedUsers.contains(user)) + case .addUser: + AddUserButton( + serverSelection: $serverSelection, + servers: viewModel.servers.keys + ) { server in + router.route(to: \.userSignIn, server) + } + } + } + + // MARK: userView + + @ViewBuilder + private var userView: some View { + VStack { + ZStack { + Color.clear + .trackingSize($contentSize) + + VStack(spacing: 0) { + + Color.clear + .frame(height: 100) + + gridContentView + } + .scroll(ifLargerThan: contentSize.height - 100) + .scrollViewOffset($scrollViewOffset) + } + + HStack { + ServerSelectionMenu( + selection: $serverSelection, + viewModel: viewModel + ) + } + } + .animation(.linear(duration: 0.1), value: scrollViewOffset) + .background { + if let splashScreenImageSource { + ZStack { + Color.clear + + ImageView(splashScreenImageSource) + .aspectRatio(contentMode: .fill) + .id(splashScreenImageSource) + .transition(.opacity) + .animation(.linear, value: splashScreenImageSource) + + Color.black + .opacity(0.9) + } + .ignoresSafeArea() + } + } + } + + // MARK: emptyView + + @ViewBuilder + private var emptyView: some View { + ZStack { + VStack { + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 100) + .edgePadding() + + Color.clear + } + + VStack(spacing: 50) { + L10n.connectToJellyfinServerStart.text + .font(.body) + .frame(minWidth: 50, maxWidth: 500) + .multilineTextAlignment(.center) + + Button { + router.route(to: \.connectToServer) + } label: { + L10n.connect.text + .font(.callout) + .fontWeight(.bold) + .frame(width: 400, height: 75) + .background(Color.jellyfinPurple) + } + .buttonStyle(.card) + } + } + } + + var body: some View { + ZStack { + if viewModel.servers.isEmpty { + emptyView + } else { + userView + } + } + .ignoresSafeArea() + .onAppear { + viewModel.send(.getServers) + + splashScreenImageSource = makeSplashScreenImageSource( + serverSelection: serverSelection, + allServersSelection: .all + ) + +// gridItems = OrderedSet( +// (0 ..< 20) +// .map { i in +// UserState(accessToken: "", id: "\(i)", serverID: "", username: "\(i)") +// } +// .map { u in +// UserGridItem.user(u, server: .init(urls: [], currentURL: URL(string: "/")!, name: "Test", id: "", usersIDs: [])) +// } +// ) + } + .onChange(of: serverSelection) { newValue in + gridItems = makeGridItems(for: newValue) + + splashScreenImageSource = makeSplashScreenImageSource( + serverSelection: newValue, + allServersSelection: .all + ) + } + .onChange(of: viewModel.servers) { _ in + gridItems = makeGridItems(for: serverSelection) + + splashScreenImageSource = makeSplashScreenImageSource( + serverSelection: serverSelection, + allServersSelection: .all + ) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + self.error = eventError + self.isPresentingError = true + case let .signedIn(user): + Defaults[.lastSignedInUserID] = user.id + UserSession.current.reset() + Notifications[.didSignIn].post() + } + } + .onNotification(.didConnectToServer) { notification in + if let server = notification.object as? ServerState { + viewModel.send(.getServers) + serverSelection = .server(id: server.id) + } + } + .onNotification(.didChangeCurrentServerURL) { notification in + if let server = notification.object as? ServerState { + viewModel.send(.getServers) + serverSelection = .server(id: server.id) + } + } + .onNotification(.didDeleteServer) { notification in + viewModel.send(.getServers) + + if let server = notification.object as? ServerState { + if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id { + if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first { + serverSelection = .server(id: first.id) + } else { + serverSelection = .all + } + } + + // change splash screen selection if necessary +// selectUserAllServersSplashscreen = serverSelection + } + } + } +} diff --git a/Swiftfin tvOS/Views/ServerDetailView.swift b/Swiftfin tvOS/Views/ServerDetailView.swift index 3360e560..b6eabff0 100644 --- a/Swiftfin tvOS/Views/ServerDetailView.swift +++ b/Swiftfin tvOS/Views/ServerDetailView.swift @@ -8,13 +8,22 @@ import SwiftUI -struct ServerDetailView: View { +struct EditServerView: View { + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @Environment(\.isEditing) + private var isEditing + + @State + private var isPresentingConfirmDeletion: Bool = false @StateObject - private var viewModel: ServerDetailViewModel + private var viewModel: EditServerViewModel init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: ServerDetailViewModel(server: server)) + self._viewModel = StateObject(wrappedValue: EditServerViewModel(server: server)) } var body: some View { @@ -26,30 +35,41 @@ struct ServerDetailView: View { .frame(maxWidth: 400) } .contentView { - Section(header: L10n.serverDetails.text) { - + Section(L10n.server) { TextPairView( leading: L10n.name, trailing: viewModel.server.name ) + } - TextPairView( - leading: L10n.url, - trailing: viewModel.server.currentURL.absoluteString - ) + Section("URL") { + ForEach(viewModel.server.urls.sorted(using: \.absoluteString)) { url in + if url == viewModel.server.currentURL { + Button(url.absoluteString, systemImage: "checkmark") {} + } else { + Button(url.absoluteString) { + viewModel.setCurrentURL(to: url) + } + } + } + } - TextPairView( - leading: L10n.version, - trailing: viewModel.server.version - ) - - TextPairView( - leading: L10n.operatingSystem, - trailing: viewModel.server.os - ) + if isEditing { + ListRowButton("Delete") { + isPresentingConfirmDeletion = true + } + .foregroundStyle(.primary, .red.opacity(0.5)) } } .withDescriptionTopPadding() .navigationTitle(L10n.server) + .alert("Delete Server", isPresented: $isPresentingConfirmDeletion) { + Button("Delete", role: .destructive) { + viewModel.delete() + router.popLast() + } + } message: { + Text("Are you sure you want to delete \(viewModel.server.name) and all of its connected users?") + } } } diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift deleted file mode 100644 index 8c4d126d..00000000 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ /dev/null @@ -1,136 +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 CollectionVGrid -import SwiftUI - -struct ServerListView: View { - - @EnvironmentObject - private var router: ServerListCoordinator.Router - - @ObservedObject - var viewModel: ServerListViewModel - - @State - private var longPressedServer: SwiftfinStore.State.Server? - - @ViewBuilder - private var listView: some View { - CollectionVGrid( - viewModel.servers, - layout: .columns( - 1, - insets: EdgeInsets.edgeInsets, - itemSpacing: EdgeInsets.edgePadding, - lineSpacing: EdgeInsets.edgePadding - ) - ) { server in - ServerButton(server: server) - .onSelect { - router.route(to: \.userList, server) - } - .onLongPressGesture { - longPressedServer = server - } - } - } - - @ViewBuilder - private var noServerView: some View { - VStack(spacing: 50) { - L10n.connectToJellyfinServerStart.text - .frame(maxWidth: 500) - .multilineTextAlignment(.center) - .font(.body) - - Button { - router.route(to: \.connectToServer) - } label: { - L10n.connect.text - .bold() - .font(.callout) - .frame(width: 400, height: 75) - .background(Color.jellyfinPurple) - } - .buttonStyle(.card) - } - } - - @ViewBuilder - private var innerBody: some View { - if viewModel.servers.isEmpty { - noServerView - .offset(y: -50) - } else { - listView - } - } - - var body: some View { - HStack { - VStack { - Image(.jellyfinBlobBlue) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 400) - - Button { - router.route(to: \.connectToServer) - } label: { - L10n.connect.text - .bold() - .font(.callout) - .frame(width: 400, height: 75) - .background(Color.jellyfinPurple) - } - .buttonStyle(.card) - } - .frame(maxWidth: .infinity) - - innerBody - .frame(maxWidth: .infinity) - } - .onAppear { - viewModel.fetchServers() - } - } - -// var body: some View { -// innerBody -// .navigationTitle(L10n.servers) -// .if(viewModel.servers.isNotEmpty) { view in -// view.toolbar { -// ToolbarItem(placement: .topBarTrailing) { -// SFSymbolButton(systemName: "plus.circle.fill") -// .onSelect { -// router.route(to: \.connectToServer) -// } -// } -// } -// } - //// .toolbar { - //// ToolbarItem(placement: .navigationBarLeading) { - //// SFSymbolButton(systemName: "gearshape.fill") - //// .onSelect { - //// router.route(to: \.basicAppSettings) - //// } - //// } - //// } -// .alert(item: $longPressedServer) { server in -// Alert( -// title: Text(server.name), -// primaryButton: .destructive(L10n.remove.text, action: { viewModel.remove(server: server) }), -// secondaryButton: .cancel() -// ) -// } -// .onAppear { -// viewModel.fetchServers() -// } -// } -} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift index fad753f9..d9220c6e 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift @@ -28,7 +28,7 @@ struct CustomizeViewsSettings: View { private var similarPosterType @Default(.Customization.searchPosterType) private var searchPosterType - @Default(.Customization.Library.viewType) + @Default(.Customization.Library.displayType) private var libraryViewType @Default(.Customization.Library.cinematicBackground) diff --git a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift index aba22d07..8aebc32e 100644 --- a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -13,9 +13,6 @@ struct ExperimentalSettingsView: View { @Default(.Experimental.forceDirectPlay) private var forceDirectPlay - @Default(.Experimental.syncSubtitleStateWithAdjacent) - private var syncSubtitleStateWithAdjacent - @Default(.Experimental.liveTVForceDirectPlay) private var liveTVForceDirectPlay @@ -32,10 +29,8 @@ struct ExperimentalSettingsView: View { Toggle("Force Direct Play", isOn: $forceDirectPlay) - Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - } header: { - L10n.experimental.text + Text("Video Player") } Section { diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index 1c25df15..d4f8a243 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -19,8 +19,8 @@ struct SettingsView: View { @EnvironmentObject private var router: SettingsCoordinator.Router - @ObservedObject - var viewModel: SettingsViewModel + @StateObject + private var viewModel = SettingsViewModel() var body: some View { SplitFormWindowView() diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift deleted file mode 100644 index 4f52439f..00000000 --- a/Swiftfin tvOS/Views/UserListView.swift +++ /dev/null @@ -1,111 +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 CollectionVGrid -import Factory -import JellyfinAPI -import SwiftUI - -struct UserListView: View { - - @EnvironmentObject - private var router: UserListCoordinator.Router - - @State - private var longPressedUser: SwiftfinStore.State.User? - - @StateObject - private var viewModel: UserListViewModel - - init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: UserListViewModel(server: server)) - } - - @ViewBuilder - private var listView: some View { - CollectionVGrid( - viewModel.users, - layout: .minWidth( - 250, - insets: EdgeInsets.edgeInsets, - itemSpacing: EdgeInsets.edgePadding, - lineSpacing: EdgeInsets.edgePadding - ) - ) { user in - UserProfileButton(user: user) - .onSelect { - viewModel.signIn(user: user) - } - .onLongPressGesture { - longPressedUser = user - } - } - } - - @ViewBuilder - private var noUserView: some View { - VStack(spacing: 50) { - L10n.signInGetStarted.text - .frame(maxWidth: 500) - .multilineTextAlignment(.center) - .font(.body) - - Button { - router.route(to: \.userSignIn, viewModel.server) - } label: { - L10n.signIn.text - .bold() - .font(.callout) - .frame(width: 400, height: 75) - .background(Color.jellyfinPurple) - } - .buttonStyle(.card) - } - } - - var body: some View { - ZStack { - - ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen())) - .ignoresSafeArea() - - Color.black - .opacity(0.9) - .ignoresSafeArea() - - if viewModel.users.isEmpty { - noUserView - .offset(y: -50) - } else { - listView - } - } - .navigationTitle(viewModel.server.name) - .if(viewModel.users.isNotEmpty) { view in - view.toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - router.route(to: \.userSignIn, viewModel.server) - } label: { - Image(systemName: "person.crop.circle.fill.badge.plus") - } - } - } - } - .alert(item: $longPressedUser) { user in - Alert( - title: Text(user.username), - primaryButton: .destructive(L10n.remove.text, action: { viewModel.remove(user: user) }), - secondaryButton: .cancel() - ) - } - .onAppear { - viewModel.fetchUsers() - } - } -} diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift deleted file mode 100644 index 71685abb..00000000 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ /dev/null @@ -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 CollectionVGrid -import JellyfinAPI -import Stinsen -import SwiftUI - -struct UserSignInView: View { - enum FocusedField { - case username - case password - } - - @FocusState - private var focusedField: FocusedField? - - @ObservedObject - var viewModel: UserSignInViewModel - - @State - private var isPresentingQuickConnect: Bool = false - @State - private var isPresentingSignInError: Bool = false - @State - private var password: String = "" - @State - private var username: String = "" - - @ViewBuilder - private var signInForm: some View { - VStack(alignment: .leading) { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) - .focused($focusedField, equals: .username) - - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) - .focused($focusedField, equals: .password) - - Button { - viewModel.send(.signInWithUserPass(username: username, password: password)) - } label: { - HStack { - if case viewModel.state = .signingIn { - ProgressView() - } - - L10n.connect.text - .bold() - .font(.callout) - } - .frame(height: 75) - .frame(maxWidth: .infinity) - .background(viewModel.isLoading || username.isEmpty ? .secondary : Color.jellyfinPurple) - } - .disabled(viewModel.isLoading || username.isEmpty) - .buttonStyle(.card) - - Button { - isPresentingQuickConnect = true - } label: { - L10n.quickConnect.text - .frame(height: 75) - .frame(maxWidth: .infinity) - .background(Color.jellyfinPurple) - } - .buttonStyle(.card) - } header: { - L10n.signInToServer(viewModel.server.name).text - } - } - } - - @ViewBuilder - private var publicUsersGrid: some View { - VStack { - L10n.publicUsers.text - .font(.title3) - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - - if viewModel.publicUsers.isEmpty { - L10n.noPublicUsers.text - .font(.callout) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: -50) - } else { - CollectionVGrid( - viewModel.publicUsers, - layout: .minWidth(250, insets: .init(20), itemSpacing: 20, lineSpacing: 20) - ) { user in - UserProfileButton(user: user) - .onSelect { - username = user.name ?? "" - focusedField = .password - } - } - } - } - } - - var errorText: some View { - var text: String? - if case let .error(error) = viewModel.state { - text = error.localizedDescription - } - return Text(text ?? .emptyDash) - } - - var body: some View { - ZStack { - ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen())) - .ignoresSafeArea() - - Color.black - .opacity(0.9) - .ignoresSafeArea() - - HStack(alignment: .top) { - signInForm - .frame(maxWidth: .infinity) - - publicUsersGrid - .frame(maxWidth: .infinity) - } - .edgesIgnoringSafeArea(.bottom) - } - .navigationTitle(L10n.signIn) - .onChange(of: viewModel.state) { _ in - // If we encountered the error as we switched from quick connect cover to this view, - // it's possible that the alert doesn't show, so wait a little bit - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - isPresentingSignInError = true - } - } - .alert( - L10n.error, - isPresented: $isPresentingSignInError - ) { - Button(L10n.dismiss, role: .cancel) - } message: { - errorText - } - .blurFullScreenCover(isPresented: $isPresentingQuickConnect) { - QuickConnectView( - viewModel: viewModel.quickConnectViewModel, - isPresentingQuickConnect: $isPresentingQuickConnect, - signIn: { authSecret in - self.viewModel.send(.signInWithQuickConnect(authSecret: authSecret)) - } - ) - } - .onAppear { - Task { - try? await viewModel.checkQuickConnect() - try? await viewModel.getPublicUsers() - } - } - .onDisappear { - viewModel.send(.cancelSignIn) - } - } -} diff --git a/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift new file mode 100644 index 00000000..60b03249 --- /dev/null +++ b/Swiftfin tvOS/Views/UserSignInView/Components/PublicUserRow.swift @@ -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 JellyfinAPI +import SwiftUI + +// TODO: change from list to grid button + +extension UserSignInView { + + struct PublicUserRow: View { + + private let user: UserDto + private let client: JellyfinClient + private let action: () -> Void + + init( + user: UserDto, + client: JellyfinClient, + action: @escaping () -> Void + ) { + self.user = user + self.client = client + self.action = action + } + + private var personView: some View { + ZStack { + Color.secondarySystemFill + + RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + + var body: some View { + Button { + action() + } label: { + HStack { + ZStack { + Color.clear + + ImageView(user.profileImageSource(client: client, maxWidth: 120)) + .image { image in + image + .posterBorder(ratio: 0.5, of: \.width) + } + .placeholder { _ in + personView + } + .failure { + personView + } + } + .aspectRatio(1, contentMode: .fill) + .posterShadow() + .clipShape(.circle) + .frame(width: 50, height: 50) + + Text(user.name ?? .emptyDash) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer() + } + } + .buttonStyle(.card) + .foregroundStyle(.primary) + } + } +} diff --git a/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift new file mode 100644 index 00000000..daf0098a --- /dev/null +++ b/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift @@ -0,0 +1,212 @@ +// +// 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 CollectionVGrid +import Defaults +import Factory +import JellyfinAPI +import Stinsen +import SwiftUI + +// TODO: change public users from list to grid + +struct UserSignInView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: UserSignInCoordinator.Router + + @FocusState + private var focusedTextField: Int? + + @State + private var duplicateUser: UserState? = nil + @State + private var error: Error? = nil + @State + private var isPresentingDuplicateUser: Bool = false + @State + private var isPresentingError: Bool = false + @State + private var password: String = "" + @State + private var username: String = "" + + @StateObject + private var viewModel: UserSignInViewModel + + init(server: ServerState) { + self._viewModel = StateObject(wrappedValue: UserSignInViewModel(server: server)) + } + + @ViewBuilder + private var signInSection: some View { + Section { + TextField(L10n.username, text: $username) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($focusedTextField, equals: 0) + .onSubmit { + focusedTextField = 1 + } + + TextField(L10n.password, text: $password) { + focusedTextField = nil + } + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($focusedTextField, equals: 1) + } header: { + Text(L10n.signInToServer(viewModel.server.name)) + } + + if case .signingIn = viewModel.state { + Button(L10n.cancel) { + viewModel.send(.cancel) + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + Button(L10n.signIn) { + focusedTextField = nil + + viewModel.send(.signIn(username: username, password: password, policy: .none)) + } + .disabled(username.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .opacity(username.isEmpty ? 0.5 : 1) + } + + if viewModel.isQuickConnectEnabled { + Section { + ListRowButton(L10n.quickConnect) { + router.route(to: \.quickConnect, viewModel.quickConnect) + } + .disabled(viewModel.state == .signingIn) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + } + } + + if let disclaimer = viewModel.serverDisclaimer { + Section("Disclaimer") { + Text(disclaimer) + .font(.callout) + } + } + } + + @ViewBuilder + private var publisUsersSection: some View { + Section(L10n.publicUsers) { + if viewModel.publicUsers.isEmpty { + L10n.noPublicUsers.text + .font(.callout) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else { + ForEach(viewModel.publicUsers, id: \.id) { user in + PublicUserRow( + user: user, + client: viewModel.server.client + ) { + username = user.name ?? "" + password = "" + focusedTextField = 1 + } + } + } + } + } + + var body: some View { + VStack { + HStack { + Spacer() + + if viewModel.state == .signingIn { + ProgressView() + } + } + .frame(height: 100) + .overlay { + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 100) + .edgePadding() + } + + HStack(alignment: .top) { + VStack(alignment: .leading) { + signInSection + } + + VStack(alignment: .leading) { + publisUsersSection + } + } + + Spacer() + } + .onReceive(viewModel.events) { event in + switch event { + case let .duplicateUser(duplicateUser): + self.duplicateUser = duplicateUser + isPresentingDuplicateUser = true + case let .error(eventError): + error = eventError + isPresentingError = true + case let .signedIn(user): + router.dismissCoordinator() + + Defaults[.lastSignedInUserID] = user.id + UserSession.current.reset() + Notifications[.didSignIn].post() + } + } + .onFirstAppear { + focusedTextField = 0 + viewModel.send(.getPublicData) + } + .alert( + Text("Duplicate User"), + isPresented: $isPresentingDuplicateUser, + presenting: duplicateUser + ) { _ in + + // TODO: uncomment when duplicate user fixed +// Button(L10n.signIn) { +// signInUplicate(user: user, replace: false) +// } + +// Button("Replace") { +// signInUplicate(user: user, replace: true) +// } + + Button(L10n.dismiss, role: .cancel) + } message: { duplicateUser in + Text("\(duplicateUser.username) is already saved") + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) + } message: { error in + Text(error.localizedDescription) + } + } +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 49baeeb0..8176293f 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -15,7 +15,6 @@ 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; }; - 53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; }; 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; }; 534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF126A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; @@ -26,7 +25,6 @@ 535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* SwiftfinApp.swift */; }; 535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; }; 5358707E2669D64F00D05A09 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; - 535870912669D7A800D05A09 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 535870902669D7A800D05A09 /* Introspect */; }; 535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */; }; @@ -124,7 +122,6 @@ 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 62C29E9B26D0FE4200C1D2E7 /* Stinsen */; }; 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29E9E26D1016600C1D2E7 /* iOSMainCoordinator.swift */; }; 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA026D102A500C1D2E7 /* iOSMainTabCoordinator.swift */; }; - 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */; }; 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */; }; 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */; }; 62C83B08288C6A630004ED0C /* FontPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C83B07288C6A630004ED0C /* FontPickerView.swift */; }; @@ -142,8 +139,8 @@ 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; }; - 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */; }; - 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */; }; + 6334175B287DDFB9000603CE /* QuickConnectAuthorizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */; }; + 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175C287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; @@ -197,6 +194,8 @@ E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */; }; E103DF902BCF2F1C000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */; }; E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF942BCF31CD000229B2 /* MediaItem.swift */; }; + E10432F62BE4426F006FF9DD /* FormatStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10432F52BE4426F006FF9DD /* FormatStyle.swift */; }; + E10432F72BE4426F006FF9DD /* FormatStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10432F52BE4426F006FF9DD /* FormatStyle.swift */; }; E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; }; E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; }; @@ -208,10 +207,22 @@ E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; }; - E10B1E8A2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */; }; - E10B1E8B2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */; }; E10B1E8E2BD7708900A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */; }; E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; }; + E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB32BD9803100A92EAF /* UserRow.swift */; }; + E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */; }; + E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */; }; + E10B1EBF2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */; }; + E10B1EC12BD9AD6100A92EAF /* V1UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */; }; + E10B1EC22BD9AD6100A92EAF /* V1UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */; }; + E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */; }; + E10B1EC82BD9AF6100A92EAF /* V2ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */; }; + E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC92BD9AF8200A92EAF /* SwiftfinStore+V1.swift */; }; + E10B1ECB2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC92BD9AF8200A92EAF /* SwiftfinStore+V1.swift */; }; + E10B1ECD2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1ECC2BD9AFD800A92EAF /* SwiftfinStore+V2.swift */; }; + E10B1ECE2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1ECC2BD9AFD800A92EAF /* SwiftfinStore+V2.swift */; }; + E10B1ED02BD9AFF200A92EAF /* V2UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1ECF2BD9AFF200A92EAF /* V2UserModel.swift */; }; + E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1ECF2BD9AFF200A92EAF /* V2UserModel.swift */; }; E10E842A29A587110064EA49 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842929A587110064EA49 /* LoadingView.swift */; }; E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842B29A589860064EA49 /* NonePosterButton.swift */; }; E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSize.swift */; }; @@ -256,6 +267,8 @@ E11895AF2893840F0042947B /* NavigationBarOffsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895AE2893840F0042947B /* NavigationBarOffsetView.swift */; }; E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */; }; E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */; }; + E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1194F4D2BEABA9100888DB6 /* NavigationBarCloseButton.swift */; }; + E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1194F4F2BEB1E3000888DB6 /* StoredValues+Temp.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF762B8513B40045C54A /* ItemGenre.swift */; }; @@ -332,18 +345,14 @@ E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDevice.swift */; }; E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3CC27164CA7009D4DAF /* CoreStore */; }; E13DD3D327168E65009D4DAF /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3D227168E65009D4DAF /* Defaults */; }; - E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */; }; - E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */; }; - E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E427177D15009D4DAF /* ServerListView.swift */; }; - E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E827177ED6009D4DAF /* ServerListCoordinator.swift */; }; E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */; }; E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */; }; E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; }; E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F4271793BB009D4DAF /* UserSignInView.swift */; }; - E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; - E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; - E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* UserListView.swift */; }; - E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; + E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* SelectUserViewModel.swift */; }; + E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* SelectUserViewModel.swift */; }; + E13DD3FC2717EAE8009D4DAF /* SelectUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* SelectUserView.swift */; }; + E13DD4022717EE79009D4DAF /* SelectUserCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */; }; E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */; }; E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EF28BC9016003499D2 /* LibraryRow.swift */; }; E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */; }; @@ -353,15 +362,30 @@ E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA82938140700E8B599 /* DarkAppIcon.swift */; }; E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CAA2938140A00E8B599 /* LightAppIcon.swift */; }; E1401CB129386C9200E8B599 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CB029386C9200E8B599 /* UIColor.swift */; }; + E145EB222BDCCA43003BF6F3 /* BulletedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB212BDCCA43003BF6F3 /* BulletedList.swift */; }; + E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB212BDCCA43003BF6F3 /* BulletedList.swift */; }; + E145EB252BE055AD003BF6F3 /* ServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB242BE055AD003BF6F3 /* ServerResponse.swift */; }; + E145EB262BE055AD003BF6F3 /* ServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB242BE055AD003BF6F3 /* ServerResponse.swift */; }; + E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB412BE0A6EE003BF6F3 /* ServerSelectionMenu.swift */; }; + E145EB452BE0AD4E003BF6F3 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB442BE0AD4E003BF6F3 /* Set.swift */; }; + E145EB462BE0AD4E003BF6F3 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB442BE0AD4E003BF6F3 /* Set.swift */; }; + E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB472BE0C136003BF6F3 /* ScrollIfLargerThanModifier.swift */; }; + E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E145EB4A2BE16849003BF6F3 /* KeychainSwift */; }; + E145EB4D2BE1688E003BF6F3 /* SwiftinStore+UserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4C2BE1688E003BF6F3 /* SwiftinStore+UserState.swift */; }; + E145EB4F2BE168AC003BF6F3 /* SwiftfinStore+ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4E2BE168AC003BF6F3 /* SwiftfinStore+ServerState.swift */; }; + E146A9D82BE6E9830034DA1E /* StoredValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E146A9D72BE6E9830034DA1E /* StoredValue.swift */; }; + E146A9D92BE6E9830034DA1E /* StoredValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E146A9D72BE6E9830034DA1E /* StoredValue.swift */; }; + E146A9DB2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */ = {isa = PBXBuildFile; fileRef = E146A9DA2BE6E9BF0034DA1E /* StoredValues+User.swift */; }; + E146A9DC2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */ = {isa = PBXBuildFile; fileRef = E146A9DA2BE6E9BF0034DA1E /* StoredValues+User.swift */; }; E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */; }; - E148128528C15472003B8787 /* SortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder.swift */; }; - E148128628C15475003B8787 /* SortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder.swift */; }; + E148128528C15472003B8787 /* SortOrder+ItemSortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */; }; + E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */; }; E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */; }; E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */; }; E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* ItemSortBy.swift */; }; + E149CCAD2BE6ECC8008B9331 /* Storable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E149CCAC2BE6ECC8008B9331 /* Storable.swift */; }; + E149CCAE2BE6ECC8008B9331 /* Storable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E149CCAC2BE6ECC8008B9331 /* Storable.swift */; }; E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; - E14CB6862A9FF62A001586C6 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E14CB6852A9FF62A001586C6 /* JellyfinAPI */; }; - E14CB6882A9FF71F001586C6 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E14CB6872A9FF71F001586C6 /* JellyfinAPI */; }; E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; }; E14E9DF22BCF7A99004E3371 /* ItemLetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */; }; E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; }; @@ -375,14 +399,15 @@ E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; E1523F822B132C350062821A /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1523F812B132C350062821A /* CollectionHStack */; }; + E1545BD82BDC55C300D9578F /* ResetUserPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */; }; E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546776289AF46E00087E35 /* CollectionItemView.swift */; }; E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546779289AF48200087E35 /* CollectionItemContentView.swift */; }; E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549655296CA2EF00C4EF88 /* DownloadTask.swift */; }; E154965F296CA2EF00C4EF88 /* DownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549655296CA2EF00C4EF88 /* DownloadTask.swift */; }; E1549660296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */; }; E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */; }; - E1549662296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */; }; - E1549663296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */; }; + E1549662296CA2EF00C4EF88 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* UserSession.swift */; }; + E1549663296CA2EF00C4EF88 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* UserSession.swift */; }; E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */; }; E1549665296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */; }; E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */; }; @@ -469,6 +494,10 @@ E15D63F02BD6DFC200AA665D /* SystemImageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */; }; E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E15EFA832BA167350080E926 /* CollectionHStack */; }; E15EFA862BA1685F0080E926 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */; }; + E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E164A7F32BE4736300A54B18 /* SignOutIntervalSection.swift */; }; + E164A7F62BE4814700A54B18 /* SelectUserServerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E164A7F52BE4814700A54B18 /* SelectUserServerSelection.swift */; }; + E164A7F72BE4816500A54B18 /* SelectUserServerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E164A7F52BE4814700A54B18 /* SelectUserServerSelection.swift */; }; + E164A8152BE58C2F00A54B18 /* V2AnyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E164A8142BE58C2F00A54B18 /* V2AnyData.swift */; }; E168BD10289A4162001A6922 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD08289A4162001A6922 /* HomeView.swift */; }; E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */; }; E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */; }; @@ -494,15 +523,33 @@ E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */; }; E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */; }; E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */; }; + E17639F82BF2E25B004DF6AB /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41A92BF077130082B8B2 /* Keychain.swift */; }; + E1763A252BF2F77B004DF6AB /* ScrollIfLargerThanModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB472BE0C136003BF6F3 /* ScrollIfLargerThanModifier.swift */; }; + E1763A272BF303C9004DF6AB /* ServerSelectionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */; }; + E1763A292BF3046A004DF6AB /* AddUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A282BF3046A004DF6AB /* AddUserButton.swift */; }; + E1763A2B2BF3046E004DF6AB /* UserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */; }; + E1763A642BF3C9AA004DF6AB /* ListRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */; }; + E1763A662BF3CA83004DF6AB /* FullScreenMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A652BF3CA83004DF6AB /* FullScreenMenu.swift */; }; + E1763A6A2BF3D177004DF6AB /* PublicUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A692BF3D177004DF6AB /* PublicUserRow.swift */; }; + E1763A6D2BF3DE17004DF6AB /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */; }; + E1763A6F2BF3DE23004DF6AB /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */; }; + E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; }; + E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */; }; + E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */; }; + E1763A762BF3FF01004DF6AB /* AppLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A752BF3FF01004DF6AB /* AppLoadingView.swift */; }; E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; }; E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; }; E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17885A3278105170094FBCF /* SFSymbolButton.swift */; }; + E178B0762BE435D70023651B /* HourMinutePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178B0752BE435D70023651B /* HourMinutePicker.swift */; }; E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9692954D00E003D2BC2 /* URLResponse.swift */; }; E17AC96B2954D00E003D2BC2 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9692954D00E003D2BC2 /* URLResponse.swift */; }; E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */; }; E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */; }; E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */; }; E17AC9732955007A003D2BC2 /* DownloadTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */; }; + E17DC74A2BE740D900B42379 /* StoredValues+Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17DC7492BE740D900B42379 /* StoredValues+Server.swift */; }; + E17DC74B2BE740D900B42379 /* StoredValues+Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17DC7492BE740D900B42379 /* StoredValues+Server.swift */; }; + E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17DC74C2BE7601E00B42379 /* SettingsBarButton.swift */; }; E17FB55228C119D400311DFE /* Displayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55128C119D400311DFE /* Displayable.swift */; }; E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */; }; E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; }; @@ -534,10 +581,9 @@ E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */ = {isa = PBXBuildFile; }; E18ACA922A15A32F00BB4F35 /* (null) in Sources */ = {isa = PBXBuildFile; }; E18ACA952A15A3E100BB4F35 /* (null) in Sources */ = {isa = PBXBuildFile; }; - E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0AE28A222240092E7F1 /* PublicUserSignInView.swift */; }; + E18CE0AF28A222240092E7F1 /* PublicUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0AE28A222240092E7F1 /* PublicUserRow.swift */; }; E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; }; E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; - E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */; }; E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E18D6AA52BAA96F000A0D167 /* CollectionHStack */; }; E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; }; E18E01AD288746AF0022598C /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A7288746AF0022598C /* DotHStack.swift */; }; @@ -574,31 +620,38 @@ E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; }; E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; }; - E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E192607F28D28AAD002314B4 /* UserProfileButton.swift */; }; E192608328D2D0DB002314B4 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = E192608228D2D0DB002314B4 /* Factory */; }; E192608828D2E5F0002314B4 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = E192608728D2E5F0002314B4 /* Factory */; }; E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */; }; E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */; }; E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3D288F0D3D00CB80AA /* UIScreen.swift */; }; E1937A61288F32DB00CB80AA /* Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A60288F32DB00CB80AA /* Poster.swift */; }; - E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */; }; E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; }; E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */; }; E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; }; E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */; }; E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */; }; E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; }; - E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E827177ED6009D4DAF /* ServerListCoordinator.swift */; }; E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; }; - E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; + E193D53C27193F9500900D82 /* SelectUserCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */; }; E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; }; E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */; }; - E193D547271941C500900D82 /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D546271941C500900D82 /* UserListView.swift */; }; + E193D547271941C500900D82 /* SelectUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D546271941C500900D82 /* SelectUserView.swift */; }; E193D549271941CC00900D82 /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D548271941CC00900D82 /* UserSignInView.swift */; }; - E193D54B271941D300900D82 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D54A271941D300900D82 /* ServerListView.swift */; }; + E193D54B271941D300900D82 /* SelectServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D54A271941D300900D82 /* SelectServerView.swift */; }; E193D5502719430400900D82 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D54F2719430400900D82 /* ServerDetailView.swift */; }; E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */; }; + E19D41A72BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */; }; + E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */; }; + E19D41AA2BF077130082B8B2 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41A92BF077130082B8B2 /* Keychain.swift */; }; + E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */; }; + E19D41AE2BF288320082B8B2 /* ServerCheckViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41AD2BF288320082B8B2 /* ServerCheckViewModel.swift */; }; + E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41AF2BF2B7540082B8B2 /* URLSessionConfiguration.swift */; }; + E19D41B22BF2BFA50082B8B2 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E19D41B12BF2BFA50082B8B2 /* KeychainSwift */; }; + E19D41B32BF2BFEF0082B8B2 /* URLSessionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41AF2BF2B7540082B8B2 /* URLSessionConfiguration.swift */; }; + E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1194F4F2BEB1E3000888DB6 /* StoredValues+Temp.swift */; }; + E19D41B52BF2C0130082B8B2 /* V2AnyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E164A8142BE58C2F00A54B18 /* V2AnyData.swift */; }; E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E19DDEC62948EF9900954E10 /* OrderedCollections */; }; E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0428A0B958005C10C8 /* Nuke */; }; @@ -634,6 +687,9 @@ E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */; }; E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */; }; E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */; }; + E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AEFA362BE317E200CFAFD8 /* ListRowButton.swift */; }; + E1AEFA382BE36C4900CFAFD8 /* SwiftinStore+UserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4C2BE1688E003BF6F3 /* SwiftinStore+UserState.swift */; }; + E1AEFA392BE36C4C00CFAFD8 /* SwiftfinStore+ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4E2BE168AC003BF6F3 /* SwiftfinStore+ServerState.swift */; }; E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33EAF28EA890D0073B0FD /* Equatable.swift */; }; E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33ECE28EB6EA90073B0FD /* OverlayMenu.swift */; }; E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */; }; @@ -651,6 +707,9 @@ E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AC29577BDD004B26CF /* OrderedCollections */; }; E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */; }; E1B90C8A2BC475E7007027C8 /* ScalingButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */; }; + E1BAFE102BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAFE0F2BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift */; }; + E1BCDB4F2BE1F491009F6744 /* ResetUserPasswordViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BCDB4E2BE1F491009F6744 /* ResetUserPasswordViewModel.swift */; }; + E1BCDB502BE1F491009F6744 /* ResetUserPasswordViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BCDB4E2BE1F491009F6744 /* ResetUserPasswordViewModel.swift */; }; E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; }; E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; }; E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */; }; @@ -663,6 +722,9 @@ E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2F829524FDA00CC0294 /* PlayPreviousItemActionButton.swift */; }; E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2FA2952502300CC0294 /* SubtitleActionButton.swift */; }; E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF31629525F0400CC0294 /* AdvancedActionButton.swift */; }; + E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BE1CE92BDB5AFE008176A9 /* UserGridButton.swift */; }; + E1BE1CEE2BDB68CD008176A9 /* UserProfileRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BE1CED2BDB68CD008176A9 /* UserProfileRow.swift */; }; + E1BE1CF02BDB6C97008176A9 /* UserProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */; }; E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; E1C812C5277A90B200918266 /* URLComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponents.swift */; }; E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE5A28FE512400DF5D7B /* CGPoint.swift */; }; @@ -689,7 +751,6 @@ E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF65B2BA345830087D991 /* MediaViewModel.swift */; }; E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF6612BA363840087D991 /* UIHostingController.swift */; }; E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF6612BA363840087D991 /* UIHostingController.swift */; }; - E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */; }; E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */; }; E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */; }; E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */; }; @@ -710,10 +771,10 @@ E1D37F562B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */; }; E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */; }; E1D37F592B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */; }; - E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; }; + E1D4BF7C2719D05000A11E64 /* AppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* AppSettingsView.swift */; }; E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; - E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */; }; - E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */; }; + E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */; }; + E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */; }; E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; }; E1D5C39628DF90C100CDBEFB /* Slider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D5C39528DF90C100CDBEFB /* Slider.swift */; }; E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D5C39828DF914700CDBEFB /* CapsuleSlider.swift */; }; @@ -741,11 +802,10 @@ E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9843296DECB600982F06 /* ProgressIndicator.swift */; }; E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; + E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */; }; E1DD55372B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; E1DD55382B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B492B97ECB900F6715F /* ErrorView.swift */; }; - E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */; }; - E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */; }; E1DE84142B9531C1008CCE21 /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */; }; E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; @@ -787,6 +847,10 @@ E1E9017F28DAB15F001B1594 /* BarActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9017E28DAB15F001B1594 /* BarActionButtons.swift */; }; E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */; }; E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; }; + E1EA09672BED6815004CDE76 /* UserSignInSecurityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA09662BED6815004CDE76 /* UserSignInSecurityView.swift */; }; + E1EA09692BED78BB004CDE76 /* UserAccessPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA09682BED78BB004CDE76 /* UserAccessPolicy.swift */; }; + E1EA096A2BED78F5004CDE76 /* UserAccessPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA09682BED78BB004CDE76 /* UserAccessPolicy.swift */; }; + E1EA09882BEE9CF3004CDE76 /* UserLocalSecurityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */; }; E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */; }; E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */; }; E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */; }; @@ -803,8 +867,6 @@ E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E1FAD1C52A0375BA007F5521 /* UDPBroadcast */; }; E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; - E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; - E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; }; E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; }; E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; }; @@ -942,7 +1004,6 @@ 628B95232670CABD0091AF3B /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 62C29E9E26D1016600C1D2E7 /* iOSMainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSMainCoordinator.swift; sourceTree = ""; }; 62C29EA026D102A500C1D2E7 /* iOSMainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSMainTabCoordinator.swift; sourceTree = ""; }; - 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerCoodinator.swift; sourceTree = ""; }; 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCoordinator.swift; sourceTree = ""; }; 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCoordinator.swift; sourceTree = ""; }; 62C83B07288C6A630004ED0C /* FontPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontPickerView.swift; sourceTree = ""; }; @@ -954,8 +1015,8 @@ 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = ""; }; 62E632F2267D54030063E547 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = ""; }; 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; - 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectSettingsView.swift; sourceTree = ""; }; - 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectSettingsViewModel.swift; sourceTree = ""; }; + 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectAuthorizeView.swift; sourceTree = ""; }; + 6334175C287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectAuthorizeViewModel.swift; sourceTree = ""; }; 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = UDPBroadcast.xcframework; path = Carthage/Build/UDPBroadcast.xcframework; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineVideoPlayerManager.swift; sourceTree = ""; }; @@ -998,14 +1059,22 @@ E102315E2BCF8B75009D71FC /* VideoPlayerWrapperCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerWrapperCoordinator.swift; sourceTree = ""; }; E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = ""; }; E103DF942BCF31CD000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = ""; }; + E10432F52BE4426F006FF9DD /* FormatStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatStyle.swift; sourceTree = ""; }; E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageContentView.swift; sourceTree = ""; }; E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; E104DC952B9E7E29008F506D /* AssertionFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertionFailureView.swift; sourceTree = ""; }; E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = ""; }; E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = ""; }; - E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModel.swift; sourceTree = ""; }; E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = ""; }; + E10B1EB32BD9803100A92EAF /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = ""; }; + E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserRow.swift; sourceTree = ""; }; + E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1ServerModel.swift; sourceTree = ""; }; + E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1UserModel.swift; sourceTree = ""; }; + E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2ServerModel.swift; sourceTree = ""; }; + E10B1EC92BD9AF8200A92EAF /* SwiftfinStore+V1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftfinStore+V1.swift"; sourceTree = ""; }; + E10B1ECC2BD9AFD800A92EAF /* SwiftfinStore+V2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftfinStore+V2.swift"; sourceTree = ""; }; + E10B1ECF2BD9AFF200A92EAF /* V2UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2UserModel.swift; sourceTree = ""; }; E10E842929A587110064EA49 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; E10E842B29A589860064EA49 /* NonePosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonePosterButton.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSize.swift; sourceTree = ""; }; @@ -1035,6 +1104,8 @@ E11895AB289383EE0042947B /* NavigationBarOffsetModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarOffsetModifier.swift; sourceTree = ""; }; E11895AE2893840F0042947B /* NavigationBarOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarOffsetView.swift; sourceTree = ""; }; E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundParallaxHeaderModifier.swift; sourceTree = ""; }; + E1194F4D2BEABA9100888DB6 /* NavigationBarCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarCloseButton.swift; sourceTree = ""; }; + E1194F4F2BEB1E3000888DB6 /* StoredValues+Temp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredValues+Temp.swift"; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E11BDF762B8513B40045C54A /* ItemGenre.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemGenre.swift; sourceTree = ""; }; E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedCaseIterable.swift; sourceTree = ""; }; @@ -1081,15 +1152,12 @@ E13D02842788B634000FCB04 /* Swiftfin.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Swiftfin.entitlements; sourceTree = ""; }; E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; E13DD3C727164B1E009D4DAF /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; - E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListViewModel.swift; sourceTree = ""; }; - E13DD3E427177D15009D4DAF /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; - E13DD3E827177ED6009D4DAF /* ServerListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListCoordinator.swift; sourceTree = ""; }; E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInViewModel.swift; sourceTree = ""; }; E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInCoordinator.swift; sourceTree = ""; }; E13DD3F4271793BB009D4DAF /* UserSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInView.swift; sourceTree = ""; }; - E13DD3F82717E961009D4DAF /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; - E13DD3FB2717EAE8009D4DAF /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; - E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; + E13DD3F82717E961009D4DAF /* SelectUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectUserViewModel.swift; sourceTree = ""; }; + E13DD3FB2717EAE8009D4DAF /* SelectUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectUserView.swift; sourceTree = ""; }; + E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectUserCoordinator.swift; sourceTree = ""; }; E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryDisplayType.swift; sourceTree = ""; }; E13F05EF28BC9016003499D2 /* LibraryRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = ""; }; E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconSelectorView.swift; sourceTree = ""; }; @@ -1099,20 +1167,31 @@ E1401CA82938140700E8B599 /* DarkAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkAppIcon.swift; sourceTree = ""; }; E1401CAA2938140A00E8B599 /* LightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightAppIcon.swift; sourceTree = ""; }; E1401CB029386C9200E8B599 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; - E148128428C15472003B8787 /* SortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortOrder.swift; sourceTree = ""; }; + E145EB212BDCCA43003BF6F3 /* BulletedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletedList.swift; sourceTree = ""; }; + E145EB242BE055AD003BF6F3 /* ServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerResponse.swift; sourceTree = ""; }; + E145EB412BE0A6EE003BF6F3 /* ServerSelectionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionMenu.swift; sourceTree = ""; }; + E145EB442BE0AD4E003BF6F3 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = ""; }; + E145EB472BE0C136003BF6F3 /* ScrollIfLargerThanModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollIfLargerThanModifier.swift; sourceTree = ""; }; + E145EB4C2BE1688E003BF6F3 /* SwiftinStore+UserState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftinStore+UserState.swift"; sourceTree = ""; }; + E145EB4E2BE168AC003BF6F3 /* SwiftfinStore+ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftfinStore+ServerState.swift"; sourceTree = ""; }; + E146A9D72BE6E9830034DA1E /* StoredValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredValue.swift; sourceTree = ""; }; + E146A9DA2BE6E9BF0034DA1E /* StoredValues+User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredValues+User.swift"; sourceTree = ""; }; + E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SortOrder+ItemSortOrder.swift"; sourceTree = ""; }; E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemFilter+ItemTrait.swift"; sourceTree = ""; }; E148128A28C15526003B8787 /* ItemSortBy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSortBy.swift; sourceTree = ""; }; + E149CCAC2BE6ECC8008B9331 /* Storable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storable.swift; sourceTree = ""; }; E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; E14E9DF02BCF7A99004E3371 /* ItemLetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLetter.swift; sourceTree = ""; }; E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyItemFilter.swift; sourceTree = ""; }; E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterType.swift; sourceTree = ""; }; E14EDECB2B8FB709000F00A4 /* ItemYear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemYear.swift; sourceTree = ""; }; E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedLightAppIcon.swift; sourceTree = ""; }; + E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; E1549655296CA2EF00C4EF88 /* DownloadTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadTask.swift; sourceTree = ""; }; E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinDefaults.swift; sourceTree = ""; }; - E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewSessionManager.swift; sourceTree = ""; }; + E1549657296CA2EF00C4EF88 /* UserSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinStore.swift; sourceTree = ""; }; E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinNotifications.swift; sourceTree = ""; }; E154965B296CA2EF00C4EF88 /* DownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; @@ -1134,6 +1213,9 @@ E15D4F092B1BD88900442DB8 /* Edge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Edge.swift; sourceTree = ""; }; E15D63EC2BD622A700AA665D /* CompactChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactChannelView.swift; sourceTree = ""; }; E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageable.swift; sourceTree = ""; }; + E164A7F32BE4736300A54B18 /* SignOutIntervalSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOutIntervalSection.swift; sourceTree = ""; }; + E164A7F52BE4814700A54B18 /* SelectUserServerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectUserServerSelection.swift; sourceTree = ""; }; + E164A8142BE58C2F00A54B18 /* V2AnyData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2AnyData.swift; sourceTree = ""; }; E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; @@ -1156,14 +1238,26 @@ E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinatable.swift; sourceTree = ""; }; E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; + E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionMenu.swift; sourceTree = ""; }; + E1763A282BF3046A004DF6AB /* AddUserButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = ""; }; + E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserGridButton.swift; sourceTree = ""; }; + E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowButton.swift; sourceTree = ""; }; + E1763A652BF3CA83004DF6AB /* FullScreenMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenMenu.swift; sourceTree = ""; }; + E1763A692BF3D177004DF6AB /* PublicUserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicUserRow.swift; sourceTree = ""; }; + E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftfinStore+Mappings.swift"; sourceTree = ""; }; + E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoadingView.swift; sourceTree = ""; }; + E1763A752BF3FF01004DF6AB /* AppLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoadingView.swift; sourceTree = ""; }; E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = ""; }; E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = ""; }; E17885A3278105170094FBCF /* SFSymbolButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolButton.swift; sourceTree = ""; }; + E178B0752BE435D70023651B /* HourMinutePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourMinutePicker.swift; sourceTree = ""; }; E17AC9692954D00E003D2BC2 /* URLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLResponse.swift; sourceTree = ""; }; E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListView.swift; sourceTree = ""; }; E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListViewModel.swift; sourceTree = ""; }; E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListCoordinator.swift; sourceTree = ""; }; E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskButton.swift; sourceTree = ""; }; + E17DC7492BE740D900B42379 /* StoredValues+Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredValues+Server.swift"; sourceTree = ""; }; + E17DC74C2BE7601E00B42379 /* SettingsBarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBarButton.swift; sourceTree = ""; }; E17FB55128C119D400311DFE /* Displayable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Displayable.swift; sourceTree = ""; }; E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = ""; }; E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = ""; }; @@ -1183,10 +1277,9 @@ E18A8E8228D60BC400333B9A /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = ""; }; E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalingButtonStyle.swift; sourceTree = ""; }; - E18CE0AE28A222240092E7F1 /* PublicUserSignInView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PublicUserSignInView.swift; sourceTree = ""; }; + E18CE0AE28A222240092E7F1 /* PublicUserRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PublicUserRow.swift; sourceTree = ""; }; E18CE0B128A229E70092E7F1 /* UserDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDto.swift; sourceTree = ""; }; E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer.swift; sourceTree = ""; }; - E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectCoordinator.swift; sourceTree = ""; }; E18E01A5288746AF0022598C /* PillHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PillHStack.swift; sourceTree = ""; }; E18E01A7288746AF0022598C /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSEpisodeContentView.swift; sourceTree = ""; }; @@ -1217,16 +1310,20 @@ E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureHStack.swift; sourceTree = ""; }; E1921B7528E63306003A5238 /* GestureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureView.swift; sourceTree = ""; }; - E192607F28D28AAD002314B4 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = ""; }; E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Images.swift"; sourceTree = ""; }; E1937A3D288F0D3D00CB80AA /* UIScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScreen.swift; sourceTree = ""; }; E1937A60288F32DB00CB80AA /* Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poster.swift; sourceTree = ""; }; E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSMainCoordinator.swift; sourceTree = ""; }; - E193D546271941C500900D82 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; + E193D546271941C500900D82 /* SelectUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectUserView.swift; sourceTree = ""; }; E193D548271941CC00900D82 /* UserSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInView.swift; sourceTree = ""; }; - E193D54A271941D300900D82 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; + E193D54A271941D300900D82 /* SelectServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectServerView.swift; sourceTree = ""; }; E193D54F2719430400900D82 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSMainTabCoordinator.swift; sourceTree = ""; }; + E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLocalSecurityViewModel.swift; sourceTree = ""; }; + E19D41A92BF077130082B8B2 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerCheckView.swift; sourceTree = ""; }; + E19D41AD2BF288320082B8B2 /* ServerCheckViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerCheckViewModel.swift; sourceTree = ""; }; + E19D41AF2BF2B7540082B8B2 /* URLSessionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionConfiguration.swift; sourceTree = ""; }; E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomEdgeGradientModifier.swift; sourceTree = ""; }; E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStreamInfoView.swift; sourceTree = ""; }; E1A1528128FD126C00600579 /* VerticalAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalAlignment.swift; sourceTree = ""; }; @@ -1251,6 +1348,7 @@ E1AA331E2782639D00F6439C /* OverlayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayType.swift; sourceTree = ""; }; E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDto.swift; sourceTree = ""; }; E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGuidPair.swift; sourceTree = ""; }; + E1AEFA362BE317E200CFAFD8 /* ListRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowButton.swift; sourceTree = ""; }; E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCast.xcframework; path = Carthage/Build/GoogleCast.xcframework; sourceTree = ""; }; E1B33EAF28EA890D0073B0FD /* Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Equatable.swift; sourceTree = ""; }; E1B33ECE28EB6EA90073B0FD /* OverlayMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayMenu.swift; sourceTree = ""; }; @@ -1260,6 +1358,8 @@ E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = ""; }; E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetScrollView.swift; sourceTree = ""; }; + E1BAFE0F2BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftfinApp+ValueObservation.swift"; sourceTree = ""; }; + E1BCDB4E2BE1F491009F6744 /* ResetUserPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordViewModel.swift; sourceTree = ""; }; E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = ""; }; E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSelectorView.swift; sourceTree = ""; }; E1BDF2EB2952290200CC0294 /* AspectFillActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AspectFillActionButton.swift; sourceTree = ""; }; @@ -1271,6 +1371,9 @@ E1BDF2F829524FDA00CC0294 /* PlayPreviousItemActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayPreviousItemActionButton.swift; sourceTree = ""; }; E1BDF2FA2952502300CC0294 /* SubtitleActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleActionButton.swift; sourceTree = ""; }; E1BDF31629525F0400CC0294 /* AdvancedActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedActionButton.swift; sourceTree = ""; }; + E1BE1CE92BDB5AFE008176A9 /* UserGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserGridButton.swift; sourceTree = ""; }; + E1BE1CED2BDB68CD008176A9 /* UserProfileRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileRow.swift; sourceTree = ""; }; + E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileSettingsView.swift; sourceTree = ""; }; E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; E1C812C4277A90B200918266 /* URLComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponents.swift; sourceTree = ""; }; E1C8CE5A28FE512400DF5D7B /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = ""; }; @@ -1294,7 +1397,6 @@ E1CAF65A2BA345830087D991 /* MediaType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = ""; }; E1CAF65B2BA345830087D991 /* MediaViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaViewModel.swift; sourceTree = ""; }; E1CAF6612BA363840087D991 /* UIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIHostingController.swift; sourceTree = ""; }; - E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = ""; }; E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterDisplayType.swift; sourceTree = ""; }; E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectOrientationModifier.swift; sourceTree = ""; }; @@ -1309,9 +1411,9 @@ E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SharedCodecProfiles.swift"; sourceTree = ""; }; E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+NativeProfile.swift"; sourceTree = ""; }; E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SwiftfinProfile.swift"; sourceTree = ""; }; - E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; + E1D4BF7B2719D05000A11E64 /* AppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsView.swift; sourceTree = ""; }; E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; - E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = ""; }; + E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsCoordinator.swift; sourceTree = ""; }; E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; E1D5C39528DF90C100CDBEFB /* Slider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Slider.swift; sourceTree = ""; }; E1D5C39828DF914700CDBEFB /* CapsuleSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleSlider.swift; sourceTree = ""; }; @@ -1333,9 +1435,9 @@ E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedIndicator.swift; sourceTree = ""; }; E1DC9843296DECB600982F06 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = ""; }; + E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = ""; }; E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; - E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteOverlayRenderingModifier.swift; sourceTree = ""; }; E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIGestureRecognizer.swift; sourceTree = ""; }; E1E1643928BAC2EF00323B0A /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; @@ -1368,6 +1470,9 @@ E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCorner.swift; sourceTree = ""; }; E1E9017E28DAB15F001B1594 /* BarActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarActionButtons.swift; sourceTree = ""; }; E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerButton.swift; sourceTree = ""; }; + E1EA09662BED6815004CDE76 /* UserSignInSecurityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInSecurityView.swift; sourceTree = ""; }; + E1EA09682BED78BB004CDE76 /* UserAccessPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccessPolicy.swift; sourceTree = ""; }; + E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLocalSecurityView.swift; sourceTree = ""; }; E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerManager.swift; sourceTree = ""; }; E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedText.swift; sourceTree = ""; }; E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; @@ -1378,7 +1483,6 @@ E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemView.swift; sourceTree = ""; }; E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; - E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnScenePhaseChangedModifier.swift; sourceTree = ""; }; @@ -1401,7 +1505,6 @@ 62666E2127E501E400EC0ECD /* CoreVideo.framework in Frameworks */, 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */, E1392FED2BA218A80034110D /* SwiftUIIntrospect in Frameworks */, - 535870912669D7A800D05A09 /* Introspect in Frameworks */, E13AF3B828A0C598009093AB /* NukeExtensions in Frameworks */, E1575E58293E7685001665B1 /* Files in Frameworks */, E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */, @@ -1412,6 +1515,7 @@ E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */, 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */, 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, + E19D41B22BF2BFA50082B8B2 /* KeychainSwift in Frameworks */, E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */, 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */, E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */, @@ -1423,11 +1527,11 @@ 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, E132D3CF2BD217AA0058A2DF /* CollectionVGrid in Frameworks */, E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */, + E1763A6F2BF3DE23004DF6AB /* JellyfinAPI in Frameworks */, E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */, 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */, E13AF3B628A0C598009093AB /* Nuke in Frameworks */, E12186DE2718F1C50010884C /* Defaults in Frameworks */, - E14CB6882A9FF71F001586C6 /* JellyfinAPI in Frameworks */, E192608828D2E5F0002314B4 /* Factory in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1443,6 +1547,7 @@ E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */, 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */, + E1763A6D2BF3DE17004DF6AB /* JellyfinAPI in Frameworks */, E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, @@ -1454,6 +1559,7 @@ E192608328D2D0DB002314B4 /* Factory in Frameworks */, E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */, E1523F822B132C350062821A /* CollectionHStack in Frameworks */, + E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */, E10706142942F57D00646DAF /* PulseUI in Frameworks */, 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, 62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */, @@ -1465,12 +1571,10 @@ E1153DD02BBB634F00424D36 /* SVGKit in Frameworks */, E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */, 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */, - E14CB6862A9FF62A001586C6 /* JellyfinAPI in Frameworks */, 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, E132D3C82BD200C10058A2DF /* CollectionVGrid in Frameworks */, E18A8E7A28D5FEDF00333B9A /* VLCUI in Frameworks */, E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */, - 53352571265EA0A0006CCA86 /* Introspect in Frameworks */, E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */, E1153DAF2BBA734200424D36 /* CollectionHStack in Frameworks */, 62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */, @@ -1499,6 +1603,7 @@ isa = PBXGroup; children = ( 091B5A872683142E00D78B61 /* ServerDiscovery.swift */, + E145EB242BE055AD003BF6F3 /* ServerResponse.swift */, ); path = ServerDiscovery; sourceTree = ""; @@ -1530,13 +1635,14 @@ C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */, E1CAF65C2BA345830087D991 /* MediaViewModel */, E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */, - 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, - E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */, + 6334175C287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift */, + E1BCDB4E2BE1F491009F6744 /* ResetUserPasswordViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */, + E13DD3F82717E961009D4DAF /* SelectUserViewModel.swift */, + E19D41AD2BF288320082B8B2 /* ServerCheckViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, - E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, - E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, + E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, BD0BA2292AD6501300306A8D /* VideoPlayerManager */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, @@ -1617,6 +1723,7 @@ 091B5A852683142E00D78B61 /* ServerDiscovery */, E1549654296CA2EF00C4EF88 /* Services */, 6286F09F271C0AA500C40ED5 /* Strings */, + E10B1EB72BD9ACC800A92EAF /* SwiftfinStore */, 532175392671BCED005491E6 /* ViewModels */, ); path = Shared; @@ -1644,9 +1751,11 @@ E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */, E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */, E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */, + E164A7F52BE4814700A54B18 /* SelectUserServerSelection.swift */, E129429228F2845000796AC6 /* SliderType.swift */, E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, E11042742B8013DF00821020 /* Stateful.swift */, + E149CCAC2BE6ECC8008B9331 /* Storable.swift */, E1EF4C402911B783008CC695 /* StreamType.swift */, E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */, E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */, @@ -1654,6 +1763,7 @@ E1E306CC28EF6E8000537998 /* TimerProxy.swift */, E129428F28F0BDC300796AC6 /* TimeStampType.swift */, E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */, + E1EA09682BED78BB004CDE76 /* UserAccessPolicy.swift */, E1D8429229340B8300D1041A /* Utilities.swift */, E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, @@ -1670,8 +1780,10 @@ E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */, E1C92618288756BD002A7A66 /* DotHStack.swift */, E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */, + E1763A652BF3CA83004DF6AB /* FullScreenMenu.swift */, E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */, E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */, + E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */, E10E842B29A589860064EA49 /* NonePosterButton.swift */, E111D8F928D0400900400001 /* PagingLibraryView.swift */, E1C92617288756BD002A7A66 /* PosterButton.swift */, @@ -1681,7 +1793,6 @@ E17885A3278105170094FBCF /* SFSymbolButton.swift */, E12E30F0296383810022FAC9 /* SplitFormWindowView.swift */, E187A60429AD2E25008387E6 /* StepperView.swift */, - E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */, ); path = Components; sourceTree = ""; @@ -1905,7 +2016,9 @@ E18E01A7288746AF0022598C /* DotHStack.swift */, E1DE2B492B97ECB900F6715F /* ErrorView.swift */, E1921B7528E63306003A5238 /* GestureView.swift */, + E178B0752BE435D70023651B /* HourMinutePicker.swift */, E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */, + E1AEFA362BE317E200CFAFD8 /* ListRowButton.swift */, E1FE69AF28C2DA4A0021BC93 /* NavigationBarFilterDrawer */, E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */, E18E01A5288746AF0022598C /* PillHStack.swift */, @@ -1913,11 +2026,11 @@ E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */, E1AA331C2782541500F6439C /* PrimaryButton.swift */, E1D3043428D1763100587289 /* SeeAllButton.swift */, + E17DC74C2BE7601E00B42379 /* SettingsBarButton.swift */, E1D5C39728DF914100CDBEFB /* Slider */, E1581E26291EF59800D6C640 /* SplitContentView.swift */, E1D27EE62BBC955F00152D16 /* UnmaskSecureField.swift */, E157562F29355B7900976E1F /* UpdateView.swift */, - E192607F28D28AAD002314B4 /* UserProfileButton.swift */, ); path = Components; sourceTree = ""; @@ -1940,12 +2053,14 @@ E1B33EAF28EA890D0073B0FD /* Equatable.swift */, E133328729538D8D00EE76AB /* Files.swift */, E11CEB8C28999B4A003E74C7 /* Font.swift */, + E10432F52BE4426F006FF9DD /* FormatStyle.swift */, E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, E139CC1E28EC83E400688DE2 /* Int.swift */, E1AD105226D96D5F003E4A08 /* JellyfinAPI */, E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */, + E145EB442BE0AD4E003BF6F3 /* Set.swift */, 621338922660107500A81A2A /* String.swift */, E1DD55362B6EE533007501C0 /* Task.swift */, E1A2C153279A7D5A005EC829 /* UIApplication.swift */, @@ -1957,6 +2072,7 @@ 62E1DCC2273CE19800C9AE76 /* URL.swift */, E1C812C4277A90B200918266 /* URLComponents.swift */, E17AC9692954D00E003D2BC2 /* URLResponse.swift */, + E19D41AF2BF2B7540082B8B2 /* URLSessionConfiguration.swift */, E1A1528128FD126C00600579 /* VerticalAlignment.swift */, E11895A22893409D0042947B /* ViewExtensions */, ); @@ -1974,9 +2090,8 @@ 62C29E9D26D0FE5900C1D2E7 /* Coordinators */ = { isa = PBXGroup; children = ( - E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */, + E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */, E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */, - 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */, E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */, E13332902953B91000EE76AB /* DownloadTaskCoordinator.swift */, 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */, @@ -1989,11 +2104,9 @@ 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */, E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */, E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */, - E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */, 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */, - E13DD3E827177ED6009D4DAF /* ServerListCoordinator.swift */, 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */, - E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */, + E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */, E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */, E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */, @@ -2195,6 +2308,62 @@ path = ItemViewModel; sourceTree = ""; }; + E10B1EAF2BD9769500A92EAF /* SelectUserView */ = { + isa = PBXGroup; + children = ( + E10B1EB02BD9769C00A92EAF /* Components */, + E13DD3FB2717EAE8009D4DAF /* SelectUserView.swift */, + ); + path = SelectUserView; + sourceTree = ""; + }; + E10B1EB02BD9769C00A92EAF /* Components */ = { + isa = PBXGroup; + children = ( + E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */, + E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */, + E145EB412BE0A6EE003BF6F3 /* ServerSelectionMenu.swift */, + E1BE1CE92BDB5AFE008176A9 /* UserGridButton.swift */, + E10B1EB32BD9803100A92EAF /* UserRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + E10B1EB72BD9ACC800A92EAF /* SwiftfinStore */ = { + isa = PBXGroup; + children = ( + E146A9DD2BE6E9DC0034DA1E /* StoredValue */, + E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */, + E1763A702BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift */, + E145EB4E2BE168AC003BF6F3 /* SwiftfinStore+ServerState.swift */, + E145EB4C2BE1688E003BF6F3 /* SwiftinStore+UserState.swift */, + E10B1EB82BD9ACE900A92EAF /* V1Schema */, + E10B1EB92BD9ACFB00A92EAF /* V2Schema */, + ); + path = SwiftfinStore; + sourceTree = ""; + }; + E10B1EB82BD9ACE900A92EAF /* V1Schema */ = { + isa = PBXGroup; + children = ( + E10B1EC92BD9AF8200A92EAF /* SwiftfinStore+V1.swift */, + E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */, + E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */, + ); + path = V1Schema; + sourceTree = ""; + }; + E10B1EB92BD9ACFB00A92EAF /* V2Schema */ = { + isa = PBXGroup; + children = ( + E10B1ECC2BD9AFD800A92EAF /* SwiftfinStore+V2.swift */, + E164A8142BE58C2F00A54B18 /* V2AnyData.swift */, + E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */, + E10B1ECF2BD9AFF200A92EAF /* V2UserModel.swift */, + ); + path = V2Schema; + sourceTree = ""; + }; E10E842829A587090064EA49 /* Components */ = { isa = PBXGroup; children = ( @@ -2327,6 +2496,7 @@ E12186E02718F23B0010884C /* Views */ = { isa = PBXGroup; children = ( + E1763A752BF3FF01004DF6AB /* AppLoadingView.swift */, E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */, E10231522BCF8AF8009D71FC /* ChannelLibraryView */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, @@ -2339,11 +2509,11 @@ E10231572BCF8AF8009D71FC /* ProgramsView */, E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */, E1E1643928BAC2EF00323B0A /* SearchView.swift */, + E193D54A271941D300900D82 /* SelectServerView.swift */, + E164A8122BE4995200A54B18 /* SelectUserView */, E193D54F2719430400900D82 /* ServerDetailView.swift */, - E193D54A271941D300900D82 /* ServerListView.swift */, E1E5D54D2783E66600692DFE /* SettingsView */, - E193D546271941C500900D82 /* UserListView.swift */, - E193D548271941CC00900D82 /* UserSignInView.swift */, + E1763A672BF3D168004DF6AB /* UserSignInView */, 5310694F2684E7EE00CFFDBA /* VideoPlayer */, ); path = Views; @@ -2384,6 +2554,7 @@ children = ( E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */, 5377CBF4263B596A003A4E83 /* SwiftfinApp.swift */, + E1BAFE0F2BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift */, ); path = App; sourceTree = ""; @@ -2393,7 +2564,8 @@ children = ( E18E01F3288747580022598C /* AboutAppView.swift */, E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */, - E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */, + E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */, + E164A7F12BE471E700A54B18 /* AppSettingsView */, E10231382BCF8A3C009D71FC /* ChannelLibraryView */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */, @@ -2410,10 +2582,10 @@ E10231342BCF8A3C009D71FC /* ProgramsView */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, 53EE24E5265060780068F029 /* SearchView.swift */, + E10B1EAF2BD9769500A92EAF /* SelectUserView */, + E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, - E13DD3E427177D15009D4DAF /* ServerListView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, - E13DD3FB2717EAE8009D4DAF /* UserListView.swift */, E1171A1A28A2215800FA1AF5 /* UserSignInView */, E193D5452719418B00900D82 /* VideoPlayer */, ); @@ -2433,6 +2605,17 @@ path = AppIcons; sourceTree = ""; }; + E146A9DD2BE6E9DC0034DA1E /* StoredValue */ = { + isa = PBXGroup; + children = ( + E146A9D72BE6E9830034DA1E /* StoredValue.swift */, + E17DC7492BE740D900B42379 /* StoredValues+Server.swift */, + E1194F4F2BEB1E3000888DB6 /* StoredValues+Temp.swift */, + E146A9DA2BE6E9BF0034DA1E /* StoredValues+User.swift */, + ); + path = StoredValue; + sourceTree = ""; + }; E14EDECA2B8FB66F000F00A4 /* ItemFilter */ = { isa = PBXGroup; children = ( @@ -2460,6 +2643,17 @@ path = ItemView; sourceTree = ""; }; + E1545BD62BDC559500D9578F /* UserProfileSettingsView */ = { + isa = PBXGroup; + children = ( + 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, + E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */, + E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, + E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, + ); + path = UserProfileSettingsView; + sourceTree = ""; + }; E1546778289AF47100087E35 /* CollectionItemView */ = { isa = PBXGroup; children = ( @@ -2474,11 +2668,11 @@ children = ( E154965B296CA2EF00C4EF88 /* DownloadManager.swift */, E1549655296CA2EF00C4EF88 /* DownloadTask.swift */, + E19D41A92BF077130082B8B2 /* Keychain.swift */, E154965D296CA2EF00C4EF88 /* LogManager.swift */, - E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */, E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */, E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */, - E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */, + E1549657296CA2EF00C4EF88 /* UserSession.swift */, ); path = Services; sourceTree = ""; @@ -2492,6 +2686,42 @@ path = Components; sourceTree = ""; }; + E164A7F12BE471E700A54B18 /* AppSettingsView */ = { + isa = PBXGroup; + children = ( + E164A7F22BE471EE00A54B18 /* Components */, + E1D4BF7B2719D05000A11E64 /* AppSettingsView.swift */, + ); + path = AppSettingsView; + sourceTree = ""; + }; + E164A7F22BE471EE00A54B18 /* Components */ = { + isa = PBXGroup; + children = ( + E164A7F32BE4736300A54B18 /* SignOutIntervalSection.swift */, + ); + path = Components; + sourceTree = ""; + }; + E164A8122BE4995200A54B18 /* SelectUserView */ = { + isa = PBXGroup; + children = ( + E164A8132BE4995800A54B18 /* Components */, + E193D546271941C500900D82 /* SelectUserView.swift */, + ); + path = SelectUserView; + sourceTree = ""; + }; + E164A8132BE4995800A54B18 /* Components */ = { + isa = PBXGroup; + children = ( + E1763A282BF3046A004DF6AB /* AddUserButton.swift */, + E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, + E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, + ); + path = Components; + sourceTree = ""; + }; E168BD07289A4162001A6922 /* HomeView */ = { isa = PBXGroup; children = ( @@ -2523,7 +2753,7 @@ E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */, E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */, E13316FD2ADE42B6009BF865 /* OnSizeChangedModifier.swift */, - E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */, + E145EB472BE0C136003BF6F3 /* ScrollIfLargerThanModifier.swift */, E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */, E1E2F8442B757E3400B75998 /* SinceLastDisappearModifier.swift */, ); @@ -2570,6 +2800,23 @@ path = Components; sourceTree = ""; }; + E1763A672BF3D168004DF6AB /* UserSignInView */ = { + isa = PBXGroup; + children = ( + E1763A682BF3D16E004DF6AB /* Components */, + E193D548271941CC00900D82 /* UserSignInView.swift */, + ); + path = UserSignInView; + sourceTree = ""; + }; + E1763A682BF3D16E004DF6AB /* Components */ = { + isa = PBXGroup; + children = ( + E1763A692BF3D177004DF6AB /* PublicUserRow.swift */, + ); + path = Components; + sourceTree = ""; + }; E178859C2780F5300094FBCF /* tvOSSLider */ = { isa = PBXGroup; children = ( @@ -2643,7 +2890,8 @@ E18CE0B028A222310092E7F1 /* Components */ = { isa = PBXGroup; children = ( - E18CE0AE28A222240092E7F1 /* PublicUserSignInView.swift */, + E18CE0AE28A222240092E7F1 /* PublicUserRow.swift */, + E1EA09662BED6815004CDE76 /* UserSignInSecurityView.swift */, ); path = Components; sourceTree = ""; @@ -2848,7 +3096,7 @@ E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */, E122A9122788EAAD0060FA63 /* MediaStream.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, - E148128428C15472003B8787 /* SortOrder.swift */, + E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, E18CE0B128A229E70092E7F1 /* UserDto.swift */, ); path = JellyfinAPI; @@ -2860,6 +3108,7 @@ E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */, E104DC952B9E7E29008F506D /* AssertionFailureView.swift */, E18E0203288749200022598C /* BlurView.swift */, + E145EB212BDCCA43003BF6F3 /* BulletedList.swift */, E1153DCB2BBB633B00424D36 /* FastSVGView.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, E1D37F472B9C648E00343D2B /* MaxHeightText.swift */, @@ -2881,6 +3130,7 @@ isa = PBXGroup; children = ( E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */, + E1194F4D2BEABA9100888DB6 /* NavigationBarCloseButton.swift */, E113133028BDB6D600930F75 /* NavigationBarDrawerButtons */, E11895B12893842D0042947B /* NavigationBarOffset */, ); @@ -2921,6 +3171,23 @@ path = ActionButtons; sourceTree = ""; }; + E1BE1CEB2BDB68BC008176A9 /* SettingsView */ = { + isa = PBXGroup; + children = ( + E1BE1CEC2BDB68C4008176A9 /* Components */, + 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + ); + path = SettingsView; + sourceTree = ""; + }; + E1BE1CEC2BDB68C4008176A9 /* Components */ = { + isa = PBXGroup; + children = ( + E1BE1CED2BDB68CD008176A9 /* UserProfileRow.swift */, + ); + path = Components; + sourceTree = ""; + }; E1C925FA2887565C002A7A66 /* MovieItemView */ = { isa = PBXGroup; children = ( @@ -3080,8 +3347,8 @@ E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */, E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */, E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */, - 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */, - 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + E1545BD62BDC559500D9578F /* UserProfileSettingsView */, + E1BE1CEB2BDB68BC008176A9 /* SettingsView */, E1BDF2E7295148F400CC0294 /* VideoPlayerSettingsView */, ); path = SettingsView; @@ -3163,7 +3430,6 @@ E1FCD08E26C466F3007C8DCF /* Errors */ = { isa = PBXGroup; children = ( - E1FCD09526C47118007C8DCF /* ErrorMessage.swift */, E1FCD08726C35A0D007C8DCF /* NetworkError.swift */, ); path = Errors; @@ -3198,7 +3464,6 @@ ); name = "Swiftfin tvOS"; packageProductDependencies = ( - 535870902669D7A800D05A09 /* Introspect */, 6220D0C826D63F3700B8E046 /* Stinsen */, E13DD3CC27164CA7009D4DAF /* CoreStore */, E12186DD2718F1C50010884C /* Defaults */, @@ -3216,12 +3481,13 @@ E1B5F7AA29577BCE004B26CF /* PulseUI */, E1B5F7AC29577BDD004B26CF /* OrderedCollections */, E18443CA2A037773002DDDC8 /* UDPBroadcast */, - E14CB6872A9FF71F001586C6 /* JellyfinAPI */, E1A7B1642B9A9F7800152546 /* PreferencesView */, E1392FEC2BA218A80034110D /* SwiftUIIntrospect */, E1153DB02BBA734C00424D36 /* CollectionHStack */, E1153DD12BBB649C00424D36 /* SVGKit */, E132D3CE2BD217AA0058A2DF /* CollectionVGrid */, + E19D41B12BF2BFA50082B8B2 /* KeychainSwift */, + E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -3244,7 +3510,6 @@ ); name = "Swiftfin iOS"; packageProductDependencies = ( - 53352570265EA0A0006CCA86 /* Introspect */, 62C29E9B26D0FE4200C1D2E7 /* Stinsen */, E13DD3C52716499E009D4DAF /* CoreStore */, E13DD3D227168E65009D4DAF /* Defaults */, @@ -3262,7 +3527,6 @@ E19DDEC62948EF9900954E10 /* OrderedCollections */, E1DC9813296DC06200982F06 /* PulseLogHandler */, E1FAD1C52A0375BA007F5521 /* UDPBroadcast */, - E14CB6852A9FF62A001586C6 /* JellyfinAPI */, E1523F812B132C350062821A /* CollectionHStack */, E114DB322B1944FA00B75FB3 /* CollectionVGrid */, E15D4F042B1B0C3C00442DB8 /* PreferencesView */, @@ -3277,6 +3541,8 @@ E1153DCF2BBB634F00424D36 /* SVGKit */, E132D3C72BD200C10058A2DF /* CollectionVGrid */, E132D3CC2BD2179C0058A2DF /* CollectionVGrid */, + E145EB4A2BE16849003BF6F3 /* KeychainSwift */, + E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -3343,11 +3609,12 @@ E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */, E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */, E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */, - E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */, E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */, E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */, E132D3CB2BD2179C0058A2DF /* XCRemoteSwiftPackageReference "CollectionVGrid" */, + E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */, + E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -3496,10 +3763,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E1AEFA392BE36C4C00CFAFD8 /* SwiftfinStore+ServerState.swift in Sources */, E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */, E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, - E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, + E145EB262BE055AD003BF6F3 /* ServerResponse.swift in Sources */, E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, E18E021E2887492B0022598C /* RowDivider.swift in Sources */, @@ -3516,22 +3784,24 @@ C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */, E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, + E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */, E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */, E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, - E1549663296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */, + E1549663296CA2EF00C4EF88 /* UserSession.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, + E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */, E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */, E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, E187A60529AD2E25008387E6 /* StepperView.swift in Sources */, E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */, - E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */, - E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, + E146A9D92BE6E9830034DA1E /* StoredValue.swift in Sources */, + E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */, E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */, - E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */, @@ -3540,10 +3810,12 @@ E1DD55382B6EE533007501C0 /* Task.swift in Sources */, E1575EA1293E7B1E001665B1 /* String.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, + E1763A292BF3046A004DF6AB /* AddUserButton.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, E14E9DF22BCF7A99004E3371 /* ItemLetter.swift in Sources */, + E10B1EC82BD9AF6100A92EAF /* V2ServerModel.swift in Sources */, E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, E1E6C45629B130F50064123F /* ChapterOverlay.swift in Sources */, @@ -3558,7 +3830,9 @@ E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */, E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */, E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */, + E1763A762BF3FF01004DF6AB /* AppLoadingView.swift in Sources */, E102315A2BCF8AF8009D71FC /* ProgramButtonContent.swift in Sources */, + E17639F82BF2E25B004DF6AB /* Keychain.swift in Sources */, C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */, @@ -3573,6 +3847,7 @@ E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, + E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, @@ -3585,6 +3860,7 @@ E12376B12A33DB33001F5B44 /* MediaSourceInfoCoordinator.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, + E1763A272BF303C9004DF6AB /* ServerSelectionMenu.swift in Sources */, E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */, E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */, E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */, @@ -3593,12 +3869,13 @@ E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */, E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, - E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E1575E93293E7B1E001665B1 /* Double.swift in Sources */, E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */, E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */, + E10B1EBF2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */, E17AC96B2954D00E003D2BC2 /* URLResponse.swift in Sources */, + E1763A2B2BF3046E004DF6AB /* UserGridButton.swift in Sources */, E1EF473A289A0F610034046B /* TruncatedText.swift in Sources */, E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */, E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, @@ -3607,12 +3884,14 @@ 62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5398514526B64DA100101B49 /* SettingsView.swift in Sources */, - E193D54B271941D300900D82 /* ServerListView.swift in Sources */, + E193D54B271941D300900D82 /* SelectServerView.swift in Sources */, E1575E91293E7B1E001665B1 /* URL.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, + E10B1EC22BD9AD6100A92EAF /* V1UserModel.swift in Sources */, E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */, E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */, + E10432F72BE4426F006FF9DD /* FormatStyle.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, @@ -3633,10 +3912,10 @@ E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */, E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */, - E10B1E8B2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */, E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */, E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */, E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, + E17DC74B2BE740D900B42379 /* StoredValues+Server.swift in Sources */, E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, @@ -3655,10 +3934,14 @@ E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */, + E19D41B32BF2BFEF0082B8B2 /* URLSessionConfiguration.swift in Sources */, + E10B1ECE2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */, + E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, + E146A9DC2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */, E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */, E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, @@ -3672,10 +3955,12 @@ E1575EA2293E7B1E001665B1 /* Color.swift in Sources */, E12E30F5296392EC0022FAC9 /* EnumPickerView.swift in Sources */, E1575E72293E77B5001665B1 /* Utilities.swift in Sources */, + E164A7F72BE4816500A54B18 /* SelectUserServerSelection.swift in Sources */, E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */, E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */, E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */, E1E6C45129B104850064123F /* Button.swift in Sources */, + E19D41B52BF2C0130082B8B2 /* V2AnyData.swift in Sources */, E102315C2BCF8AF8009D71FC /* ProgramsView.swift in Sources */, E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */, @@ -3683,9 +3968,11 @@ 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, E1575E66293E77B5001665B1 /* Poster.swift in Sources */, E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */, + E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */, E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */, C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */, + E1BCDB502BE1F491009F6744 /* ResetUserPasswordViewModel.swift in Sources */, C46DD8D72A8DC2990046A504 /* LiveVideoPlayer.swift in Sources */, E1575E88293E7A00001665B1 /* LightAppIcon.swift in Sources */, E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */, @@ -3695,16 +3982,19 @@ E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, + E149CCAE2BE6ECC8008B9331 /* Storable.swift in Sources */, C46DD8D92A8DC2990046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1575E9F293E7B1E001665B1 /* Int.swift in Sources */, E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */, + E145EB462BE0AD4E003BF6F3 /* Set.swift in Sources */, E1575E7D293E77B5001665B1 /* PosterDisplayType.swift in Sources */, E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */, + E1763A6A2BF3D177004DF6AB /* PublicUserRow.swift in Sources */, E1E6C44B29AED2B70064123F /* HorizontalAlignment.swift in Sources */, E193D549271941CC00900D82 /* UserSignInView.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, - E148128628C15475003B8787 /* SortOrder.swift in Sources */, + E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */, E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */, E133328929538D8D00EE76AB /* Files.swift in Sources */, @@ -3718,20 +4008,23 @@ E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */, E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */, + E1763A252BF2F77B004DF6AB /* ScrollIfLargerThanModifier.swift in Sources */, E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */, E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */, + E1763A642BF3C9AA004DF6AB /* ListRowButton.swift in Sources */, E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */, E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */, E1153D942BBA3D3000424D36 /* EpisodeContent.swift in Sources */, E11BDF982B865F550045C54A /* ItemTag.swift in Sources */, E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, - E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, + E193D53C27193F9500900D82 /* SelectUserCoordinator.swift in Sources */, E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */, E154967C296CBB1A00C4EF88 /* FontPickerView.swift in Sources */, E15D63F02BD6DFC200AA665D /* SystemImageable.swift in Sources */, - E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */, + E1EA096A2BED78F5004CDE76 /* UserAccessPolicy.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */, + E1AEFA382BE36C4900CFAFD8 /* SwiftinStore+UserState.swift in Sources */, E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, @@ -3761,6 +4054,7 @@ E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */, E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, + E10B1ECB2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */, E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, 535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */, E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */, @@ -3772,8 +4066,7 @@ E1D37F4F2B9CEDC400343D2B /* DeviceProfile.swift in Sources */, E1575E94293E7B1E001665B1 /* VerticalAlignment.swift in Sources */, E1575EA3293E7B1E001665B1 /* UIDevice.swift in Sources */, - E193D547271941C500900D82 /* UserListView.swift in Sources */, - E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, + E193D547271941C500900D82 /* SelectUserView.swift in Sources */, E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E10231592BCF8AF8009D71FC /* ChannelLibraryView.swift in Sources */, E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */, @@ -3781,7 +4074,6 @@ E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, - E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */, @@ -3792,6 +4084,7 @@ E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */, 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */, E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, + E1763A662BF3CA83004DF6AB /* FullScreenMenu.swift in Sources */, E14EDECD2B8FB709000F00A4 /* ItemYear.swift in Sources */, E154965F296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, E154967E296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift in Sources */, @@ -3814,6 +4107,7 @@ 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, + E146A9D82BE6E9830034DA1E /* StoredValue.swift in Sources */, 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */, 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */, @@ -3831,6 +4125,7 @@ E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */, E1C812C5277A90B200918266 /* URLComponents.swift in Sources */, E1C925F428875037002A7A66 /* ItemViewType.swift in Sources */, + E17DC74A2BE740D900B42379 /* StoredValues+Server.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */, @@ -3841,6 +4136,7 @@ E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */, + E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, @@ -3858,12 +4154,13 @@ E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */, E16AA60828A364A6009A983C /* PosterButton.swift in Sources */, E1E1644128BB301900323B0A /* Array.swift in Sources */, - E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */, + E18CE0AF28A222240092E7F1 /* PublicUserRow.swift in Sources */, E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, E1E2F8422B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */, + E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */, E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, @@ -3887,6 +4184,7 @@ E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */, E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */, E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */, + E145EB452BE0AD4E003BF6F3 /* Set.swift in Sources */, E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */, C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, @@ -3895,18 +4193,22 @@ E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, + E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */, E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */, E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, + E1BE1CEE2BDB68CD008176A9 /* UserProfileRow.swift in Sources */, E17FB55228C119D400311DFE /* Displayable.swift in Sources */, - E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, E113132B28BDB4B500930F75 /* NavigationBarDrawerView.swift in Sources */, + E164A7F62BE4814700A54B18 /* SelectUserServerSelection.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, + E1EA09882BEE9CF3004CDE76 /* UserLocalSecurityView.swift in Sources */, E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */, + E19D41AA2BF077130082B8B2 /* Keychain.swift in Sources */, E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */, E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */, E12A9EF829499E0100731C3A /* JellyfinClient.swift in Sources */, @@ -3914,12 +4216,12 @@ E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */, 62133890265F83A900A81A2A /* MediaView.swift in Sources */, - 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */, E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */, E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */, E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, + E1EA09692BED78BB004CDE76 /* UserAccessPolicy.swift in Sources */, E18E0204288749200022598C /* RowDivider.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */, @@ -3931,22 +4233,28 @@ E15756322935642A00976E1F /* Double.swift in Sources */, E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */, E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, + E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */, E1E6C45029B104840064123F /* Button.swift in Sources */, E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */, + E10432F62BE4426F006FF9DD /* FormatStyle.swift in Sources */, E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */, E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E11245B728D97ED200D8A977 /* TopBarView.swift in Sources */, + E145EB222BDCCA43003BF6F3 /* BulletedList.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */, E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */, E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, + E145EB252BE055AD003BF6F3 /* ServerResponse.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, + E1BAFE102BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */, E1DD55372B6EE533007501C0 /* Task.swift in Sources */, + E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */, E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, @@ -3960,18 +4268,21 @@ E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */, + E146A9DB2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */, E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, E1E306CD28EF6E8000537998 /* TimerProxy.swift in Sources */, BD0BA22E2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */, E18E01F0288747230022598C /* AttributeHStack.swift in Sources */, - 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */, + 6334175B287DDFB9000603CE /* QuickConnectAuthorizeView.swift in Sources */, E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, + E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */, E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */, + E19D41A72BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, E15D63EF2BD6DFC200AA665D /* SystemImageable.swift in Sources */, E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, @@ -3981,13 +4292,16 @@ E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, + E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanModifier.swift in Sources */, E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */, E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */, + E178B0762BE435D70023651B /* HourMinutePicker.swift in Sources */, E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */, 6267B3D626710B8900A7371D /* Collection.swift in Sources */, E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, E17AC9732955007A003D2BC2 /* DownloadTaskButton.swift in Sources */, + E145EB4F2BE168AC003BF6F3 /* SwiftfinStore+ServerState.swift in Sources */, E1A1528228FD126C00600579 /* VerticalAlignment.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */, @@ -3999,15 +4313,13 @@ E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */, E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */, - E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */, E12CC1AE28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E1A1528528FD191A00600579 /* TextPair.swift in Sources */, - 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */, + 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */, - E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, @@ -4032,13 +4344,14 @@ E1D8424F2932F7C400D1041A /* OverviewView.swift in Sources */, E113133628BE98AA00930F75 /* FilterDrawerButton.swift in Sources */, E1DE84142B9531C1008CCE21 /* OrderedSectionSelectorView.swift in Sources */, - E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */, + E13DD3FC2717EAE8009D4DAF /* SelectUserView.swift in Sources */, E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */, E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */, - E10B1E8A2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */, E1EF4C412911B783008CC695 /* StreamType.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */, + E1EA09672BED6815004CDE76 /* UserSignInSecurityView.swift in Sources */, + E1BCDB4F2BE1F491009F6744 /* ResetUserPasswordViewModel.swift in Sources */, E1921B7628E63306003A5238 /* GestureView.swift in Sources */, E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, @@ -4050,19 +4363,19 @@ E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */, E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, + E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */, - E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */, + E10B1EC12BD9AD6100A92EAF /* V1UserModel.swift in Sources */, E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */, E1D842912933F87500D1041A /* ItemFields.swift in Sources */, E1BDF2F729524ECD00CC0294 /* PlaybackSpeedActionButton.swift in Sources */, E113132F28BDB66A00930F75 /* NavigationBarDrawerModifier.swift in Sources */, E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */, - E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */, E129429328F2845000796AC6 /* SliderType.swift in Sources */, E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */, @@ -4071,10 +4384,11 @@ E18E01EE288747230022598C /* AboutView.swift in Sources */, 62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */, - E1549662296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */, + E1549662296CA2EF00C4EF88 /* UserSession.swift in Sources */, E15756362936856700976E1F /* VideoPlayerType.swift in Sources */, E1DA654C28E69B0500592A73 /* SpecialFeatureType.swift in Sources */, E11CEB8B28998552003E74C7 /* View-iOS.swift in Sources */, + E10B1ECD2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */, E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */, E1A1529028FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, E11042752B8013DF00821020 /* Stateful.swift in Sources */, @@ -4093,22 +4407,30 @@ E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */, E18E01E9288747230022598C /* SeriesItemView.swift in Sources */, E15756342936851D00976E1F /* NativeVideoPlayerSettingsView.swift in Sources */, - E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */, + E1D4BF7C2719D05000A11E64 /* AppSettingsView.swift in Sources */, + E19D41AE2BF288320082B8B2 /* ServerCheckViewModel.swift in Sources */, E1BDF2F329524C3B00CC0294 /* ChaptersActionButton.swift in Sources */, E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, + E1BE1CF02BDB6C97008176A9 /* UserProfileSettingsView.swift in Sources */, E1CFE28028FA606800B7D34C /* ChapterTrack.swift in Sources */, E1401CA22938122C00E8B599 /* AppIcons.swift in Sources */, E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */, E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, + E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */, + E149CCAD2BE6ECC8008B9331 /* Storable.swift in Sources */, E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */, E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */, E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */, + E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, + E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */, + E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */, E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */, + E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, E1D8429329340B8300D1041A /* Utilities.swift in Sources */, @@ -4118,8 +4440,10 @@ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */, E1A7B1662B9ADAD300152546 /* ItemTypeLibraryViewModel.swift in Sources */, + E1545BD82BDC55C300D9578F /* ResetUserPasswordView.swift in Sources */, E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, + E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, @@ -4132,6 +4456,7 @@ E18E01F1288747230022598C /* PlayButton.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, + E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */, E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */, 62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */, E11BDF7A2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, @@ -4145,10 +4470,11 @@ E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, - E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */, E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */, - E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */, + E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */, + E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */, E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */, E1E6C44029AECC6D0064123F /* ActionButtons.swift in Sources */, @@ -4156,12 +4482,15 @@ 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, E1E2F83F2B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E15D4F072B1B12C300442DB8 /* Backport.swift in Sources */, + E10B1ED02BD9AFF200A92EAF /* V2UserModel.swift in Sources */, E1549660296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, + E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */, E18E01EA288747230022598C /* MovieItemView.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, - E148128528C15472003B8787 /* SortOrder.swift in Sources */, + E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */, + E148128528C15472003B8787 /* SortOrder+ItemSortOrder.swift in Sources */, E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */, E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, @@ -4171,12 +4500,12 @@ E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */, E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, - E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, + E13DD4022717EE79009D4DAF /* SelectUserCoordinator.swift in Sources */, E11245B128D919CD00D8A977 /* Overlay.swift in Sources */, - E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, + E145EB4D2BE1688E003BF6F3 /* SwiftinStore+UserState.swift in Sources */, 53EE24E6265060780068F029 /* SearchView.swift in Sources */, + E164A8152BE58C2F00A54B18 /* V2AnyData.swift in Sources */, E1D37F522B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, - E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */, E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4622,7 +4951,7 @@ repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.1.3; + minimumVersion = 1.0.0; }; }; 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */ = { @@ -4697,12 +5026,12 @@ minimumVersion = 7.0.0; }; }; - E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { + E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift.git"; + repositoryURL = "https://github.com/evgenyneu/keychain-swift"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.3.1; + minimumVersion = 24.0.0; }; }; E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */ = { @@ -4721,6 +5050,14 @@ minimumVersion = 4.0.0; }; }; + E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.4; + }; + }; E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LePips/VLCUI"; @@ -4780,16 +5117,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 53352570265EA0A0006CCA86 /* Introspect */ = { - isa = XCSwiftPackageProductDependency; - package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; - productName = Introspect; - }; - 535870902669D7A800D05A09 /* Introspect */ = { - isa = XCSwiftPackageProductDependency; - package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; - productName = Introspect; - }; 6220D0C826D63F3700B8E046 /* Stinsen */ = { isa = XCSwiftPackageProductDependency; package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; @@ -4934,15 +5261,10 @@ package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - E14CB6852A9FF62A001586C6 /* JellyfinAPI */ = { + E145EB4A2BE16849003BF6F3 /* KeychainSwift */ = { isa = XCSwiftPackageProductDependency; - package = E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; - }; - E14CB6872A9FF71F001586C6 /* JellyfinAPI */ = { - isa = XCSwiftPackageProductDependency; - package = E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; + package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */; + productName = KeychainSwift; }; E15210532946DF1B00375CC2 /* Pulse */ = { isa = XCSwiftPackageProductDependency; @@ -4991,6 +5313,16 @@ package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = SwiftUIIntrospect; }; + E1763A6C2BF3DE17004DF6AB /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + E1763A6E2BF3DE23004DF6AB /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E1763A6B2BF3DE17004DF6AB /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; E18443CA2A037773002DDDC8 /* UDPBroadcast */ = { isa = XCSwiftPackageProductDependency; package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */; @@ -5015,6 +5347,11 @@ package = E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */; productName = Factory; }; + E19D41B12BF2BFA50082B8B2 /* KeychainSwift */ = { + isa = XCSwiftPackageProductDependency; + package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */; + productName = KeychainSwift; + }; E19DDEC62948EF9900954E10 /* OrderedCollections */ = { isa = XCSwiftPackageProductDependency; package = E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 648426ef..57454d59 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c477e4f1cd54bd525946c46dd4d4243ceca54f879672628a772af4ecb8aa0a79", + "originHash" : "558c2e760c073dbad0a2bfbade5ccfa1b2962fdd8ab5f658d9bbfc4310623441", "pins" : [ { "identity" : "blurhashkit", @@ -34,7 +34,7 @@ "location" : "https://github.com/LePips/CollectionVGrid", "state" : { "branch" : "main", - "revision" : "e4e0adc7722430870293e390e32d35c37a0d047b" + "revision" : "b50b5241df5fc1d71e5a09f6a87731c67c2a79e5" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Get", "state" : { - "revision" : "12830cc64f31789ae6f4352d2d51d03a25fc3741", - "version" : "2.1.6" + "revision" : "74dba201ebe42e9c15c1db6ee1cc893025bbef94", + "version" : "2.2.0" } }, { @@ -96,8 +96,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jellyfin/jellyfin-sdk-swift.git", "state" : { - "revision" : "30957ea3fe007eaecb203d16217c0c07dc3bcd8e", - "version" : "0.3.3" + "revision" : "eae2ab5ed7caf770d79afbcdae08aab48df27a6e", + "version" : "0.3.4" + } + }, + { + "identity" : "keychain-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/evgenyneu/keychain-swift", + "state" : { + "revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608", + "version" : "24.0.0" } }, { @@ -186,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect", "state" : { - "revision" : "121c146fe591b1320238d054ae35c81ffa45f45a", - "version" : "0.12.0" + "revision" : "0cd2a5a5895306bc21d54a2254302d24a9a571e4", + "version" : "1.1.3" } }, { diff --git a/Swiftfin/App/SwiftfinApp+ValueObservation.swift b/Swiftfin/App/SwiftfinApp+ValueObservation.swift new file mode 100644 index 00000000..24c8cf53 --- /dev/null +++ b/Swiftfin/App/SwiftfinApp+ValueObservation.swift @@ -0,0 +1,122 @@ +// +// 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 Defaults +import Foundation +import SwiftUI + +// Following class is necessary to observe values that can either +// be a user *or* an app setting and only one should apply at a time. +// +// Also just to separate out value observation + +// TODO: could clean up? + +extension SwiftfinApp { + + class ValueObservation: ObservableObject { + + private var accentColorCancellable: AnyCancellable? + private var appearanceCancellable: AnyCancellable? + private var lastSignInUserIDCancellable: AnyCancellable? + private var splashScreenCancellable: AnyCancellable? + + init() { + + // MARK: signed in observation + + lastSignInUserIDCancellable = Task { + for await newValue in Defaults.updates(.lastSignedInUserID) { + if let _ = newValue { + setUserDefaultsObservation() + } else { + setAppDefaultsObservation() + } + } + } + .asAnyCancellable() + } + + // MARK: user observation + + private func setUserDefaultsObservation() { + accentColorCancellable?.cancel() + appearanceCancellable?.cancel() + splashScreenCancellable?.cancel() + + accentColorCancellable = Task { + for await newValue in Defaults.updates(.userAccentColor) { + await MainActor.run { + Defaults[.accentColor] = newValue + UIApplication.shared.setAccentColor(newValue.uiColor) + } + } + } + .asAnyCancellable() + + appearanceCancellable = Task { + for await newValue in Defaults.updates(.userAppearance) { + await MainActor.run { + Defaults[.appearance] = newValue + UIApplication.shared.setAppearance(newValue.style) + } + } + } + .asAnyCancellable() + } + + // MARK: app observation + + private func setAppDefaultsObservation() { + accentColorCancellable?.cancel() + appearanceCancellable?.cancel() + splashScreenCancellable?.cancel() + + accentColorCancellable = Task { + for await newValue in Defaults.updates(.appAccentColor) { + await MainActor.run { + Defaults[.accentColor] = newValue + UIApplication.shared.setAccentColor(newValue.uiColor) + } + } + } + .asAnyCancellable() + + appearanceCancellable = Task { + for await newValue in Defaults.updates(.appAppearance) { + + // other cancellable will set appearance if enabled + // and need to avoid races + guard !Defaults[.selectUserUseSplashscreen] else { continue } + + await MainActor.run { + Defaults[.appearance] = newValue + UIApplication.shared.setAppearance(newValue.style) + } + } + } + .asAnyCancellable() + + splashScreenCancellable = Task { + for await newValue in Defaults.updates(.selectUserUseSplashscreen) { + await MainActor.run { + if newValue { + Defaults[.appearance] = .dark + UIApplication.shared.setAppearance(.dark) + } else { + Defaults[.appearance] = Defaults[.appAppearance] + UIApplication.shared.setAppearance(Defaults[.appAppearance].style) + } + } + } + } + .asAnyCancellable() + } + } +} diff --git a/Swiftfin/App/SwiftfinApp.swift b/Swiftfin/App/SwiftfinApp.swift index 99d63476..f237814a 100644 --- a/Swiftfin/App/SwiftfinApp.swift +++ b/Swiftfin/App/SwiftfinApp.swift @@ -8,6 +8,7 @@ import CoreStore import Defaults +import Factory import Logging import PreferencesView import Pulse @@ -20,21 +21,11 @@ struct SwiftfinApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject + private var valueObservation = ValueObservation() + init() { - // Defaults - Task { - for await newValue in Defaults.updates(.accentColor) { - UIApplication.shared.setAccentColor(newValue.uiColor) - } - } - - Task { - for await newValue in Defaults.updates(.appAppearance) { - UIApplication.shared.setAppearance(newValue.style) - } - } - // Logging LoggingSystem.bootstrap { label in @@ -50,6 +41,8 @@ struct SwiftfinApp: App { CoreStoreDefaults.dataStack = SwiftfinStore.dataStack CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + UIScrollView.appearance().keyboardDismissMode = .onDrag + // Sometimes the tab bar won't appear properly on push, always have material background UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified) } @@ -62,8 +55,20 @@ struct SwiftfinApp: App { .supportedOrientations(UIDevice.isPad ? .allButUpsideDown : .portrait) } .ignoresSafeArea() - .onOpenURL { url in - AppURLHandler.shared.processDeepLink(url: url) + .onNotification(UIApplication.didEnterBackgroundNotification) { _ in + Defaults[.backgroundTimeStamp] = Date.now + } + .onNotification(UIApplication.willEnterForegroundNotification) { _ in + + // TODO: needs to check if any background playback is happening + // - atow, background video playback isn't officially supported + let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp]) + + if backgroundedInterval > Defaults[.backgroundSignOutInterval] { + Defaults[.lastSignedInUserID] = nil + UserSession.current.reset() + Notifications[.didSignOut].post() + } } } } diff --git a/Swiftfin/Components/ErrorView.swift b/Swiftfin/Components/ErrorView.swift index 2fc0a629..e5f55bd3 100644 --- a/Swiftfin/Components/ErrorView.swift +++ b/Swiftfin/Components/ErrorView.swift @@ -9,10 +9,10 @@ import SwiftUI // TODO: should use environment refresh instead? -struct ErrorView<_Error: Error>: View { +struct ErrorView: View { - private let error: _Error - private var onRetry: () -> Void + private let error: ErrorType + private var onRetry: (() -> Void)? var body: some View { VStack(spacing: 20) { @@ -24,20 +24,22 @@ struct ErrorView<_Error: Error>: View { .frame(minWidth: 50, maxWidth: 240) .multilineTextAlignment(.center) - PrimaryButton(title: L10n.retry) - .onSelect(onRetry) - .frame(maxWidth: 300) - .frame(height: 50) + if let onRetry { + PrimaryButton(title: L10n.retry) + .onSelect(onRetry) + .frame(maxWidth: 300) + .frame(height: 50) + } } } } extension ErrorView { - init(error: _Error) { + init(error: ErrorType) { self.init( error: error, - onRetry: {} + onRetry: nil ) } diff --git a/Swiftfin/Components/HourMinutePicker.swift b/Swiftfin/Components/HourMinutePicker.swift new file mode 100644 index 00000000..a2b00e55 --- /dev/null +++ b/Swiftfin/Components/HourMinutePicker.swift @@ -0,0 +1,51 @@ +// +// 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 HourMinutePicker: UIViewRepresentable { + + let interval: Binding + + func makeUIView(context: Context) -> some UIView { + let picker = UIDatePicker(frame: .zero) + picker.translatesAutoresizingMaskIntoConstraints = false + picker.datePickerMode = .countDownTimer + + context.coordinator.add(picker: picker) + context.coordinator.interval = interval + + return picker + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + + var interval: Binding! + + func add(picker: UIDatePicker) { + picker.addTarget( + self, + action: #selector( + dateChanged + ), + for: .valueChanged + ) + } + + @objc + func dateChanged(_ picker: UIDatePicker) { + interval.wrappedValue = picker.countDownDuration + } + } +} diff --git a/Swiftfin/Components/ListRowButton.swift b/Swiftfin/Components/ListRowButton.swift new file mode 100644 index 00000000..3800571e --- /dev/null +++ b/Swiftfin/Components/ListRowButton.swift @@ -0,0 +1,45 @@ +// +// 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 ListRowButton: View { + + let title: String + let action: () -> Void + + init(_ title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + Button(title) { + action() + } + .font(.body.weight(.bold)) + .buttonStyle(ListRowButtonStyle()) + .listRowInsets(.init(.zero)) + } +} + +private struct ListRowButtonStyle: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + ZStack { + Rectangle() + .foregroundStyle(.secondary) + + configuration.label + .foregroundStyle(.primary) + } + .opacity(configuration.isPressed ? 0.75 : 1) + .frame(maxWidth: .infinity) + .listRowInsets(.zero) + } +} diff --git a/Swiftfin/Components/PrimaryButton.swift b/Swiftfin/Components/PrimaryButton.swift index c960c29c..b65fe36a 100644 --- a/Swiftfin/Components/PrimaryButton.swift +++ b/Swiftfin/Components/PrimaryButton.swift @@ -29,8 +29,8 @@ struct PrimaryButton: View { .cornerRadius(10) Text(title) + .fontWeight(.bold) .foregroundColor(accentColor.overlayColor) - .bold() } } } diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift new file mode 100644 index 00000000..2f976a2e --- /dev/null +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -0,0 +1,55 @@ +// +// 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 SwiftUI + +// Want the default navigation bar `Image(systemName:)` styling +// but using within `ImageView.placeholder/failure` ruins it. +// Need to do manual checking of image loading. +struct SettingsBarButton: View { + + @State + private var isUserImage = false + + let server: ServerState + let user: UserState + let action: () -> Void + + var body: some View { + Button { + action() + } label: { + Image(systemName: "gearshape.fill") + .visible(!isUserImage) + .overlay { + ZStack { + Color.clear + + ImageView(user.profileImageSource( + client: server.client, + maxWidth: 120 + )) + .image { image in + image + .clipShape(.circle) + .aspectRatio(1, contentMode: .fit) + .posterBorder(ratio: 1 / 2, of: \.width) + .onAppear { + isUserImage = true + } + } + .placeholder { _ in + Color.clear + } + } + } + } + .accessibilityLabel(L10n.settings) + } +} diff --git a/Swiftfin/Components/UnmaskSecureField.swift b/Swiftfin/Components/UnmaskSecureField.swift index 630157e8..a1b8015b 100644 --- a/Swiftfin/Components/UnmaskSecureField.swift +++ b/Swiftfin/Components/UnmaskSecureField.swift @@ -8,31 +8,51 @@ import SwiftUI +// TODO: use _UIHostingView for button animation workaround? +// - have a nice animation for toggle + struct UnmaskSecureField: UIViewRepresentable { @Binding private var text: String - let title: String + private let onReturn: () -> Void + private let title: String - init(_ title: String, text: Binding) { - self.title = title + init( + _ title: String, + text: Binding, + onReturn: @escaping () -> Void = {} + ) { self._text = text + self.title = title + self.onReturn = onReturn } - func makeUIView(context: Context) -> some UIView { + func makeUIView(context: Context) -> UITextField { let textField = UITextField() textField.isSecureTextEntry = true textField.keyboardType = .asciiCapable textField.placeholder = title textField.text = text - textField.addTarget(context.coordinator, action: #selector(Coordinator.textDidChange), for: .editingChanged) + textField.addTarget( + context.coordinator, + action: #selector(Coordinator.textDidChange), + for: .editingChanged + ) let button = UIButton(type: .custom) button.translatesAutoresizingMaskIntoConstraints = false - button.addTarget(context.coordinator, action: #selector(Coordinator.buttonPressed), for: .touchUpInside) - button.setImage(UIImage(systemName: "eye.fill"), for: .normal) + button.addTarget( + context.coordinator, + action: #selector(Coordinator.buttonPressed), + for: .touchUpInside + ) + button.setImage( + UIImage(systemName: "eye.fill"), + for: .normal + ) NSLayoutConstraint.activate([ button.heightAnchor.constraint(equalToConstant: 50), @@ -43,24 +63,32 @@ struct UnmaskSecureField: UIViewRepresentable { textField.rightViewMode = .always context.coordinator.button = button + context.coordinator.onReturn = onReturn context.coordinator.textField = textField context.coordinator.textDidChange() context.coordinator.textBinding = _text + textField.delegate = context.coordinator + return textField } - func updateUIView(_ uiView: UIViewType, context: Context) {} + func updateUIView(_ textField: UITextField, context: Context) { + if text != textField.text { + textField.text = text + } + } func makeCoordinator() -> Coordinator { Coordinator() } - class Coordinator { + class Coordinator: NSObject, UITextFieldDelegate { weak var button: UIButton? weak var textField: UITextField? var textBinding: Binding = .constant("") + var onReturn: () -> Void = {} @objc func buttonPressed() { @@ -77,6 +105,11 @@ struct UnmaskSecureField: UIViewRepresentable { button?.isEnabled = !text.isEmpty textBinding.wrappedValue = text } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + onReturn() + return true + } } } diff --git a/Swiftfin/Components/UserProfileButton.swift b/Swiftfin/Components/UserProfileButton.swift deleted file mode 100644 index aae2399b..00000000 --- a/Swiftfin/Components/UserProfileButton.swift +++ /dev/null @@ -1,63 +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 JellyfinAPI -import SwiftUI - -// TODO: remove client passing and mirror how other images are made - -struct UserProfileButton: View { - - private let client: JellyfinClient - private let user: UserDto - private var onSelect: () -> Void - - // TODO: Why both? - init(user: UserDto, client: JellyfinClient) { - self.client = client - self.user = user - self.onSelect = {} - } - - init(user: UserState, client: JellyfinClient) { - self.client = client - self.user = .init(id: user.id, name: user.username) - self.onSelect = {} - } - - var body: some View { - VStack(alignment: .center) { - Button { - onSelect() - } label: { - ImageView(user.profileImageSource(client: client, maxWidth: 120, maxHeight: 120)) - .failure { - ZStack { - Color.secondarySystemFill - .opacity(0.5) - - Image(systemName: "person.fill") - .resizable() - .frame(width: 60, height: 60) - } - } - .clipShape(Circle()) - } - .frame(width: 120, height: 120) - - Text(user.name ?? .emptyDash) - } - } -} - -extension UserProfileButton { - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Extensions/Label-iOS.swift b/Swiftfin/Extensions/Label-iOS.swift index bdd77978..cb3ab26c 100644 --- a/Swiftfin/Extensions/Label-iOS.swift +++ b/Swiftfin/Extensions/Label-iOS.swift @@ -15,13 +15,6 @@ extension LabelStyle where Self == EpisodeSelectorLabelStyle { } } -extension LabelStyle where Self == TrailingIconLabelStyle { - - static var trailingIcon: TrailingIconLabelStyle { - TrailingIconLabelStyle() - } -} - struct EpisodeSelectorLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { @@ -42,14 +35,3 @@ struct EpisodeSelectorLabelStyle: LabelStyle { .font(.caption) } } - -struct TrailingIconLabelStyle: LabelStyle { - - func makeBody(configuration: Configuration) -> some View { - HStack { - configuration.title - - configuration.icon - } - } -} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift new file mode 100644 index 00000000..57e651f9 --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift @@ -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 Defaults +import SwiftUI + +struct NavigationBarCloseButtonModifier: ViewModifier { + + @Default(.accentColor) + private var accentColor + + let disabled: Bool + let action: () -> Void + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Button { + action() + } label: { + Image(systemName: "xmark.circle.fill") + .backport + .fontWeight(.bold) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + .opacity(disabled ? 0.75 : 1) + } + .disabled(disabled) + } + } + } +} diff --git a/Swiftfin/Extensions/View/View-iOS.swift b/Swiftfin/Extensions/View/View-iOS.swift index c5f78aa0..afb88203 100644 --- a/Swiftfin/Extensions/View/View-iOS.swift +++ b/Swiftfin/Extensions/View/View-iOS.swift @@ -43,27 +43,27 @@ extension View { } func onAppDidEnterBackground(_ action: @escaping () -> Void) -> some View { - onNotification(UIApplication.didEnterBackgroundNotification, perform: action) + onNotification(UIApplication.didEnterBackgroundNotification, perform: { _ in action() }) } func onAppWillResignActive(_ action: @escaping () -> Void) -> some View { - onNotification(UIApplication.willResignActiveNotification, perform: action) + onNotification(UIApplication.willResignActiveNotification, perform: { _ in action() }) } func onAppWillTerminate(_ action: @escaping () -> Void) -> some View { - onNotification(UIApplication.willTerminateNotification, perform: action) + onNotification(UIApplication.willTerminateNotification, perform: { _ in action() }) } - func navigationBarCloseButton(_ action: @escaping () -> Void) -> some View { - toolbar { - ToolbarItemGroup(placement: .topBarLeading) { - Button { - action() - } label: { - Image(systemName: "xmark.circle.fill") - .paletteOverlayRendering() - } - } - } + @ViewBuilder + func navigationBarCloseButton( + disabled: Bool = false, + _ action: @escaping () -> Void + ) -> some View { + modifier( + NavigationBarCloseButtonModifier( + disabled: disabled, + action: action + ) + ) } } diff --git a/Swiftfin/Resources/Info.plist b/Swiftfin/Resources/Info.plist index c3c1c17e..54c52e68 100644 --- a/Swiftfin/Resources/Info.plist +++ b/Swiftfin/Resources/Info.plist @@ -29,8 +29,6 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) - CFBundledisplayTitle - Swiftfin ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -51,6 +49,8 @@ _googlecast._tcp _F007D354._googlecast._tcp + NSFaceIDUsageDescription + Use FaceID to lock and access local users. NSLocalNetworkUsageDescription ${PRODUCT_NAME} uses the local network to connect to your Jellyfin server & discover Cast-enabled devices on your WiFi network. diff --git a/Swiftfin/Views/AboutAppView.swift b/Swiftfin/Views/AboutAppView.swift index fe7f387e..85d9010a 100644 --- a/Swiftfin/Views/AboutAppView.swift +++ b/Swiftfin/Views/AboutAppView.swift @@ -10,22 +10,18 @@ import SwiftUI struct AboutAppView: View { - @EnvironmentObject - private var router: SettingsCoordinator.Router - @ObservedObject var viewModel: SettingsViewModel var body: some View { List { Section { - VStack(alignment: .center) { + VStack(alignment: .center, spacing: 10) { - Image(uiImage: viewModel.currentAppIcon.iconPreview) + Image(.jellyfinBlobBlue) .resizable() - .frame(width: 150, height: 150) - .cornerRadius(150 / 6.4) - .shadow(radius: 5) + .aspectRatio(1, contentMode: .fit) + .frame(height: 150) // App name, not to be localized Text("Swiftfin") @@ -47,7 +43,8 @@ struct AboutAppView: View { .leadingView { Image(.logoGithub) .resizable() - .frame(width: 20, height: 20) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) .foregroundColor(.primary) } .onSelect { @@ -58,7 +55,10 @@ struct AboutAppView: View { .leadingView { Image(systemName: "plus.circle.fill") .resizable() - .frame(width: 20, height: 20) + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) .foregroundColor(.primary) } .onSelect { @@ -69,7 +69,8 @@ struct AboutAppView: View { .leadingView { Image(systemName: "gearshape.fill") .resizable() - .frame(width: 20, height: 20) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) .foregroundColor(.primary) } .onSelect { diff --git a/Swiftfin/Views/AppIconSelectorView.swift b/Swiftfin/Views/AppIconSelectorView.swift index 99972a9f..6b77918f 100644 --- a/Swiftfin/Views/AppIconSelectorView.swift +++ b/Swiftfin/Views/AppIconSelectorView.swift @@ -84,9 +84,12 @@ extension AppIconSelectorView { if icon.iconName == viewModel.currentAppIcon.iconName { Image(systemName: "checkmark.circle.fill") .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .paletteOverlayRendering() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) } } } diff --git a/Swiftfin/Views/AppLoadingView.swift b/Swiftfin/Views/AppLoadingView.swift new file mode 100644 index 00000000..7103bea5 --- /dev/null +++ b/Swiftfin/Views/AppLoadingView.swift @@ -0,0 +1,39 @@ +// +// 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 + +/// The loading view for the app when migrations are taking place +struct AppLoadingView: View { + + @State + private var didFailMigration = false + + var body: some View { + ZStack { + Color.clear + + if !didFailMigration { + DelayedProgressView() + } + + if didFailMigration { + ErrorView(error: JellyfinAPIError("An internal error occurred.")) + } + } + .topBarTrailing { + Button("Advanced", systemImage: "gearshape.fill") {} + .foregroundStyle(.secondary) + .disabled(true) + .opacity(didFailMigration ? 0 : 1) + } + .onNotification(.didFailMigration) { _ in + didFailMigration = true + } + } +} diff --git a/Swiftfin/Views/AppSettingsView/AppSettingsView.swift b/Swiftfin/Views/AppSettingsView/AppSettingsView.swift new file mode 100644 index 00000000..e0f98da7 --- /dev/null +++ b/Swiftfin/Views/AppSettingsView/AppSettingsView.swift @@ -0,0 +1,100 @@ +// +// 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 Stinsen +import SwiftUI + +// TODO: move sign out-stuff into super user when implemented + +struct AppSettingsView: View { + + @Default(.accentColor) + private var accentColor + + @Default(.appAppearance) + private var appearance + + @Default(.selectUserUseSplashscreen) + private var selectUserUseSplashscreen + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + + @Default(.signOutOnClose) + private var signOutOnClose + + @EnvironmentObject + private var router: AppSettingsCoordinator.Router + + @StateObject + private var viewModel = SettingsViewModel() + + var body: some View { + Form { + + ChevronButton(title: L10n.about) + .onSelect { + router.route(to: \.about, viewModel) + } + + Section(L10n.accessibility) { + + ChevronButton(title: L10n.appIcon) + .onSelect { + router.route(to: \.appIconSelector, viewModel) + } + + if !selectUserUseSplashscreen { + CaseIterablePicker( + title: L10n.appearance, + selection: $appearance + ) + } + } + + Section { + + Toggle("Use splashscreen", isOn: $selectUserUseSplashscreen) + + if selectUserUseSplashscreen { + Picker("Servers", selection: $selectUserAllServersSplashscreen) { + + Section { + Label("Random", systemImage: "dice.fill") + .tag(SelectUserServerSelection.all) + } + + ForEach(viewModel.servers) { server in + Text(server.name) + .tag(SelectUserServerSelection.server(id: server.id)) + } + } + } + } header: { + Text("Splashscreen") + } footer: { + if selectUserUseSplashscreen { + Text("When All Servers is selected, use the splashscreen from a single server or a random server") + } + } + + SignOutIntervalSection() + + ChevronButton(title: L10n.logs) + .onSelect { + router.route(to: \.log) + } + } + .animation(.linear, value: selectUserUseSplashscreen) + .navigationTitle(L10n.advanced) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + } +} diff --git a/Swiftfin/Views/AppSettingsView/Components/SignOutIntervalSection.swift b/Swiftfin/Views/AppSettingsView/Components/SignOutIntervalSection.swift new file mode 100644 index 00000000..0350a097 --- /dev/null +++ b/Swiftfin/Views/AppSettingsView/Components/SignOutIntervalSection.swift @@ -0,0 +1,71 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension AppSettingsView { + + struct SignOutIntervalSection: View { + + @Default(.backgroundSignOutInterval) + private var backgroundSignOutInterval + @Default(.signOutOnBackground) + private var signOutOnBackground + @Default(.signOutOnClose) + private var signOutOnClose + + @State + private var isEditingBackgroundSignOutInterval: Bool = false + + var body: some View { + Section { + Toggle("Sign out on close", isOn: $signOutOnClose) + } footer: { + Text("Signs out the last user when Swiftfin has been force closed") + } + + Section { + Toggle("Sign out on background", isOn: $signOutOnBackground) + + if signOutOnBackground { + HStack { + Text("Duration") + + Spacer() + + Button { + isEditingBackgroundSignOutInterval.toggle() + } label: { + HStack { + Text(backgroundSignOutInterval, format: .hourMinute) + .foregroundStyle(.secondary) + + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(isEditingBackgroundSignOutInterval ? .degrees(90) : .zero) + .animation(.linear(duration: 0.075), value: isEditingBackgroundSignOutInterval) + } + } + .foregroundStyle(.primary, .secondary) + } + + if isEditingBackgroundSignOutInterval { + HourMinutePicker(interval: $backgroundSignOutInterval) + } + } + } footer: { + Text( + "Signs out the last user when Swiftfin has been in the background without media playback after some time" + ) + } + .animation(.linear(duration: 0.15), value: isEditingBackgroundSignOutInterval) + } + } +} diff --git a/Swiftfin/Views/BasicAppSettingsView.swift b/Swiftfin/Views/BasicAppSettingsView.swift deleted file mode 100644 index 125f8a6a..00000000 --- a/Swiftfin/Views/BasicAppSettingsView.swift +++ /dev/null @@ -1,95 +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 Stinsen -import SwiftUI - -struct BasicAppSettingsView: View { - - @Default(.accentColor) - private var accentColor - @Default(.appAppearance) - private var appAppearance - - @EnvironmentObject - private var router: BasicAppSettingsCoordinator.Router - - @ObservedObject - var viewModel: SettingsViewModel - - @State - private var resetUserSettingsSelected: Bool = false - @State - private var resetAppSettingsSelected: Bool = false - @State - private var removeAllServersSelected: Bool = false - - var body: some View { - Form { - - ChevronButton(title: L10n.about) - .onSelect { - router.route(to: \.about) - } - - Section { - CaseIterablePicker(title: L10n.appearance, selection: $appAppearance) - - ChevronButton(title: L10n.appIcon) - .onSelect { - router.route(to: \.appIconSelector) - } - } header: { - L10n.accessibility.text - } - - Section { - ColorPicker(L10n.accentColor, selection: $accentColor, supportsOpacity: false) - } footer: { - L10n.accentColorDescription.text - } - - ChevronButton(title: L10n.logs) - .onSelect { - router.route(to: \.log) - } - - Section { - Button { - resetUserSettingsSelected = true - } label: { - L10n.resetUserSettings.text - } - - Button { - removeAllServersSelected = true - } label: { - Text(L10n.removeAllServers) - } - } - } - .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsSelected) { - Button(L10n.reset, role: .destructive) { - viewModel.resetUserSettings() - } - } message: { - Text(L10n.resetAllSettings) - } - .alert(L10n.removeAllServers, isPresented: $removeAllServersSelected) { - Button(L10n.reset, role: .destructive) { - viewModel.removeAllServers() - } - } - .navigationTitle(L10n.settings) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - } -} diff --git a/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift b/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift index 78851ce6..8e33c7c8 100644 --- a/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift +++ b/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift @@ -35,9 +35,8 @@ extension ChannelLibraryView { $0.aspectRatio(contentMode: .fit) } .failure { - SystemImageContentView(systemName: channel.systemImage) + SystemImageContentView(systemName: channel.systemImage, ratio: 0.5) .background(color: .clear) - .imageFrameRatio(width: 2, height: 2) } .placeholder { _ in EmptyView() diff --git a/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift b/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift index 411ef29e..85851440 100644 --- a/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift +++ b/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift @@ -44,9 +44,8 @@ extension ChannelLibraryView { $0.aspectRatio(contentMode: .fit) } .failure { - SystemImageContentView(systemName: channel.systemImage) + SystemImageContentView(systemName: channel.systemImage, ratio: 0.5) .background(color: .clear) - .imageFrameRatio(width: 2, height: 2) } .placeholder { _ in EmptyView() diff --git a/Swiftfin/Views/ConnectToServerView.swift b/Swiftfin/Views/ConnectToServerView.swift index 15e062cd..b917c74f 100644 --- a/Swiftfin/Views/ConnectToServerView.swift +++ b/Swiftfin/Views/ConnectToServerView.swift @@ -6,182 +6,176 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Combine import Defaults -import Stinsen import SwiftUI struct ConnectToServerView: View { + @Default(.accentColor) + private var accentColor + @EnvironmentObject - private var router: ConnectToServerCoodinator.Router + private var router: SelectUserCoordinator.Router - @ObservedObject - var viewModel: ConnectToServerViewModel + @FocusState + private var isURLFocused: Bool @State - private var connectionError: Error? + private var duplicateServer: ServerState? = nil @State - private var connectionTask: Task? + private var error: Error? = nil @State - private var duplicateServer: (server: ServerState, url: URL)? - @State - private var isConnecting: Bool = false - @State - private var isPresentingConnectionError: Bool = false - @State - private var isPresentingDuplicateServerAlert: Bool = false + private var isPresentingDuplicateServer: Bool = false @State private var isPresentingError: Bool = false @State - private var url = "" + private var url: String = "" - private func connectToServer() { - let task = Task { - isConnecting = true - connectionError = nil + @StateObject + private var viewModel = ConnectToServerViewModel() - do { - let serverConnection = try await viewModel.connectToServer(url: url) - - if viewModel.isDuplicate(server: serverConnection.server) { - duplicateServer = serverConnection - isPresentingDuplicateServerAlert = true - } else { - try viewModel.save(server: serverConnection.server) - router.route(to: \.userSignIn, serverConnection.server) - } - } catch { - connectionError = error - isPresentingConnectionError = true - } - - isConnecting = false - } - - connectionTask = task - } + private let timer = Timer.publish(every: 12, on: .main, in: .common).autoconnect() @ViewBuilder private var connectSection: some View { - Section { + Section(L10n.connectToServer) { TextField(L10n.serverURL, text: $url) .disableAutocorrection(true) - .autocapitalization(.none) + .textInputAutocapitalization(.never) .keyboardType(.URL) + .focused($isURLFocused) + } - if isConnecting { - Button(role: .destructive) { - connectionTask?.cancel() - isConnecting = false - } label: { - L10n.cancel.text - } - } else { - Button { - if !url.contains("://") { - url = "http://" + url - } - connectToServer() - } label: { - L10n.connect.text - } - .disabled(URL(string: url) == nil || isConnecting) + if viewModel.state == .connecting { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) } - } header: { - L10n.connectToJellyfinServer.text + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.connect) { + isURLFocused = false + viewModel.send(.connect(url)) + } + .disabled(url.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .opacity(url.isEmpty ? 0.5 : 1) } } - @ViewBuilder - private var publicServerSection: some View { - Section { - if viewModel.isSearching { - L10n.searchingDots.text + private func localServerButton(for server: ServerState) -> some View { + Button { + url = server.currentURL.absoluteString + viewModel.send(.connect(server.currentURL.absoluteString)) + } label: { + HStack { + VStack(alignment: .leading) { + Text(server.name) + .font(.headline) + .fontWeight(.semibold) + + Text(server.currentURL.absoluteString) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .disabled(viewModel.state == .connecting) + .buttonStyle(.plain) + } + + private var localServersSection: some View { + Section(L10n.localServers) { + if viewModel.localServers.isEmpty { + L10n.noLocalServersFound.text + .font(.callout) .foregroundColor(.secondary) .frame(maxWidth: .infinity) } else { - if viewModel.discoveredServers.isEmpty { - L10n.noLocalServersFound.text - .font(.callout) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - } else { - ForEach(viewModel.discoveredServers, id: \.id) { server in - Button { - url = server.currentURL.absoluteString - connectToServer() - } label: { - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title3) - - Text(server.currentURL.absoluteString) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .disabled(isConnecting) - } + ForEach(viewModel.localServers) { server in + localServerButton(for: server) } } - } header: { - HStack { - L10n.localServers.text - Spacer() - - Button { - viewModel.discoverServers() - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - } - .disabled(viewModel.isSearching || isConnecting) - } } - .headerProminence(.increased) } var body: some View { List { - connectSection - publicServerSection - } - .alert( - L10n.error, - isPresented: $isPresentingConnectionError - ) { - Button(L10n.dismiss, role: .cancel) - } message: { - Text(connectionError?.localizedDescription ?? .emptyDash) - } - .alert( - L10n.existingServer, - isPresented: $isPresentingDuplicateServerAlert - ) { - Button { - guard let duplicateServer else { return } - viewModel.add( - url: duplicateServer.url, - server: duplicateServer.server - ) - router.dismissCoordinator() - } label: { - L10n.addURL.text - } - - Button(L10n.dismiss, role: .cancel) - } message: { - if let duplicateServer { - L10n.serverAlreadyExistsPrompt(duplicateServer.server.name).text - } + localServersSection } + .interactiveDismissDisabled(viewModel.state == .connecting) .navigationTitle(L10n.connect) - .onAppear { - viewModel.discoverServers() + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton(disabled: viewModel.state == .connecting) { + router.popLast() } - .onDisappear { - isConnecting = false + .onFirstAppear { + isURLFocused = true + viewModel.send(.searchForServers) + } + .onReceive(viewModel.events) { event in + switch event { + case let .connected(server): + UIDevice.feedback(.success) + + Notifications[.didConnectToServer].post(object: server) + router.popLast() + case let .duplicateServer(server): + UIDevice.feedback(.warning) + + duplicateServer = server + isPresentingDuplicateServer = true + case let .error(eventError): + UIDevice.feedback(.error) + + error = eventError + isPresentingError = true + isURLFocused = true + } + } + .onReceive(timer) { _ in + guard viewModel.state != .connecting else { return } + + viewModel.send(.searchForServers) + } + .topBarTrailing { + if viewModel.state == .connecting { + ProgressView() + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .destructive) + } message: { error in + Text(error.localizedDescription) + } + .alert( + L10n.server.text, + isPresented: $isPresentingDuplicateServer, + presenting: duplicateServer + ) { server in + Button(L10n.dismiss, role: .destructive) + + Button(L10n.addURL) { + viewModel.send(.addNewURL(server)) + router.popLast() + } + } message: { server in + Text("\(server.name) is already connected.") } } } diff --git a/Swiftfin/Views/FilterView.swift b/Swiftfin/Views/FilterView.swift index c4e002ee..f8a93700 100644 --- a/Swiftfin/Views/FilterView.swift +++ b/Swiftfin/Views/FilterView.swift @@ -38,7 +38,7 @@ struct FilterView: View { router.dismissCoordinator() } .topBarTrailing { - Button { + Button(L10n.reset) { switch type { case .genres: viewModel.currentFilters.genres = ItemFilterCollection.default.genres @@ -55,8 +55,6 @@ struct FilterView: View { case .years: viewModel.currentFilters.years = ItemFilterCollection.default.years } - } label: { - L10n.reset.text } .environment( \.isEnabled, diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index d45e2ed2..a1743d14 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -7,6 +7,7 @@ // import Defaults +import Factory import Foundation import SwiftUI @@ -20,6 +21,8 @@ struct HomeView: View { @Default(.Customization.recentlyAddedPosterType) private var recentlyAddedPosterType + @EnvironmentObject + private var mainRouter: MainCoordinator.Router @EnvironmentObject private var router: HomeCoordinator.Router @@ -55,7 +58,7 @@ struct HomeView: View { } var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: contentView @@ -65,7 +68,7 @@ struct HomeView: View { DelayedProgressView() } } - .transition(.opacity.animation(.linear(duration: 0.2))) + .animation(.linear(duration: 0.1), value: viewModel.state) .onFirstAppear { viewModel.send(.refresh) } @@ -76,11 +79,11 @@ struct HomeView: View { ProgressView() } - Button { - router.route(to: \.settings) - } label: { - Image(systemName: "gearshape.fill") - .accessibilityLabel(L10n.settings) + SettingsBarButton( + server: viewModel.userSession.server, + user: viewModel.userSession.user + ) { + mainRouter.route(to: \.settings) } } .sinceLastDisappear { interval in diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift index 2094ab56..d6a60f5c 100644 --- a/Swiftfin/Views/ItemOverviewView.swift +++ b/Swiftfin/Views/ItemOverviewView.swift @@ -9,9 +9,6 @@ import JellyfinAPI import SwiftUI -// TODO: fix with shorter text -// - seems to center align - struct ItemOverviewView: View { @EnvironmentObject @@ -33,8 +30,10 @@ struct ItemOverviewView: View { if let itemOverview = item.overview { Text(itemOverview) .font(.body) + .multilineTextAlignment(.leading) } } + .frame(maxWidth: .infinity, alignment: .leading) .edgePadding() } .navigationTitle(item.displayTitle) diff --git a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift index aa6a1647..250b0134 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift @@ -10,6 +10,8 @@ import Defaults import JellyfinAPI import SwiftUI +// TODO: rename `AboutItemView` + extension ItemView { struct AboutView: View { @@ -17,9 +19,6 @@ extension ItemView { @Default(.accentColor) private var accentColor - @EnvironmentObject - private var router: ItemCoordinator.Router - @ObservedObject var viewModel: ItemViewModel diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift index 9a17b55d..3891700a 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift @@ -13,9 +13,6 @@ extension ItemView.AboutView { struct RatingsCard: View { - @EnvironmentObject - private var router: ItemCoordinator.Router - let item: BaseItemDto var body: some View { diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift index 7512a721..3b062859 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -35,7 +35,8 @@ extension SeriesEpisodeSelector { Image(systemName: "checkmark.circle.fill") .resizable() .frame(width: 30, height: 30, alignment: .bottomTrailing) - .paletteOverlayRendering(color: .white) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black) .padding() } } diff --git a/Swiftfin/Views/ItemView/Components/GenresHStack.swift b/Swiftfin/Views/ItemView/Components/GenresHStack.swift index 1e372005..eda8c51b 100644 --- a/Swiftfin/Views/ItemView/Components/GenresHStack.swift +++ b/Swiftfin/Views/ItemView/Components/GenresHStack.swift @@ -23,7 +23,11 @@ extension ItemView { title: L10n.genres, items: genres ).onSelect { genre in - let viewModel = ItemLibraryViewModel(title: genre.displayTitle, filters: .init(genres: [genre])) + let viewModel = ItemLibraryViewModel( + title: genre.displayTitle, + id: genre.value, + filters: .init(genres: [genre]) + ) router.route(to: \.library, viewModel) } } diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index bf2ea59e..a9141ccc 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -6,10 +6,8 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Introspect import JellyfinAPI import SwiftUI -import WidgetKit // TODO: try to make views simpler so there isn't one per media type, but per view type // - basic (episodes, collection) vs more fancy (rest) diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift index 0ad3d1e9..e7b9d8cb 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift @@ -92,6 +92,7 @@ extension CollectionItemView { .onSelect { let viewModel = ItemLibraryViewModel( title: viewModel.item.displayTitle, + id: viewModel.item.id, viewModel.collectionItems ) router.route(to: \.library, viewModel) diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift index 95306aea..aec4ab53 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift @@ -30,14 +30,6 @@ extension ItemView { let content: () -> Content - private var topOpacity: CGFloat { - let start = UIScreen.main.bounds.height * 0.5 - let end = UIScreen.main.bounds.height * 0.65 - let diff = end - start - let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) - return opacity - } - @ViewBuilder private var headerView: some View { ImageView(viewModel.item.imageSource( diff --git a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift index 51af5e23..a935ff4c 100644 --- a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift @@ -13,9 +13,6 @@ extension iPadOSMovieItemView { struct ContentView: View { - @EnvironmentObject - private var router: ItemCoordinator.Router - @ObservedObject var viewModel: MovieItemViewModel diff --git a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift index ec203e74..bceb066f 100644 --- a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift @@ -77,9 +77,6 @@ extension ItemView.iPadOSCinematicScrollView { struct OverlayView: View { - @EnvironmentObject - private var router: ItemCoordinator.Router - @ObservedObject var viewModel: ItemViewModel diff --git a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift index 68fcf672..55113b1b 100644 --- a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift @@ -13,9 +13,6 @@ extension iPadOSSeriesItemView { struct ContentView: View { - @EnvironmentObject - private var router: ItemCoordinator.Router - @ObservedObject var viewModel: SeriesItemViewModel diff --git a/Swiftfin/Views/MediaView/MediaView.swift b/Swiftfin/Views/MediaView/MediaView.swift index 448d32c2..40ff4d41 100644 --- a/Swiftfin/Views/MediaView/MediaView.swift +++ b/Swiftfin/Views/MediaView/MediaView.swift @@ -50,8 +50,10 @@ struct MediaView: View { case .downloads: router.route(to: \.downloads) case .favorites: + // TODO: favorites should have its own view instead of a library let viewModel = ItemLibraryViewModel( title: L10n.favorites, + id: "favorites", filters: .favorites ) router.route(to: \.library, viewModel) @@ -70,7 +72,7 @@ struct MediaView: View { } var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: contentView @@ -80,11 +82,11 @@ struct MediaView: View { DelayedProgressView() } } - .transition(.opacity.animation(.linear(duration: 0.2))) + .animation(.linear(duration: 0.1), value: viewModel.state) .ignoresSafeArea() .navigationTitle(L10n.allMedia) .topBarTrailing { - if viewModel.isLoading { + if viewModel.state == .refreshing { ProgressView() } } diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift index eb48970d..998c3f96 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift @@ -9,6 +9,10 @@ import Defaults import SwiftUI +// TODO: rename `LibraryDisplayTypeToggle`/Section +// - change to 2 Menu's in a section with subtitle +// like on `SelectUserView`? + extension PagingLibraryView { struct LibraryViewTypeToggle: View { diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index ba1b46eb..4774d8d6 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -14,25 +14,41 @@ import SwiftUI // TODO: need to think about better design for views that may not support current library display type // - ex: channels/albums when in portrait/landscape // - just have the supported view embedded in a container view? +// TODO: could bottom (defaults + stored) `onChange` copies be cleaned up? +// - more could be cleaned up if there was a "switcher" property wrapper that takes two +// sources and a switch and holds the current expected value +// - or if Defaults values were moved to StoredValues and each key would return/respond to +// what values they should have +// TODO: when there are no filters sometimes navigation bar will be clear until popped back to -// Note: Currently, it is a conscious decision to not have grid posters have subtitle content. -// This is due to episodes, which have their `S_E_` subtitles, and these can be alongside -// other items that don't have a subtitle which requires the entire library to implement -// subtitle content but that doesn't look appealing. Until a solution arrives grid posters -// will not have subtitle content. -// There should be a solution since there are contexts where subtitles are desirable and/or -// we can have subtitle content for other items. +/* + Note: Currently, it is a conscious decision to not have grid posters have subtitle content. + This is due to episodes, which have their `S_E_` subtitles, and these can be alongside + other items that don't have a subtitle which requires the entire library to implement + subtitle content but that doesn't look appealing. Until a solution arrives grid posters + will not have subtitle content. + There should be a solution since there are contexts where subtitles are desirable and/or + we can have subtitle content for other items. + + Note: For `rememberLayout` and `rememberSort`, there are quirks for observing changes while a + library is open and the setting has been changed. For simplicity, do not enforce observing + changes and doing proper updates since there is complexitry with what "actual" settings + should be applied. + */ struct PagingLibraryView: View { @Default(.Customization.Library.enabledDrawerFilters) private var enabledDrawerFilters - @Default(.Customization.Library.listColumnCount) - private var listColumnCount - @Default(.Customization.Library.posterType) - private var posterType - @Default(.Customization.Library.viewType) - private var viewType + @Default(.Customization.Library.rememberLayout) + private var rememberLayout + + @Default + private var defaultDisplayType: LibraryDisplayType + @Default + private var defaultListColumnCount: Int + @Default + private var defaultPosterType: PosterDisplayType @EnvironmentObject private var router: LibraryCoordinator.Router @@ -40,6 +56,13 @@ struct PagingLibraryView: View { @State private var layout: CollectionVGridLayout + @StoredValue + private var displayType: LibraryDisplayType + @StoredValue + private var listColumnCount: Int + @StoredValue + private var posterType: PosterDisplayType + @StateObject private var collectionVGridProxy: CollectionVGridProxy = .init() @StateObject @@ -48,21 +71,34 @@ struct PagingLibraryView: View { // MARK: init init(viewModel: PagingLibraryViewModel) { + + // have to set these properties manually to get proper initial layout + + self._defaultDisplayType = Default(.Customization.Library.displayType) + self._defaultListColumnCount = Default(.Customization.Library.listColumnCount) + self._defaultPosterType = Default(.Customization.Library.posterType) + + self._displayType = StoredValue(.User.libraryDisplayType(parentID: viewModel.parent?.id)) + self._listColumnCount = StoredValue(.User.libraryListColumnCount(parentID: viewModel.parent?.id)) + self._posterType = StoredValue(.User.libraryPosterType(parentID: viewModel.parent?.id)) + self._viewModel = StateObject(wrappedValue: viewModel) - let initialPosterType = Defaults[.Customization.Library.posterType] - let initialViewType = Defaults[.Customization.Library.viewType] - let initialListColumnCount = Defaults[.Customization.Library.listColumnCount] + let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType + .wrappedValue + let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? _listColumnCount + .wrappedValue : _defaultListColumnCount.wrappedValue + let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue if UIDevice.isPhone { layout = Self.phoneLayout( posterType: initialPosterType, - viewType: initialViewType + viewType: initialDisplayType ) } else { layout = Self.padLayout( posterType: initialPosterType, - viewType: initialViewType, + viewType: initialDisplayType, listColumnCount: initialListColumnCount ) } @@ -101,6 +137,8 @@ struct PagingLibraryView: View { // MARK: layout + // TODO: rename old "viewType" paramter to "displayType" and sort + private static func padLayout( posterType: PosterDisplayType, viewType: LibraryDisplayType, @@ -173,13 +211,14 @@ struct PagingLibraryView: View { } } - private func listItemView(item: Element) -> some View { + private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { LibraryRow(item: item, posterType: posterType) .onSelect { onSelect(item) } } + @ViewBuilder private func errorView(with error: some Error) -> some View { ErrorView(error: error) .onRetry { @@ -192,13 +231,18 @@ struct PagingLibraryView: View { $viewModel.elements, layout: $layout ) { item in - switch (posterType, viewType) { + + let displayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType + .wrappedValue + let posterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue + + switch (posterType, displayType) { case (.landscape, .grid): landscapeGridItemView(item: item) case (.portrait, .grid): portraitGridItemView(item: item) case (_, .list): - listItemView(item: item) + listItemView(item: item, posterType: posterType) } } .onReachedBottomEdge(offset: .offset(300)) { @@ -209,8 +253,10 @@ struct PagingLibraryView: View { // MARK: body + // TODO: becoming too large for typechecker during development, should break up somehow + var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: if viewModel.elements.isEmpty { @@ -224,7 +270,7 @@ struct PagingLibraryView: View { DelayedProgressView() } } - .transition(.opacity.animation(.linear(duration: 0.2))) + .animation(.linear(duration: 0.1), value: viewModel.state) .ignoresSafeArea() .navigationTitle(viewModel.parent?.displayTitle ?? "") .navigationBarTitleDisplayMode(.inline) @@ -236,29 +282,58 @@ struct PagingLibraryView: View { router.route(to: \.filter, $0) } } - .onChange(of: posterType) { newValue in + .onChange(of: defaultDisplayType) { newValue in + guard !Defaults[.Customization.Library.rememberLayout] else { return } + if UIDevice.isPhone { - if viewType == .list { + layout = Self.phoneLayout( + posterType: defaultPosterType, + viewType: newValue + ) + } else { + layout = Self.padLayout( + posterType: defaultPosterType, + viewType: newValue, + listColumnCount: defaultListColumnCount + ) + } + } + .onChange(of: defaultListColumnCount) { newValue in + guard !Defaults[.Customization.Library.rememberLayout] else { return } + + if UIDevice.isPad { + layout = Self.padLayout( + posterType: defaultPosterType, + viewType: defaultDisplayType, + listColumnCount: newValue + ) + } + } + .onChange(of: defaultPosterType) { newValue in + guard !Defaults[.Customization.Library.rememberLayout] else { return } + + if UIDevice.isPhone { + if defaultDisplayType == .list { collectionVGridProxy.layout() } else { layout = Self.phoneLayout( posterType: newValue, - viewType: viewType + viewType: defaultDisplayType ) } } else { - if viewType == .list { + if defaultDisplayType == .list { collectionVGridProxy.layout() } else { layout = Self.padLayout( posterType: newValue, - viewType: viewType, - listColumnCount: listColumnCount + viewType: defaultDisplayType, + listColumnCount: defaultListColumnCount ) } } } - .onChange(of: viewType) { newValue in + .onChange(of: displayType) { newValue in if UIDevice.isPhone { layout = Self.phoneLayout( posterType: posterType, @@ -276,11 +351,62 @@ struct PagingLibraryView: View { if UIDevice.isPad { layout = Self.padLayout( posterType: posterType, - viewType: viewType, + viewType: displayType, listColumnCount: newValue ) } } + .onChange(of: posterType) { newValue in + if UIDevice.isPhone { + if displayType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.phoneLayout( + posterType: newValue, + viewType: displayType + ) + } + } else { + if displayType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.padLayout( + posterType: newValue, + viewType: displayType, + listColumnCount: listColumnCount + ) + } + } + } + .onChange(of: rememberLayout) { newValue in + let newDisplayType = newValue ? displayType : defaultDisplayType + let newListColumnCount = newValue ? listColumnCount : defaultListColumnCount + let newPosterType = newValue ? posterType : defaultPosterType + + if UIDevice.isPhone { + layout = Self.phoneLayout( + posterType: newPosterType, + viewType: newDisplayType + ) + } else { + layout = Self.padLayout( + posterType: newPosterType, + viewType: newDisplayType, + listColumnCount: newListColumnCount + ) + } + } + .onChange(of: viewModel.filterViewModel?.currentFilters) { newValue in + guard let newValue, let id = viewModel.parent?.id else { return } + + if Defaults[.Customization.Library.rememberSort] { + let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)] + .mutating(\.sortBy, with: newValue.sortBy) + .mutating(\.sortOrder, with: newValue.sortOrder) + + StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters + } + } .onReceive(viewModel.events) { event in switch event { case let .gotRandomItem(item): @@ -308,11 +434,19 @@ struct PagingLibraryView: View { Menu { - LibraryViewTypeToggle( - posterType: $posterType, - viewType: $viewType, - listColumnCount: $listColumnCount - ) + if Defaults[.Customization.Library.rememberLayout] { + LibraryViewTypeToggle( + posterType: $posterType, + viewType: $displayType, + listColumnCount: $listColumnCount + ) + } else { + LibraryViewTypeToggle( + posterType: $defaultPosterType, + viewType: $defaultDisplayType, + listColumnCount: $defaultListColumnCount + ) + } Button(L10n.random, systemImage: "dice.fill") { viewModel.send(.getRandomItem) diff --git a/Swiftfin/Views/QuickConnectView.swift b/Swiftfin/Views/QuickConnectView.swift index 5756090d..dd9bee30 100644 --- a/Swiftfin/Views/QuickConnectView.swift +++ b/Swiftfin/Views/QuickConnectView.swift @@ -6,57 +6,28 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import SwiftUI struct QuickConnectView: View { + @EnvironmentObject - private var router: QuickConnectCoordinator.Router + private var router: UserSignInCoordinator.Router @ObservedObject - var viewModel: QuickConnectViewModel + private var viewModel: QuickConnect - // Once the auth secret is fetched, run this and dismiss this view - var signIn: @MainActor (_: String) -> Void - - func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { - Text(quickConnectCode) - .tracking(10) - .font(.largeTitle) - .monospacedDigit() - .frame(maxWidth: .infinity) + init(quickConnect: QuickConnect) { + self.viewModel = quickConnect } - var quickConnectFailed: some View { - Label { - Text("Failed to retrieve quick connect code") - } icon: { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - } - } - - var quickConnectLoading: some View { - HStack { - Spacer() - ProgressView() - Spacer() - } - } - - @ViewBuilder - var quickConnectBody: some View { - switch viewModel.state { - case let .awaitingAuthentication(code): - quickConnectWaitingAuthentication(quickConnectCode: code) - case .initial, .fetchingSecret, .authenticated: - quickConnectLoading - case .error: - quickConnectFailed - } - } - - var body: some View { + private func pollingView(code: String) -> some View { VStack(alignment: .leading, spacing: 20) { + + // TODO: change strings so that numbers are removed + // and use `BulletedList` + // - also probably rephrase/change steps + L10n.quickConnectStep1.text L10n.quickConnectStep2.text @@ -64,26 +35,42 @@ struct QuickConnectView: View { L10n.quickConnectStep3.text .padding(.bottom) - quickConnectBody + Text(code) + .tracking(10) + .font(.largeTitle) + .monospacedDigit() + .frame(maxWidth: .infinity) Spacer() } - .padding(.horizontal) - .navigationTitle(L10n.quickConnect) - .onChange(of: viewModel.state) { newState in - if case let .authenticated(secret: secret) = newState { - signIn(secret) - router.dismissCoordinator() + .frame(maxWidth: .infinity) + .edgePadding() + } + + var body: some View { + WrappedView { + switch viewModel.state { + case .idle, .authenticated: + Color.clear + case .retrievingCode: + ProgressView() + case let .polling(code): + pollingView(code: code) + case let .error(error): + ErrorView(error: error) } } - .onAppear { - viewModel.send(.startQuickConnect) + .edgePadding() + .navigationTitle(L10n.quickConnect) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + viewModel.start() } .onDisappear { - viewModel.send(.cancelQuickConnect) + viewModel.stop() } .navigationBarCloseButton { - router.dismissCoordinator() + router.popLast() } } } diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index d5bd1c90..f18ddd5d 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.swift @@ -120,7 +120,11 @@ struct SearchView: View { .trailing { SeeAllButton() .onSelect { - let viewModel = PagingLibraryViewModel(title: title, viewModel[keyPath: keyPath]) + let viewModel = PagingLibraryViewModel( + title: title, + id: "search-\(keyPath.hashValue)", + viewModel[keyPath: keyPath] + ) router.route(to: \.library, viewModel) } } diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift b/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift new file mode 100644 index 00000000..fadb1b81 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift @@ -0,0 +1,110 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserButton: View { + + @Binding + private var serverSelection: SelectUserServerSelection + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEnabled) + private var isEnabled + + private let action: (ServerState) -> Void + private let servers: OrderedSet + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = servers.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + init( + serverSelection: Binding, + servers: OrderedSet, + action: @escaping (ServerState) -> Void + ) { + self._serverSelection = serverSelection + self.action = action + self.servers = servers + } + + private var content: some View { + VStack(alignment: .center) { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + + Text("Add User") + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + + if serverSelection == .all { + Text("Hidden") + .font(.footnote) + .hidden() + } + } + } + + var body: some View { + if serverSelection == .all { + Menu { + + Text("Select server") + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + content + } + .disabled(!isEnabled) + .foregroundStyle(.primary, .secondary) + } else { + Button { + if let selectedServer { + action(selectedServer) + } + } label: { + content + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift b/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift new file mode 100644 index 00000000..5a847283 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift @@ -0,0 +1,115 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserRow: View { + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEnabled) + private var isEnabled + + @Binding + private var serverSelection: SelectUserServerSelection + + private let action: (ServerState) -> Void + private let servers: OrderedSet + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = servers.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + init( + serverSelection: Binding, + servers: OrderedSet, + action: @escaping (ServerState) -> Void + ) { + self._serverSelection = serverSelection + self.action = action + self.servers = servers + } + + private var content: some View { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { + + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + .frame(width: 80) + .padding(.vertical, 8) + + HStack { + + Text("Add User") + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Spacer() + } + .frame(maxWidth: .infinity) + } + } + + var body: some View { + if serverSelection == .all { + Menu { + + Text("Select server") + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + content + } + .disabled(!isEnabled) + .foregroundStyle(.primary, .secondary) + } else { + Button { + if let selectedServer { + action(selectedServer) + } + } label: { + content + } + .disabled(!isEnabled) + .foregroundStyle(.primary, .secondary) + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift b/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift new file mode 100644 index 00000000..0c745794 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -0,0 +1,103 @@ +// +// 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 + +extension SelectUserView { + + struct ServerSelectionMenu: View { + + @Environment(\.colorScheme) + private var colorScheme + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @Binding + private var serverSelection: SelectUserServerSelection + + @ObservedObject + private var viewModel: SelectUserViewModel + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = viewModel.servers.keys.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + init( + selection: Binding, + viewModel: SelectUserViewModel + ) { + self._serverSelection = selection + self.viewModel = viewModel + } + + var body: some View { + Menu { + Section { + Button("Add Server", systemImage: "plus") { + router.route(to: \.connectToServer) + } + + if let selectedServer { + Button("Edit Server", systemImage: "server.rack") { + router.route(to: \.editServer, selectedServer) + } + } + } + + Picker("Servers", selection: _serverSelection) { + + if viewModel.servers.keys.count > 1 { + Label("All Servers", systemImage: "person.2.fill") + .tag(SelectUserServerSelection.all) + } + + ForEach(viewModel.servers.keys.reversed()) { server in + Button { + Text(server.name) + Text(server.currentURL.absoluteString) + } + .tag(SelectUserServerSelection.server(id: server.id)) + } + } + } label: { + ZStack { + + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + + Group { + switch serverSelection { + case .all: + Label("All Servers", systemImage: "person.2.fill") + case let .server(id): + if let server = viewModel.servers.keys.first(where: { $0.id == id }) { + Label(server.name, systemImage: "server.rack") + } + } + } + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + } + .frame(height: 50) + .frame(maxWidth: 400) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift new file mode 100644 index 00000000..c4c0e744 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift @@ -0,0 +1,133 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension SelectUserView { + + struct UserGridButton: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + private let user: UserState + private let server: ServerState + private let showServer: Bool + private let action: () -> Void + private let onDelete: () -> Void + + init( + user: UserState, + server: ServerState, + showServer: Bool, + action: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.server = server + self.showServer = showServer + self.action = action + self.onDelete = onDelete + } + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + + return isSelected ? .primary : .secondary + } + + private var personView: some View { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + + var body: some View { + Button { + action() + } label: { + VStack(alignment: .center) { + ZStack { + Color.clear + + ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) + .image { image in + image + .posterBorder(ratio: 1 / 2, of: \.width) + } + .placeholder { _ in + personView + } + .failure { + personView + } + } + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + .overlay { + if isEditing { + ZStack(alignment: .bottomTrailing) { + Color.black + .opacity(isSelected ? 0 : 0.5) + .clipShape(.circle) + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40, alignment: .bottomTrailing) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + } + } + + Text(user.username) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(labelForegroundStyle) + .lineLimit(1) + + if showServer { + Text(server.name) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + .contextMenu { + Button("Delete", role: .destructive) { + onDelete() + } + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/UserRow.swift b/Swiftfin/Views/SelectUserView/Components/UserRow.swift new file mode 100644 index 00000000..d13e3f69 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/UserRow.swift @@ -0,0 +1,170 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension SelectUserView { + + struct UserRow: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + @State + private var contentSize: CGSize = .zero + + private let user: UserState + private let server: ServerState + private let showServer: Bool + private let action: () -> Void + private let onDelete: () -> Void + + init( + user: UserState, + server: ServerState, + showServer: Bool, + action: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.server = server + self.showServer = showServer + self.action = action + self.onDelete = onDelete + } + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + + return isSelected ? .primary : .secondary + } + + private var personView: some View { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + + @ViewBuilder + private var userImage: some View { + ZStack { + Color.clear + + ImageView(user.profileImageSource(client: server.client, maxWidth: 120)) + .image { image in + image + .posterBorder(ratio: 1 / 2, of: \.width) + } + .placeholder { _ in + personView + } + .failure { + personView + } + + if isEditing { + Color.black + .opacity(isSelected ? 0 : 0.5) + } + } + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Button { + action() + } label: { + ZStack { + Color.clear + + HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { + + userImage + .frame(width: 80) + .padding(.vertical, 8) + + HStack { + + VStack(alignment: .leading, spacing: 5) { + Text(user.username) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(labelForegroundStyle) + .lineLimit(2) + .multilineTextAlignment(.leading) + + if showServer { + Text(server.name) + .font(.footnote) + .foregroundColor(Color(UIColor.lightGray)) + } + } + + Spacer() + + if isEditing, isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + + } else if isEditing { + Image(systemName: "circle") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .trackingSize($contentSize) + } + } + } + .contextMenu { + Button("Delete", role: .destructive) { + onDelete() + } + } + .foregroundStyle(.primary, .secondary) + + Color.secondarySystemFill + .frame(width: contentSize.width, height: 1) + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/SelectUserView.swift b/Swiftfin/Views/SelectUserView/SelectUserView.swift new file mode 100644 index 00000000..8d150ab5 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/SelectUserView.swift @@ -0,0 +1,649 @@ +// +// 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 CollectionVGrid +import Defaults +import Factory +import JellyfinAPI +import LocalAuthentication +import OrderedCollections +import SwiftUI + +// TODO: authentication view during device authentication +// - could use provided UI, but is iOS 16+ +// - could just ignore for iOS 15, or basic view +// TODO: user ordering +// - name +// - last signed in date +// TODO: for random splash screen, instead have a random sorted array +// for failure cases + +struct SelectUserView: View { + + private enum UserGridItem: Hashable { + case user(UserState, server: ServerState) + case addUser + } + + @Default(.selectUserUseSplashscreen) + private var selectUserUseSplashscreen + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + @Default(.selectUserServerSelection) + private var serverSelection + @Default(.selectUserDisplayType) + private var userListDisplayType + + @Environment(\.colorScheme) + private var colorScheme + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @State + private var contentSafeAreaInsets: EdgeInsets = .zero + @State + private var contentSize: CGSize = .zero + @State + private var error: Error? = nil + @State + private var gridItems: OrderedSet = [] + @State + private var gridItemSize: CGSize = .zero + @State + private var isEditingUsers: Bool = false + @State + private var isPresentingConfirmDeleteUsers = false + @State + private var isPresentingError: Bool = false + @State + private var isPresentingLocalPin: Bool = false + @State + private var padGridItemColumnCount: Int = 1 + @State + private var pin: String = "" + @State + private var selectedUsers: Set = [] + @State + private var splashScreenImageSource: ImageSource? = nil + + @StateObject + private var viewModel = SelectUserViewModel() + + private var selectedServer: ServerState? { + if case let SelectUserServerSelection.server(id: id) = serverSelection, + let server = viewModel.servers.keys.first(where: { server in server.id == id }) + { + return server + } + + return nil + } + + private func makeGridItems(for serverSelection: SelectUserServerSelection) -> OrderedSet { + switch serverSelection { + case .all: + let items = viewModel.servers + .map { server, users in + users.map { (server: server, user: $0) } + } + .flatMap { $0 } + .sorted(using: \.user.username) + .reversed() + .map { UserGridItem.user($0.user, server: $0.server) } + .appending(.addUser) + + return OrderedSet(items) + case let .server(id: id): + guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { + assertionFailure("server with ID not found?") + return [.addUser] + } + + let items = viewModel.servers[server]! + .sorted(using: \.username) + .map { UserGridItem.user($0, server: server) } + .appending(.addUser) + + return OrderedSet(items) + } + } + + // For all server selection, .all is random + private func makeSplashScreenImageSource( + serverSelection: SelectUserServerSelection, + allServersSelection: SelectUserServerSelection + ) -> ImageSource? { + switch (serverSelection, allServersSelection) { + case (.all, .all): + return viewModel + .servers + .keys + .randomElement()? + .splashScreenImageSource() + + // need to evaluate server with id selection first + case let (.server(id), _), let (.all, .server(id)): + return viewModel + .servers + .keys + .first { $0.id == id }? + .splashScreenImageSource() + } + } + + private func select(user: UserState, needsPin: Bool = true) { + Task { @MainActor in + selectedUsers.insert(user) + + switch user.signInPolicy { + case .requireDeviceAuthentication: + try await performDeviceAuthentication(reason: "User \(user.username) requires device authentication") + case .requirePin: + if needsPin { + isPresentingLocalPin = true + return + } + case .none: () + } + + viewModel.send(.signIn(user, pin: pin)) + } + } + + // error logging/presentation is handled within here, just + // use try+thrown error in local Task for early return + private func performDeviceAuthentication(reason: String) async throws { + let context = LAContext() + var policyError: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { + viewModel.logger.critical("\(policyError!.localizedDescription)") + + await MainActor.run { + self + .error = + JellyfinAPIError( + "Unable to perform device authentication. You may need to enable Face ID in the Settings app for Swiftfin." + ) + self.isPresentingError = true + } + + throw JellyfinAPIError("Device auth failed") + } + + do { + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + } catch { + viewModel.logger.critical("\(error.localizedDescription)") + + await MainActor.run { + self.error = JellyfinAPIError("Unable to perform device authentication") + self.isPresentingError = true + } + + throw JellyfinAPIError("Device auth failed") + } + } + + // MARK: advancedMenu + + @ViewBuilder + private var advancedMenu: some View { + Menu(L10n.advanced, systemImage: "gearshape.fill") { + + Section { + if gridItems.count > 1 { + Button("Edit Users", systemImage: "person.crop.circle") { + isEditingUsers.toggle() + } + } + } + + if !viewModel.servers.isEmpty { + Picker(selection: $userListDisplayType) { + ForEach(LibraryDisplayType.allCases, id: \.hashValue) { + Label($0.displayTitle, systemImage: $0.systemImage) + .tag($0) + } + } label: { + Text("Layout") + Text(userListDisplayType.displayTitle) + Image(systemName: userListDisplayType.systemImage) + } + .pickerStyle(.menu) + } + + Section { + Button(L10n.advanced, systemImage: "gearshape.fill") { + router.route(to: \.advancedSettings) + } + } + } + } + + // MARK: grid + + private func padGridItemOffset(index: Int) -> CGFloat { + let lastRowIndices = (gridItems.count - gridItems.count % padGridItemColumnCount ..< gridItems.count) + + guard lastRowIndices.contains(index) else { return 0 } + + let lastRowMissing = padGridItemColumnCount - gridItems.count % padGridItemColumnCount + return CGFloat(lastRowMissing) * (gridItemSize.width + EdgeInsets.edgePadding) / 2 + } + + @ViewBuilder + private var padGridContentView: some View { + let columns = [GridItem(.adaptive(minimum: 150, maximum: 300), spacing: EdgeInsets.edgePadding)] + + LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) { + ForEach(Array(gridItems.enumerated().map(\.offset)), id: \.hashValue) { index in + let item = gridItems[index] + + gridItemView(for: item) + .trackingSize($gridItemSize) + .offset(x: padGridItemOffset(index: index)) + } + } + .edgePadding() + .scroll(ifLargerThan: contentSize.height - 100) + .onChange(of: gridItemSize) { newValue in + let columns = Int(contentSize.width / (newValue.width + EdgeInsets.edgePadding)) + + padGridItemColumnCount = columns + } + } + + @ViewBuilder + private var phoneGridContentView: some View { + let columns = [GridItem(.flexible(), spacing: EdgeInsets.edgePadding), GridItem(.flexible())] + + LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) { + ForEach(gridItems, id: \.hashValue) { item in + gridItemView(for: item) + .if(gridItems.count % 2 == 1 && item == gridItems.last) { view in + view.trackingSize($gridItemSize) + .offset(x: (gridItemSize.width + EdgeInsets.edgePadding) / 2) + } + } + } + .edgePadding() + .scroll(ifLargerThan: contentSize.height - 100) + } + + @ViewBuilder + private func gridItemView(for item: UserGridItem) -> some View { + switch item { + case let .user(user, server): + UserGridButton( + user: user, + server: server, + showServer: serverSelection == .all + ) { + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + select(user: user) + } + } onDelete: { + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true + } + .environment(\.isEditing, isEditingUsers) + .environment(\.isSelected, selectedUsers.contains(user)) + case .addUser: + AddUserButton( + serverSelection: $serverSelection, + servers: viewModel.servers.keys + ) { server in + UIDevice.impact(.light) + router.route(to: \.userSignIn, server) + } + .environment(\.isEnabled, !isEditingUsers) + } + } + + // MARK: list + + @ViewBuilder + private var listContentView: some View { + ScrollView { + LazyVStack { + ForEach(gridItems, id: \.hashValue) { item in + listItemView(for: item) + } + } + .edgePadding() + } + } + + @ViewBuilder + private func listItemView(for item: UserGridItem) -> some View { + switch item { + case let .user(user, server): + UserRow( + user: user, + server: server, + showServer: serverSelection == .all + ) { + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + select(user: user) + } + } onDelete: { + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true + } + .environment(\.isEditing, isEditingUsers) + .environment(\.isSelected, selectedUsers.contains(user)) + case .addUser: + AddUserRow( + serverSelection: $serverSelection, + servers: viewModel.servers.keys + ) { server in + UIDevice.impact(.light) + router.route(to: \.userSignIn, server) + } + .environment(\.isEnabled, !isEditingUsers) + } + } + + private var deleteUsersButton: some View { + Button { + isPresentingConfirmDeleteUsers = true + } label: { + ZStack { + Color.red + + Text("Delete") + .font(.body.weight(.semibold)) + .foregroundStyle(selectedUsers.isNotEmpty ? .primary : .secondary) + + if selectedUsers.isEmpty { + Color.black + .opacity(0.5) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(height: 50) + .frame(maxWidth: 400) + } + .disabled(selectedUsers.isEmpty) + .buttonStyle(.plain) + } + + // MARK: userView + + @ViewBuilder + private var userView: some View { + VStack(spacing: 0) { + ZStack { + Color.clear + .onSizeChanged { size, safeAreaInsets in + contentSize = size + contentSafeAreaInsets = safeAreaInsets + } + + switch userListDisplayType { + case .grid: + if UIDevice.isPhone { + phoneGridContentView + } else { + padGridContentView + } + case .list: + listContentView + } + } + .frame(maxHeight: .infinity) + .mask { + VStack(spacing: 0) { + Color.white + + LinearGradient( + stops: [ + .init(color: .white, location: 0), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 30) + } + } + + if !isEditingUsers { + ServerSelectionMenu( + selection: $serverSelection, + viewModel: viewModel + ) + .edgePadding([.bottom, .horizontal]) + } + + if isEditingUsers { + deleteUsersButton + .edgePadding([.bottom, .horizontal]) + } + } + .background { + if selectUserUseSplashscreen, let splashScreenImageSource { + ZStack { + Color.clear + + ImageView(splashScreenImageSource) + .aspectRatio(contentMode: .fill) + .id(splashScreenImageSource) + .transition(.opacity) + .animation(.linear, value: splashScreenImageSource) + + Color.black + .opacity(0.9) + } + .ignoresSafeArea() + } + } + } + + // MARK: emptyView + + private var emptyView: some View { + VStack(spacing: 10) { + L10n.connectToJellyfinServerStart.text + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + PrimaryButton(title: L10n.connect) + .onSelect { + router.route(to: \.connectToServer) + } + .frame(maxWidth: 300) + } + } + + // MARK: body + + var body: some View { + WrappedView { + if viewModel.servers.isEmpty { + emptyView + } else { + userView + } + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .navigationTitle("Users") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Image(uiImage: .jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30) + } + } + .topBarTrailing { + if isEditingUsers { + Button { + isEditingUsers = false + } label: { + L10n.cancel.text + .font(.headline) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } else { + advancedMenu + } + } + .onAppear { + viewModel.send(.getServers) + + splashScreenImageSource = makeSplashScreenImageSource( + serverSelection: serverSelection, + allServersSelection: selectUserAllServersSplashscreen + ) + } + .onChange(of: isEditingUsers) { newValue in + guard !newValue else { return } + selectedUsers.removeAll() + } + .onChange(of: isPresentingConfirmDeleteUsers) { newValue in + guard !newValue else { return } + isEditingUsers = false + selectedUsers.removeAll() + } + .onChange(of: isPresentingLocalPin) { newValue in + if newValue { + pin = "" + } else { + selectedUsers.removeAll() + } + } + .onChange(of: selectUserAllServersSplashscreen) { newValue in + splashScreenImageSource = makeSplashScreenImageSource( + serverSelection: serverSelection, + allServersSelection: newValue + ) + } + .onChange(of: serverSelection) { newValue in + gridItems = makeGridItems(for: newValue) + + splashScreenImageSource = makeSplashScreenImageSource( + serverSelection: newValue, + allServersSelection: selectUserAllServersSplashscreen + ) + } + .onChange(of: viewModel.servers) { _ in + gridItems = makeGridItems(for: serverSelection) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + + self.error = eventError + self.isPresentingError = true + case let .signedIn(user): + UIDevice.feedback(.success) + + Defaults[.lastSignedInUserID] = user.id + UserSession.current.reset() + Notifications[.didSignIn].post() + } + } + .onNotification(.didConnectToServer) { notification in + if let server = notification.object as? ServerState { + viewModel.send(.getServers) + serverSelection = .server(id: server.id) + } + } + .onNotification(.didChangeCurrentServerURL) { notification in + if let server = notification.object as? ServerState { + viewModel.send(.getServers) + serverSelection = .server(id: server.id) + } + } + .onNotification(.didDeleteServer) { notification in + viewModel.send(.getServers) + + if let server = notification.object as? ServerState { + if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id { + if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first { + serverSelection = .server(id: first.id) + } else { + serverSelection = .all + } + } + + // change splash screen selection if necessary + selectUserAllServersSplashscreen = serverSelection + } + } + .alert( + Text("Delete User"), + isPresented: $isPresentingConfirmDeleteUsers, + presenting: selectedUsers + ) { selectedUsers in + Button("Delete", role: .destructive) { + viewModel.send(.deleteUsers(Array(selectedUsers))) + } + } message: { selectedUsers in + if selectedUsers.count == 1, let first = selectedUsers.first { + Text("Are you sure you want to delete \(first.username)?") + } else { + Text("Are you sure you want to delete \(selectedUsers.count) users?") + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .destructive) + } message: { error in + Text(error.localizedDescription) + } + .alert("Sign in", isPresented: $isPresentingLocalPin) { + + TextField("Pin", text: $pin) + .keyboardType(.numberPad) + + // bug in SwiftUI: having .disabled will dismiss + // alert but not call the closure (for length) + Button("Sign In") { + guard let user = selectedUsers.first else { + assertionFailure("User not selected") + return + } + + select(user: user, needsPin: false) + } + + Button("Cancel", role: .cancel) {} + } message: { + if let user = selectedUsers.first, user.pinHint.isNotEmpty { + Text(user.pinHint) + } else { + let username = selectedUsers.first?.username ?? .emptyDash + + Text("Enter pin for \(username)") + } + } + } +} diff --git a/Swiftfin/Views/ServerCheckView.swift b/Swiftfin/Views/ServerCheckView.swift new file mode 100644 index 00000000..d924c9ac --- /dev/null +++ b/Swiftfin/Views/ServerCheckView.swift @@ -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 SwiftUI + +struct ServerCheckView: View { + + @EnvironmentObject + private var router: MainCoordinator.Router + + @StateObject + private var viewModel = ServerCheckViewModel() + + @ViewBuilder + private func errorView(_ error: E) -> some View { + VStack(spacing: 10) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + + Text(viewModel.userSession.server.name) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + Text(error.localizedDescription) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + PrimaryButton(title: L10n.retry) + .onSelect { + viewModel.send(.checkServer) + } + .frame(maxWidth: 300) + .frame(height: 50) + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .connecting, .connected: + ZStack { + Color.clear + + ProgressView() + } + case let .error(error): + errorView(error) + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .onFirstAppear { + viewModel.send(.checkServer) + } + .onReceive(viewModel.$state) { newState in + if newState == .connected { + withAnimation(.linear(duration: 0.1)) { + let _ = router.root(\.mainTab) + } + } + } + .topBarTrailing { + + SettingsBarButton( + server: viewModel.userSession.server, + user: viewModel.userSession.user + ) { + router.route(to: \.settings) + } + } + } +} diff --git a/Swiftfin/Views/ServerDetailView.swift b/Swiftfin/Views/ServerDetailView.swift index ae8fc765..c04551cc 100644 --- a/Swiftfin/Views/ServerDetailView.swift +++ b/Swiftfin/Views/ServerDetailView.swift @@ -9,21 +9,35 @@ import Factory import SwiftUI -struct ServerDetailView: View { +// Note: uses environment `isEditing` for deletion button. This was done +// to just prevent having 2 views that looked/interacted the same +// except for a single button. + +// TODO: change URL picker from menu to list with network-url mapping + +struct EditServerView: View { + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @Environment(\.isEditing) + private var isEditing @State private var currentServerURL: URL + @State + private var isPresentingConfirmDeletion: Bool = false @StateObject - private var viewModel: ServerDetailViewModel + private var viewModel: EditServerViewModel init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: ServerDetailViewModel(server: server)) + self._viewModel = StateObject(wrappedValue: EditServerViewModel(server: server)) self._currentServerURL = State(initialValue: server.currentURL) } var body: some View { - Form { + List { Section { TextPairView( @@ -37,24 +51,28 @@ struct ServerDetailView: View { .tag(url) .foregroundColor(.secondary) } - .onChange(of: currentServerURL) { _ in - // TODO: change server url - viewModel.setCurrentServerURL(to: currentServerURL) - } } + } - TextPairView( - leading: L10n.version, - trailing: viewModel.server.version - ) - - TextPairView( - leading: L10n.operatingSystem, - trailing: viewModel.server.os - ) + if isEditing { + ListRowButton("Delete") { + isPresentingConfirmDeletion = true + } + .foregroundStyle(.red, .red.opacity(0.2)) } } - .navigationBarTitleDisplayMode(.inline) .navigationTitle(L10n.server) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: currentServerURL) { newValue in + viewModel.setCurrentURL(to: newValue) + } + .alert("Delete Server", isPresented: $isPresentingConfirmDeletion) { + Button("Delete", role: .destructive) { + viewModel.delete() + router.popLast() + } + } message: { + Text("Are you sure you want to delete \(viewModel.server.name) and all of its connected users?") + } } } diff --git a/Swiftfin/Views/ServerListView.swift b/Swiftfin/Views/ServerListView.swift deleted file mode 100644 index 4e5e7d0c..00000000 --- a/Swiftfin/Views/ServerListView.swift +++ /dev/null @@ -1,131 +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 SwiftUI - -struct ServerListView: View { - - @EnvironmentObject - private var router: ServerListCoordinator.Router - - @ObservedObject - var viewModel: ServerListViewModel - - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.servers, id: \.id) { server in - Button { - router.route(to: \.userList, server) - } label: { - ZStack(alignment: Alignment.leading) { - Rectangle() - .foregroundColor(Color(UIColor.secondarySystemFill)) - .cornerRadius(10) - - HStack(spacing: 10) { - Image(systemName: "server.rack") - .font(.system(size: 36)) - .foregroundColor(.primary) - - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title2) - .foregroundColor(.primary) - - Text(server.currentURL.absoluteString) - .font(.footnote) - .disabled(true) - .foregroundColor(.secondary) - - Text(viewModel.userTextFor(server: server)) - .font(.footnote) - .foregroundColor(.primary) - } - } - .padding() - } - } - .padding() - .contextMenu { - Button(role: .destructive) { - viewModel.remove(server: server) - } label: { - Label(L10n.remove, systemImage: "trash") - } - } - } - } - } - } - - private var noServerView: some View { - VStack { - L10n.connectToJellyfinServerStart.text - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - PrimaryButton(title: L10n.connect) - .onSelect { - router.route(to: \.connectToServer) - } - .frame(maxWidth: 300) - .frame(height: 50) - } - } - - @ViewBuilder - private var innerBody: some View { - if viewModel.servers.isEmpty { - noServerView - .offset(y: -50) - } else { - listView - } - } - - @ViewBuilder - private var trailingToolbarContent: some View { - if viewModel.servers.isNotEmpty { - Button { - router.route(to: \.connectToServer) - } label: { - Image(systemName: "plus.circle.fill") - } - } - } - - @ViewBuilder - private var leadingToolbarContent: some View { - Button { - router.route(to: \.basicAppSettings) - } label: { - Image(systemName: "gearshape.fill") - .accessibilityLabel(L10n.settings) - } - } - - var body: some View { - innerBody - .navigationTitle(L10n.servers) - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - trailingToolbarContent - } - } - .toolbar { - ToolbarItemGroup(placement: .topBarLeading) { - leadingToolbarContent - } - } - .onAppear { - viewModel.fetchServers() - } - } -} diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift index f7ac75af..31835e83 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift @@ -9,6 +9,8 @@ import Defaults import SwiftUI +// TODO: will be entirely re-organized + struct CustomizeViewsSettings: View { @Default(.Customization.itemViewType) @@ -16,9 +18,6 @@ struct CustomizeViewsSettings: View { @Default(.Customization.CinematicItemViewType.usePrimaryImage) private var cinematicItemViewTypeUsePrimaryImage - @Default(.hapticFeedback) - private var hapticFeedback - @Default(.Customization.shouldShowMissingSeasons) private var shouldShowMissingSeasons @Default(.Customization.shouldShowMissingEpisodes) @@ -41,11 +40,18 @@ struct CustomizeViewsSettings: View { private var similarPosterType @Default(.Customization.searchPosterType) private var searchPosterType - @Default(.Customization.Library.viewType) - private var libraryViewType + @Default(.Customization.Library.displayType) + private var libraryDisplayType + @Default(.Customization.Library.posterType) + private var libraryPosterType @Default(.Customization.Library.listColumnCount) private var listColumnCount + @Default(.Customization.Library.rememberLayout) + private var rememberLibraryLayout + @Default(.Customization.Library.rememberSort) + private var rememberLibrarySort + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) private var useSeriesLandscapeBackdrop @@ -72,8 +78,6 @@ struct CustomizeViewsSettings: View { L10n.usePrimaryImageDescription.text } } - - Toggle(L10n.hapticFeedback, isOn: $hapticFeedback) } Section { @@ -108,7 +112,7 @@ struct CustomizeViewsSettings: View { L10n.missingItems.text } - Section { + Section(L10n.posters) { ChevronButton(title: L10n.indicators) .onSelect { @@ -126,11 +130,14 @@ struct CustomizeViewsSettings: View { CaseIterablePicker(title: L10n.recommended, selection: $similarPosterType) CaseIterablePicker(title: L10n.search, selection: $searchPosterType) + } - // TODO: figure out how we can do the same Menu as the library menu picker? - CaseIterablePicker(title: L10n.library, selection: $libraryViewType) + Section("Libraries") { + CaseIterablePicker(title: L10n.library, selection: $libraryDisplayType) - if libraryViewType == .list, UIDevice.isPad { + CaseIterablePicker(title: L10n.posters, selection: $libraryPosterType) + + if libraryDisplayType == .list, UIDevice.isPad { BasicStepper( title: "Columns", value: $listColumnCount, @@ -138,9 +145,18 @@ struct CustomizeViewsSettings: View { step: 1 ) } + } - } header: { - L10n.posters.text + Section { + Toggle("Remember layout", isOn: $rememberLibraryLayout) + } footer: { + Text("Remember layout for individual libraries") + } + + Section { + Toggle("Remember sorting", isOn: $rememberLibrarySort) + } footer: { + Text("Remember sorting for individual libraries") } Section { diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 036c24e4..46cfa95d 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -13,8 +13,6 @@ struct ExperimentalSettingsView: View { @Default(.Experimental.forceDirectPlay) private var forceDirectPlay - @Default(.Experimental.syncSubtitleStateWithAdjacent) - private var syncSubtitleStateWithAdjacent @Default(.Experimental.liveTVForceDirectPlay) private var liveTVForceDirectPlay diff --git a/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift b/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift deleted file mode 100644 index 5792e435..00000000 --- a/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift +++ /dev/null @@ -1,68 +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 SwiftUI - -struct QuickConnectSettingsView: View { - - @ObservedObject - var viewModel: QuickConnectSettingsViewModel - - @State - private var code: String = "" - @State - private var error: Error? - @State - private var isPresentingError: Bool = false - @State - private var isPresentingSuccess: Bool = false - - var body: some View { - Form { - Section { - TextField(L10n.quickConnectCode, text: $code) - .keyboardType(.numberPad) - .disabled(viewModel.isLoading) - - Button { - Task { - do { - try await viewModel.authorize(code: code) - isPresentingSuccess = true - } catch { - self.error = error - isPresentingError = true - } - } - } label: { - L10n.authorize.text - .font(.callout) - .disabled(code.count != 6 || viewModel.isLoading) - } - } - } - .navigationTitle(L10n.quickConnect.text) - .alert( - L10n.error, - isPresented: $isPresentingError - ) { - Button(L10n.dismiss, role: .cancel) - } message: { - Text(error?.localizedDescription ?? .emptyDash) - } - .alert( - L10n.quickConnect, - isPresented: $isPresentingSuccess - ) { - Button(L10n.dismiss, role: .cancel) - } message: { - L10n.quickConnectSuccessMessage.text - } - } -} diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift new file mode 100644 index 00000000..0fdfd11b --- /dev/null +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -0,0 +1,56 @@ +// +// 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 SwiftUI + +extension SettingsView { + + struct UserProfileRow: View { + + @Injected(UserSession.current) + private var userSession: UserSession! + + let action: () -> Void + + @ViewBuilder + private var imageView: some View { + ImageView(userSession.user.profileImageSource(client: userSession.client, maxWidth: 120, maxHeight: 120)) + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + } + + var body: some View { + Button { + action() + } label: { + HStack { + imageView + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + .frame(width: 50, height: 50) + + Text(userSession.user.username) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .foregroundStyle(.primary, .secondary) + } + } +} diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift similarity index 61% rename from Swiftfin/Views/SettingsView/SettingsView.swift rename to Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift index d94cd023..f5db7412 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -14,54 +14,49 @@ import SwiftUI struct SettingsView: View { - @Default(.accentColor) + @Default(.userAccentColor) private var accentColor - @Default(.appAppearance) - private var appAppearance + + @Default(.userAppearance) + private var appearance @Default(.VideoPlayer.videoPlayerType) private var videoPlayerType - @Injected(Container.userSession) - private var userSession - @EnvironmentObject private var router: SettingsCoordinator.Router - @ObservedObject - var viewModel: SettingsViewModel + @StateObject + private var viewModel = SettingsViewModel() var body: some View { Form { Section { - HStack { - L10n.user.text - Spacer() - Text(userSession.user.username) - .foregroundColor(accentColor) + + UserProfileRow { + router.route(to: \.userProfile, viewModel) } - ChevronButton(title: L10n.server, subtitle: userSession.server.name) - .onSelect { - router.route(to: \.serverDetail, userSession.server) - } - - ChevronButton(title: L10n.quickConnect) - .onSelect { - router.route(to: \.quickConnect) - } - - Button { - router.dismissCoordinator { - viewModel.signOut() - } - } label: { - L10n.switchUser.text - .font(.callout) + // TODO: admin users go to dashboard instead + ChevronButton( + title: L10n.server, + subtitle: viewModel.userSession.server.name + ) + .onSelect { + router.route(to: \.serverDetail, viewModel.userSession.server) } } - Section { + ListRowButton(L10n.switchUser) { + UIDevice.impact(.medium) + + router.dismissCoordinator { + viewModel.signOut() + } + } + .foregroundStyle(accentColor.overlayColor, accentColor) + + Section(L10n.videoPlayer) { CaseIterablePicker( title: L10n.videoPlayerType, selection: $videoPlayerType @@ -76,17 +71,10 @@ struct SettingsView: View { .onSelect { router.route(to: \.videoPlayerSettings) } - } header: { - L10n.videoPlayer.text } - Section { - CaseIterablePicker(title: L10n.appearance, selection: $appAppearance) - - ChevronButton(title: L10n.appIcon) - .onSelect { - router.route(to: \.appIconSelector) - } + Section(L10n.accessibility) { + CaseIterablePicker(title: L10n.appearance, selection: $appearance) ChevronButton(title: L10n.customize) .onSelect { @@ -97,8 +85,6 @@ struct SettingsView: View { .onSelect { router.route(to: \.experimentalSettings) } - } header: { - L10n.accessibility.text } Section { @@ -107,11 +93,6 @@ struct SettingsView: View { Text(L10n.accentColorDescription) } - ChevronButton(title: L10n.about) - .onSelect { - router.route(to: \.about) - } - ChevronButton(title: L10n.logs) .onSelect { router.route(to: \.log) diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift new file mode 100644 index 00000000..de793931 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift @@ -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 Defaults +import Foundation +import SwiftUI + +struct QuickConnectAuthorizeView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @FocusState + private var isCodeFocused: Bool + + @State + private var code: String = "" + @State + private var error: Error? = nil + @State + private var isPresentingError: Bool = false + @State + private var isPresentingSuccess: Bool = false + + @StateObject + private var viewModel = QuickConnectAuthorizeViewModel() + + var body: some View { + Form { + Section { + TextField(L10n.quickConnectCode, text: $code) + .keyboardType(.numberPad) + .disabled(viewModel.state == .authorizing) + .focused($isCodeFocused) + } footer: { + Text("Enter the 6 digit code from your other device.") + } + + if viewModel.state == .authorizing { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + isCodeFocused = true + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.authorize) { + viewModel.send(.authorize(code)) + } + .disabled(code.count != 6 || viewModel.state == .authorizing) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .opacity(code.count != 6 ? 0.5 : 1) + } + } + .interactiveDismissDisabled(viewModel.state == .authorizing) + .navigationBarBackButtonHidden(viewModel.state == .authorizing) + .navigationTitle(L10n.quickConnect.text) + .onFirstAppear { + isCodeFocused = true + } + .onChange(of: code) { newValue in + code = String(newValue.prefix(6)) + } + .onReceive(viewModel.events) { event in + switch event { + case .authorized: + UIDevice.feedback(.success) + + isPresentingSuccess = true + case let .error(eventError): + UIDevice.feedback(.error) + + error = eventError + isPresentingError = true + } + } + .topBarTrailing { + if viewModel.state == .authorizing { + ProgressView() + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .destructive) { + isCodeFocused = true + } + } message: { error in + Text(error.localizedDescription) + } + .alert( + L10n.quickConnect, + isPresented: $isPresentingSuccess + ) { + Button(L10n.dismiss, role: .cancel) { + router.pop() + } + } message: { + L10n.quickConnectSuccessMessage.text + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift new file mode 100644 index 00000000..741db8db --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift @@ -0,0 +1,152 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct ResetUserPasswordView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @FocusState + private var focusedPassword: Int? + + @State + private var currentPassword: String = "" + @State + private var newPassword: String = "" + @State + private var confirmNewPassword: String = "" + + @State + private var error: Error? = nil + @State + private var isPresentingError: Bool = false + @State + private var isPresentingSuccess: Bool = false + + @StateObject + private var viewModel = ResetUserPasswordViewModel() + + var body: some View { + List { + + Section("Current Password") { + UnmaskSecureField("Current Password", text: $currentPassword) { + focusedPassword = 1 + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedPassword, equals: 0) + .disabled(viewModel.state == .resetting) + } + + Section("New Password") { + UnmaskSecureField("New Password", text: $newPassword) { + focusedPassword = 2 + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedPassword, equals: 1) + .disabled(viewModel.state == .resetting) + } + + Section { + UnmaskSecureField("Confirm New Password", text: $confirmNewPassword) { + viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedPassword, equals: 2) + .disabled(viewModel.state == .resetting) + } header: { + Text("Confirm New Password") + } footer: { + if newPassword != confirmNewPassword { + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + + Text("New passwords do not match") + } + } + } + + Section { + if viewModel.state == .resetting { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + focusedPassword = 0 + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton("Reset") { + focusedPassword = nil + viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) + } + .disabled(newPassword != confirmNewPassword || viewModel.state == .resetting) + .foregroundStyle(accentColor.overlayColor, accentColor) + .opacity(newPassword != confirmNewPassword ? 0.5 : 1) + } + } footer: { + Text("Changes the Jellyfin server user password. This does not change any Swiftfin settings.") + } + } + .interactiveDismissDisabled(viewModel.state == .resetting) + .navigationBarBackButtonHidden(viewModel.state == .resetting) + .navigationTitle(L10n.password) + .onFirstAppear { + focusedPassword = 0 + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + + error = eventError + isPresentingError = true + case .success: + UIDevice.feedback(.success) + + isPresentingSuccess = true + } + } + .topBarTrailing { + if viewModel.state == .resetting { + ProgressView() + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) { + focusedPassword = 1 + } + } message: { error in + Text(error.localizedDescription) + } + .alert( + "Success", + isPresented: $isPresentingSuccess + ) { + Button(L10n.dismiss, role: .cancel) { + router.pop() + } + } message: { + Text("User password has been changed.") + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift new file mode 100644 index 00000000..a15b4f35 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift @@ -0,0 +1,278 @@ +// +// 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 Defaults +import KeychainSwift +import LocalAuthentication +import SwiftUI + +// TODO: present toast when authentication successfully changed +// TODO: pop is just a workaround to get change published from usersession. +// find fix and don't pop when successfully changed +// TODO: could cleanup/refactor greatly + +struct UserLocalSecurityView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @State + private var error: Error? = nil + @State + private var isPresentingError: Bool = false + @State + private var isPresentingOldPinPrompt: Bool = false + @State + private var isPresentingNewPinPrompt: Bool = false + @State + private var listSize: CGSize = .zero + @State + private var onPinCompletion: (() -> Void)? = nil + @State + private var pin: String = "" + @State + private var pinHint: String = "" + @State + private var signInPolicy: UserAccessPolicy = .none + + @StateObject + private var viewModel = UserLocalSecurityViewModel() + + private func checkOldPolicy() { + do { + try viewModel.checkForOldPolicy() + } catch { + return + } + + checkNewPolicy() + } + + private func checkNewPolicy() { + do { + try viewModel.checkFor(newPolicy: signInPolicy) + } catch { + return + } + + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) + } + + // error logging/presentation is handled within here, just + // use try+thrown error in local Task for early return + private func performDeviceAuthentication(reason: String) async throws { + let context = LAContext() + var policyError: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { + viewModel.logger.critical("\(policyError!.localizedDescription)") + + await MainActor.run { + self + .error = + JellyfinAPIError( + "Unable to perform device authentication. You may need to enable Face ID in the Settings app for Swiftfin." + ) + self.isPresentingError = true + } + + throw JellyfinAPIError("Device auth failed") + } + + do { + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + } catch { + viewModel.logger.critical("\(error.localizedDescription)") + + await MainActor.run { + self.error = JellyfinAPIError("Unable to perform device authentication") + self.isPresentingError = true + } + + throw JellyfinAPIError("Device auth failed") + } + } + + var body: some View { + List { + + Section { + CaseIterablePicker(title: "Security", selection: $signInPolicy) + } footer: { + VStack(alignment: .leading, spacing: 10) { + Text( + "Additional security access for users signed in to this device. This does not change any Jellyfin server user settings." + ) + + // frame necessary with bug within BulletedList + BulletedList { + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requireDeviceAuthentication.displayTitle) + .fontWeight(.semibold) + + Text("Require device authentication when signing in to the user.") + } + .padding(.bottom, 15) + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requirePin.displayTitle) + .fontWeight(.semibold) + + Text("Require a local pin when signing in to the user. This pin is unrecoverable.") + } + .padding(.bottom, 15) + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.none.displayTitle) + .fontWeight(.semibold) + + Text("Save the user to this device without any local authentication.") + } + } + .frame(width: max(10, listSize.width - 50)) + } + } + + if signInPolicy == .requirePin { + Section { + TextField("Hint", text: $pinHint) + } header: { + Text("Hint") + } footer: { + Text("Set a hint when prompting for the pin.") + } + } + } + .animation(.linear, value: signInPolicy) + .navigationTitle("Security") + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + pinHint = viewModel.userSession.user.pinHint + signInPolicy = viewModel.userSession.user.signInPolicy + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + + error = eventError + isPresentingError = true + case .promptForOldDeviceAuth: + Task { @MainActor in + try await performDeviceAuthentication( + reason: "User \(viewModel.userSession.user.username) requires device authentication" + ) + + checkNewPolicy() + } + case .promptForOldPin: + onPinCompletion = { + Task { + try viewModel.check(oldPin: pin) + + checkNewPolicy() + } + } + + pin = "" + isPresentingOldPinPrompt = true + case .promptForNewDeviceAuth: + Task { @MainActor in + try await performDeviceAuthentication( + reason: "User \(viewModel.userSession.user.username) requires device authentication" + ) + + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: "") + router.popLast() + } + case .promptForNewPin: + onPinCompletion = { + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) + router.popLast() + } + + pin = "" + isPresentingNewPinPrompt = true + } + } + .topBarTrailing { + Button { + checkOldPolicy() + } label: { + Group { + if signInPolicy == .requirePin, signInPolicy == viewModel.userSession.user.signInPolicy { + Text("Change Pin") + } else { + Text("Save") + } + } + .foregroundStyle(accentColor.overlayColor) + .font(.headline) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background { + accentColor + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .trackingSize($listSize) + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) + } message: { error in + Text(error.localizedDescription) + } + .alert( + "Enter Pin", + isPresented: $isPresentingOldPinPrompt, + presenting: onPinCompletion + ) { completion in + + TextField("Pin", text: $pin) + .keyboardType(.numberPad) + + // bug in SwiftUI: having .disabled will dismiss + // alert but not call the closure (for length) + Button("Continue") { + completion() + } + + Button(L10n.cancel, role: .cancel) {} + } message: { _ in + Text("Enter pin for \(viewModel.userSession.user.username)") + } + .alert( + "Set Pin", + isPresented: $isPresentingNewPinPrompt, + presenting: onPinCompletion + ) { completion in + + TextField("Pin", text: $pin) + .keyboardType(.numberPad) + + // bug in SwiftUI: having .disabled will dismiss + // alert but not call the closure (for length) + Button("Set") { + completion() + } + + Button(L10n.cancel, role: .cancel) {} + } message: { _ in + Text("Create a pin to sign in to \(viewModel.userSession.user.username) on this device") + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift new file mode 100644 index 00000000..1b4a115b --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -0,0 +1,85 @@ +// +// 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 SwiftUI + +struct UserProfileSettingsView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @ObservedObject + var viewModel: SettingsViewModel + + @ViewBuilder + private var imageView: some View { + ImageView( + viewModel.userSession.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 120, + maxHeight: 120 + ) + ) + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + } + + var body: some View { + List { + Section { + VStack(alignment: .center) { + Button { + // TODO: photo picker + } label: { + ZStack(alignment: .bottomTrailing) { + imageView + .frame(width: 150, height: 150) + .clipShape(.circle) + .shadow(radius: 5) + + // TODO: uncomment when photo picker implemented +// Image(systemName: "pencil.circle.fill") +// .resizable() +// .frame(width: 30, height: 30) + } + } + + Text(viewModel.userSession.user.username) + .fontWeight(.semibold) + .font(.title2) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + + Section { + ChevronButton(title: L10n.quickConnect) + .onSelect { + router.route(to: \.quickConnect) + } + + ChevronButton(title: "Password") + .onSelect { + router.route(to: \.resetUserPassword) + } + } + + Section { + ChevronButton(title: "Local Security") + .onSelect { + router.route(to: \.localSecurity) + } + } + } + } +} diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift deleted file mode 100644 index 4d08f0f0..00000000 --- a/Swiftfin/Views/UserListView.swift +++ /dev/null @@ -1,88 +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 CollectionVGrid -import SwiftUI - -struct UserListView: View { - - @EnvironmentObject - private var router: UserListCoordinator.Router - - @StateObject - private var viewModel: UserListViewModel - - init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: UserListViewModel(server: server)) - } - - private var noUserView: some View { - VStack { - L10n.signInGetStarted.text - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - PrimaryButton(title: L10n.signIn) - .onSelect { - router.route(to: \.userSignIn, viewModel.server) - } - .frame(maxWidth: 300) - .frame(height: 50) - } - } - - @ViewBuilder - private var gridView: some View { - CollectionVGrid( - viewModel.users, - layout: .minWidth(120, itemSpacing: 30, lineSpacing: 30) - ) { user in - UserProfileButton(user: user, client: viewModel.client) - .onSelect { - viewModel.signIn(user: user) - } - .contextMenu(menuItems: { - Button(L10n.remove, systemImage: "trash", role: .destructive) { - viewModel.remove(user: user) - } - }) - } - } - - var body: some View { - Group { - if viewModel.users.isEmpty { - noUserView - .offset(y: -50) - } else { - gridView - } - } - .navigationTitle(viewModel.server.name) - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - if viewModel.users.isNotEmpty { - Button { - router.route(to: \.userSignIn, viewModel.server) - } label: { - Image(systemName: "person.crop.circle.fill.badge.plus") - } - } - - Button { - router.route(to: \.serverDetail, viewModel.server) - } label: { - Image(systemName: "info.circle.fill") - } - } - } - .onAppear { - viewModel.fetchUsers() - } - } -} diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift new file mode 100644 index 00000000..c4d6133b --- /dev/null +++ b/Swiftfin/Views/UserSignInView/Components/PublicUserRow.swift @@ -0,0 +1,91 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension UserSignInView { + + struct PublicUserRow: View { + + @Environment(\.colorScheme) + private var colorScheme + + private let user: UserDto + private let client: JellyfinClient + private let action: () -> Void + + init( + user: UserDto, + client: JellyfinClient, + action: @escaping () -> Void + ) { + self.user = user + self.client = client + self.action = action + } + + private var personView: some View { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + + var body: some View { + Button { + action() + } label: { + HStack { + ZStack { + Color.clear + + ImageView(user.profileImageSource(client: client, maxWidth: 120)) + .image { image in + image + .posterBorder(ratio: 0.5, of: \.width) + } + .placeholder { _ in + personView + } + .failure { + personView + } + } + .aspectRatio(1, contentMode: .fill) + .posterShadow() + .clipShape(.circle) + .frame(width: 50, height: 50) + + Text(user.name ?? .emptyDash) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .foregroundStyle(.primary) + } + } +} diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift deleted file mode 100644 index 44e98c5b..00000000 --- a/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift +++ /dev/null @@ -1,47 +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 JellyfinAPI -import SwiftUI - -extension UserSignInView { - struct PublicUserSignInView: View { - @ObservedObject - var viewModel: UserSignInViewModel - - @State - private var password: String = "" - - let publicUser: UserDto - - var body: some View { - DisclosureGroup { - SecureField(L10n.password, text: $password) - Button { - guard let username = publicUser.name else { return } - viewModel.send(.signInWithUserPass(username: username, password: password)) - } label: { - L10n.signIn.text - } - } label: { - HStack { - ImageView(publicUser.profileImageSource(client: viewModel.client, maxWidth: 50, maxHeight: 50)) - .failure { - Image(systemName: "person.circle") - .resizable() - } - .frame(width: 50, height: 50) - .clipShape(Circle()) - - Text(publicUser.name ?? .emptyDash) - Spacer() - } - } - } - } -} diff --git a/Swiftfin/Views/UserSignInView/Components/UserSignInSecurityView.swift b/Swiftfin/Views/UserSignInView/Components/UserSignInSecurityView.swift new file mode 100644 index 00000000..797c061c --- /dev/null +++ b/Swiftfin/Views/UserSignInView/Components/UserSignInSecurityView.swift @@ -0,0 +1,115 @@ +// +// 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 + +// Note: this could be renamed from Security, but that's all it's used for atow + +extension UserSignInView { + + struct SecurityView: View { + + @EnvironmentObject + private var router: UserSignInCoordinator.Router + + @Binding + private var pinHint: String + @Binding + private var signInPolicy: UserAccessPolicy + + @State + private var listSize: CGSize = .zero + @State + private var updatePinHint: String + @State + private var updateSignInPolicy: UserAccessPolicy + + init( + pinHint: Binding, + signInPolicy: Binding + ) { + self._pinHint = pinHint + self._signInPolicy = signInPolicy + self._updatePinHint = State(initialValue: pinHint.wrappedValue) + self._updateSignInPolicy = State(initialValue: signInPolicy.wrappedValue) + } + + var body: some View { + List { + + Section { + CaseIterablePicker(title: "Security", selection: $updateSignInPolicy) + } footer: { + // TODO: descriptions of each section + + VStack(alignment: .leading, spacing: 10) { + Text( + "Additional security for users signed in to this device. This does not change any Jellyfin server user settings." + ) + + // frame necessary with bug within BulletedList + BulletedList { + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requireDeviceAuthentication.displayTitle) + .fontWeight(.semibold) + + Text("Require device authentication when signing in to the user.") + } + .padding(.bottom, 15) + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requirePin.displayTitle) + .fontWeight(.semibold) + + Text("Require a local pin when signing in to the user. This pin is unrecoverable.") + } + .padding(.bottom, 15) + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.none.displayTitle) + .fontWeight(.semibold) + + Text("Save the user to this device without any local authentication.") + } + } + .frame(width: max(10, listSize.width - 50)) + } + } + + if signInPolicy == .requirePin { + Section { + TextField("Hint", text: $updatePinHint) + } header: { + Text("Hint") + } footer: { + Text("Set a hint when prompting for the pin.") + } + } + } + .animation(.linear, value: signInPolicy) + .navigationTitle("Security") + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.popLast() + } + .onChange(of: updatePinHint) { newValue in + let truncated = String(newValue.prefix(120)) + updatePinHint = truncated + pinHint = truncated + } + .onChange(of: updatePinHint) { newValue in + pinHint = newValue + } + .onChange(of: updateSignInPolicy) { newValue in + signInPolicy = newValue + } + .trackingSize($listSize) + } + } +} diff --git a/Swiftfin/Views/UserSignInView/UserSignInView.swift b/Swiftfin/Views/UserSignInView/UserSignInView.swift index c816f161..a5c31edd 100644 --- a/Swiftfin/Views/UserSignInView/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView/UserSignInView.swift @@ -6,57 +6,252 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Defaults +import Factory +import LocalAuthentication import Stinsen import SwiftUI +// TODO: ignore device authentication `canceled by user` NSError +// TODO: fix duplicate user +// - could be good to replace access token +// - check against current user policy + struct UserSignInView: View { + @Default(.accentColor) + private var accentColor + @EnvironmentObject private var router: UserSignInCoordinator.Router - @ObservedObject - var viewModel: UserSignInViewModel + @FocusState + private var focusedTextField: Int? @State - private var isPresentingSignInError: Bool = false + private var duplicateUser: UserState? = nil + @State + private var error: Error? = nil + @State + private var isPresentingDuplicateUser: Bool = false + @State + private var isPresentingError: Bool = false + @State + private var isPresentingLocalPin: Bool = false + @State + private var onPinCompletion: (() -> Void)? = nil @State private var password: String = "" @State + private var pin: String = "" + @State + private var pinHint: String = "" + @State + private var signInPolicy: UserAccessPolicy = .none + @State private var username: String = "" + @StateObject + private var viewModel: UserSignInViewModel + + init(server: ServerState) { + self._viewModel = StateObject(wrappedValue: UserSignInViewModel(server: server)) + } + + // TODO: don't have multiple ways to handle device authentication vs required pin + + private func openQuickConnect(needsPin: Bool = true) { + Task { + switch signInPolicy { + case .none: () + case .requireDeviceAuthentication: + try await performDeviceAuthentication( + reason: "Require device authentication to sign in to the Quick Connect user on this device" + ) + case .requirePin: + if needsPin { + onPinCompletion = { + router.route(to: \.quickConnect, viewModel.quickConnect) + } + isPresentingLocalPin = true + return + } + } + + router.route(to: \.quickConnect, viewModel.quickConnect) + } + } + + private func signInUserPassword(needsPin: Bool = true) { + Task { + switch signInPolicy { + case .none: () + case .requireDeviceAuthentication: + try await performDeviceAuthentication(reason: "Require device authentication to sign in to \(username) on this device") + case .requirePin: + if needsPin { + onPinCompletion = { + viewModel.send(.signIn(username: username, password: password, policy: signInPolicy)) + } + isPresentingLocalPin = true + return + } + } + + viewModel.send(.signIn(username: username, password: password, policy: signInPolicy)) + } + } + + private func signInUplicate(user: UserState, needsPin: Bool = true, replace: Bool) { + Task { + switch user.signInPolicy { + case .none: () + case .requireDeviceAuthentication: + try await performDeviceAuthentication(reason: "User \(user.username) requires device authentication") + case .requirePin: + onPinCompletion = { + viewModel.send(.signInDuplicate(user, replace: replace)) + } + isPresentingLocalPin = true + return + } + + viewModel.send(.signInDuplicate(user, replace: replace)) + } + } + + private func performPinAuthentication() async throws { + isPresentingLocalPin = true + + guard pin.count > 4, pin.count < 30 else { + throw JellyfinAPIError("Pin auth failed") + } + } + + // error logging/presentation is handled within here, just + // use try+thrown error in local Task for early return + private func performDeviceAuthentication(reason: String) async throws { + let context = LAContext() + var policyError: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { + viewModel.logger.critical("\(policyError!.localizedDescription)") + + await MainActor.run { + self + .error = + JellyfinAPIError( + "Unable to perform device authentication. You may need to enable Face ID in the Settings app for Swiftfin." + ) + self.isPresentingError = true + } + + throw JellyfinAPIError("Device auth failed") + } + + do { + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + } catch { + viewModel.logger.critical("\(error.localizedDescription)") + + await MainActor.run { + self.error = JellyfinAPIError("Unable to perform device authentication") + self.isPresentingError = true + } + + throw JellyfinAPIError("Device auth failed") + } + } + @ViewBuilder private var signInSection: some View { Section { TextField(L10n.username, text: $username) .autocorrectionDisabled() - .textInputAutocapitalization(.none) - - UnmaskSecureField(L10n.password, text: $password) - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - - if case .signingIn = viewModel.state { - Button(role: .destructive) { - viewModel.send(.cancelSignIn) - } label: { - L10n.cancel.text + .textInputAutocapitalization(.never) + .focused($focusedTextField, equals: 0) + .onSubmit { + focusedTextField = 1 } - } else { - Button { - viewModel.send(.signInWithUserPass(username: username, password: password)) - } label: { - L10n.signIn.text - } - .disabled(username.isEmpty) + + UnmaskSecureField(L10n.password, text: $password) { + focusedTextField = nil + + signInUserPassword() } + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($focusedTextField, equals: 1) } header: { - L10n.signInToServer(viewModel.server.name).text + Text(L10n.signInToServer(viewModel.server.name)) + } footer: { + switch signInPolicy { + case .requireDeviceAuthentication: + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + .backport + .fontWeight(.bold) + + Text("This user will require device authentication.") + } + case .requirePin: + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + .backport + .fontWeight(.bold) + + Text("This user will require a pin.") + } + case .none: + EmptyView() + } + } + + if case .signingIn = viewModel.state { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.signIn) { + focusedTextField = nil + + signInUserPassword() + } + .disabled(username.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .opacity(username.isEmpty ? 0.5 : 1) + } + + if viewModel.isQuickConnectEnabled { + Section { + ListRowButton(L10n.quickConnect) { + openQuickConnect() + } + .disabled(viewModel.state == .signingIn) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + } + } + + if let disclaimer = viewModel.serverDisclaimer { + Section("Disclaimer") { + Text(disclaimer) + .font(.callout) + } } } @ViewBuilder private var publicUsersSection: some View { - Section { + Section(L10n.publicUsers) { if viewModel.publicUsers.isEmpty { L10n.noPublicUsers.text .font(.callout) @@ -64,77 +259,133 @@ struct UserSignInView: View { .frame(maxWidth: .infinity) } else { ForEach(viewModel.publicUsers, id: \.id) { user in - PublicUserSignInView(viewModel: viewModel, publicUser: user) - .disabled(viewModel.isLoading) - } - } - } header: { - HStack { - L10n.publicUsers.text - - Spacer() - - Button { - Task { - try? await viewModel.getPublicUsers() + PublicUserRow( + user: user, + client: viewModel.server.client + ) { + username = user.name ?? "" + password = "" + focusedTextField = 1 } - } label: { - Image(systemName: "arrow.clockwise.circle.fill") } - .disabled(viewModel.isLoading) } } - .headerProminence(.increased) - } - - var errorText: some View { - var text: String? - if case let .error(error) = viewModel.state { - text = error.localizedDescription - } - return Text(text ?? .emptyDash) } var body: some View { List { signInSection - if viewModel.quickConnectEnabled { - Button { - router.route(to: \.quickConnect) - } label: { - L10n.quickConnect.text - } - } - publicUsersSection } - .onChange(of: viewModel.state) { newState in - if case .error = newState { - // If we encountered the error as we switched from quick connect navigation to this view, - // it's possible that the alert doesn't show, so wait a little bit - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - isPresentingSignInError = true - } + .animation(.linear, value: viewModel.isQuickConnectEnabled) + .interactiveDismissDisabled(viewModel.state == .signingIn) + .navigationTitle(L10n.signIn) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton(disabled: viewModel.state == .signingIn) { + router.dismissCoordinator() + } + .onChange(of: isPresentingLocalPin) { newValue in + if newValue { + pin = "" + } else { + onPinCompletion = nil + } + } + .onChange(of: pin) { newValue in + StoredValues[.Temp.userLocalPin] = newValue + } + .onChange(of: pinHint) { newValue in + StoredValues[.Temp.userLocalPinHint] = newValue + } + .onChange(of: signInPolicy) { newValue in + // necessary for Quick Connect sign in, but could + // just use for general sign in + StoredValues[.Temp.userSignInPolicy] = newValue + } + .onReceive(viewModel.events) { event in + switch event { + case let .duplicateUser(duplicateUser): + UIDevice.impact(.medium) + + self.duplicateUser = duplicateUser + isPresentingDuplicateUser = true + case let .error(eventError): + UIDevice.feedback(.error) + + error = eventError + isPresentingError = true + case let .signedIn(user): + UIDevice.feedback(.success) + + Defaults[.lastSignedInUserID] = user.id + UserSession.current.reset() + Notifications[.didSignIn].post() + } + } + .onFirstAppear { + focusedTextField = 0 + viewModel.send(.getPublicData) + } + .topBarTrailing { + if viewModel.state == .signingIn || viewModel.backgroundStates.contains(.gettingPublicData) { + ProgressView() + } + + Button("Security", systemImage: "gearshape.fill") { + let parameters = UserSignInCoordinator.SecurityParameters( + pinHint: $pinHint, + signInPolicy: $signInPolicy + ) + router.route(to: \.security, parameters) } } .alert( - L10n.error, - isPresented: $isPresentingSignInError - ) { + Text("Duplicate User"), + isPresented: $isPresentingDuplicateUser, + presenting: duplicateUser + ) { _ in + + // TODO: uncomment when duplicate user fixed +// Button(L10n.signIn) { +// signInUplicate(user: user, replace: false) +// } + +// Button("Replace") { +// signInUplicate(user: user, replace: true) +// } + Button(L10n.dismiss, role: .cancel) - } message: { - errorText + } message: { duplicateUser in + Text("\(duplicateUser.username) is already saved") } - .navigationTitle(L10n.signIn) - .onAppear { - Task { - try? await viewModel.checkQuickConnect() - try? await viewModel.getPublicUsers() + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) + } message: { error in + Text(error.localizedDescription) + } + .alert( + "Set Pin", + isPresented: $isPresentingLocalPin, + presenting: onPinCompletion + ) { completion in + + TextField("Pin", text: $pin) + .keyboardType(.numberPad) + + // bug in SwiftUI: having .disabled will dismiss + // alert but not call the closure (for length) + Button("Sign In") { + completion() } - } - .onDisappear { - viewModel.send(.cancelSignIn) + + Button(L10n.cancel, role: .cancel) {} + } message: { _ in + Text("Set pin for new user.") } } } diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift index 0d217faf..080e8d61 100644 --- a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift @@ -154,7 +154,7 @@ extension LiveVideoPlayer.Overlay { guard chapterSlider else { return } let newChapter = viewModel.chapter(from: newValue) if newChapter != currentChapter { - if isScrubbing && Defaults[.hapticFeedback] { + if isScrubbing { UIDevice.impact(.light) } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift index fe379345..b333ee41 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift @@ -154,7 +154,7 @@ extension VideoPlayer.Overlay { guard chapterSlider else { return } let newChapter = viewModel.chapter(from: newValue) if newChapter != currentChapter { - if isScrubbing && Defaults[.hapticFeedback] { + if isScrubbing { UIDevice.impact(.light) }