diff --git a/Shared/Components/AlternateLayoutView.swift b/Shared/Components/AlternateLayoutView.swift index ef3da0c4..d9a1b3fc 100644 --- a/Shared/Components/AlternateLayoutView.swift +++ b/Shared/Components/AlternateLayoutView.swift @@ -28,8 +28,9 @@ struct AlternateLayoutView: View { var body: some View { layout() .hidden() - .overlay(alignment: alignment) { - content() - } + .overlay( + alignment: alignment, + content: content + ) } } diff --git a/Shared/Components/CenteredLazyVGrid.swift b/Shared/Components/CenteredLazyVGrid.swift new file mode 100644 index 00000000..d666c203 --- /dev/null +++ b/Shared/Components/CenteredLazyVGrid.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) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +/// A LazyVGrid that centers its elements, most notably on the last row. +struct CenteredLazyVGrid: View { + + @State + private var elementSize: CGSize = .zero + + private let columnCount: Int + private let columns: [GridItem] + private let content: (Data.Element) -> Content + private let data: Data + private let id: KeyPath + private let spacing: CGFloat + + /// Calculates the x offset for elements in + /// the last row of the grid to be centered. + private func elementXOffset(for offset: Int) -> CGFloat { + let dataCount = data.count + let lastRowCount = dataCount % columnCount + + guard lastRowCount > 0 else { return 0 } + + let lastRowIndices = (dataCount - lastRowCount ..< dataCount) + + guard lastRowIndices.contains(offset) else { return 0 } + + let lastRowMissingCount = columnCount - lastRowCount + return CGFloat(lastRowMissingCount) * (elementSize.width + spacing) / 2 + } + + var body: some View { + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(Array(data.enumerated()), id: \.offset) { offset, element in + content(element) + .trackingSize($elementSize) + .offset(x: elementXOffset(for: offset)) + } + } + } +} + +extension CenteredLazyVGrid { + + init( + data: Data, + id: KeyPath, + columns: Int, + spacing: CGFloat = 0, + @ViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.columnCount = columns + self.content = content + self.data = data + self.id = id + self.spacing = spacing + + self.columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns) + } +} + +extension CenteredLazyVGrid where Data.Element: Identifiable, ID == Data.Element.ID { + + init( + data: Data, + columns: Int, + spacing: CGFloat = 0, + @ViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.init( + data: data, + id: \.id, + columns: columns, + spacing: spacing, + content: content + ) + } +} diff --git a/Shared/Components/ConditionalMenu.swift b/Shared/Components/ConditionalMenu.swift new file mode 100644 index 00000000..c683d03d --- /dev/null +++ b/Shared/Components/ConditionalMenu.swift @@ -0,0 +1,63 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: Figure out workaround with extra padding from `Menu` + +struct ConditionalMenu: View { + + private let action: () -> Void + private let isMenu: Bool + private let label: () -> Label + private let menuContent: () -> MenuContent + + var body: some View { + if isMenu { + Menu( + content: menuContent, + label: label + ) + } else { + Button( + action: action, + label: label + ) + } + } +} + +extension ConditionalMenu { + + init( + tracking data: V?, + action: @escaping (V) -> Void, + @ViewBuilder menuContent: @escaping () -> MenuContent, + @ViewBuilder label: @escaping () -> Label + ) where V: Identifiable { + self.action = { + guard let data else { return } + action(data) + } + self.isMenu = data == nil + self.label = label + self.menuContent = menuContent + } + + init( + isMenu: Bool, + action: @escaping () -> Void, + @ViewBuilder menuContent: @escaping () -> MenuContent, + @ViewBuilder label: @escaping () -> Label + ) { + self.action = action + self.isMenu = isMenu + self.label = label + self.menuContent = menuContent + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift index 5a6225b4..1e24968a 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift @@ -9,6 +9,7 @@ import SwiftUI // TODO: both axes +// TODO: add scrollClipDisabled() to iOS when iOS 15 dropped struct ScrollIfLargerThanContainerModifier: ViewModifier { @@ -28,6 +29,9 @@ struct ScrollIfLargerThanContainerModifier: ViewModifier { content .trackingSize($contentSize) } + #if os(tvOS) + .scrollClipDisabled() + #endif .frame(maxHeight: contentSize.height >= layoutSize.height ? .infinity : contentSize.height) .backport .scrollDisabled(contentSize.height < layoutSize.height) diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 51aa1175..e8f048ce 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -251,6 +251,16 @@ extension View { opacity(isVisible ? 1 : 0) } + @inlinable + @ViewBuilder + func hidden(_ isHidden: Bool) -> some View { + if isHidden { + hidden() + } else { + self + } + } + func blurred(style: UIBlurEffect.Style = .regular) -> some View { overlay { BlurView(style: style) diff --git a/Shared/Objects/SelectUserServerSelection.swift b/Shared/Objects/SelectUserServerSelection.swift index ded2602b..083d2fbb 100644 --- a/Shared/Objects/SelectUserServerSelection.swift +++ b/Shared/Objects/SelectUserServerSelection.swift @@ -7,9 +7,8 @@ // import Defaults -import Foundation -enum SelectUserServerSelection: RawRepresentable, Codable, Defaults.Serializable, Equatable, Hashable { +enum SelectUserServerSelection: RawRepresentable, Hashable, Storable { case all case server(id: String) @@ -31,4 +30,13 @@ enum SelectUserServerSelection: RawRepresentable, Codable, Defaults.Serializable self = .server(id: rawValue) } } + + func server(from servers: S) -> ServerState? where S.Element == ServerState { + switch self { + case .all: + return nil + case let .server(id): + return servers.first { $0.id == id } + } + } } diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift new file mode 100644 index 00000000..f43aac44 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift @@ -0,0 +1,67 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserBottomButton: View { + + // MARK: Properties + + private let action: (ServerState) -> Void + private let selectedServer: ServerState? + private let servers: OrderedSet + + // MARK: View Builders + + @ViewBuilder + private var label: some View { + Label(L10n.addUser, systemImage: "plus") + .foregroundStyle(Color.primary) + .font(.body.weight(.semibold)) + .labelStyle(.iconOnly) + .frame(width: 50, height: 50) + } + + // MARK: - Initializer + + init( + selectedServer: ServerState?, + servers: OrderedSet, + action: @escaping (ServerState) -> Void + ) { + self.action = action + self.selectedServer = selectedServer + self.servers = servers + } + + // MARK: Body + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift deleted file mode 100644 index 48878ba4..00000000 --- a/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift +++ /dev/null @@ -1,101 +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) 2025 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 - } - - @ViewBuilder - private var content: some View { - ZStack { - Color.secondarySystemFill - - RelativeSystemImageView(systemName: "plus") - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - .hoverEffect(.highlight) - - Text(L10n.addUser) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(isEnabled ? .primary : .secondary) - - if serverSelection == .all { - // For layout, not to be localized - Text("Hidden") - .font(.footnote) - .hidden() - } - } - - var body: some View { - if serverSelection == .all { - Menu { - Text(L10n.selectServer) - - ForEach(servers) { server in - Button { - action(server) - } label: { - Text(server.name) - Text(server.currentURL.absoluteString) - } - } - } label: { - content - } - .buttonStyle(.borderless) - .buttonBorderShape(.circle) - } else { - Button { - if let selectedServer { - action(selectedServer) - } - } label: { - content - } - .buttonStyle(.borderless) - .buttonBorderShape(.circle) - } - } - } -} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/AddUserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserGridButton.swift new file mode 100644 index 00000000..ca99cf75 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserGridButton.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) 2025 Jellyfin & Jellyfin Contributors +// + +import OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserGridButton: View { + + @Environment(\.isEnabled) + private var isEnabled + + let selectedServer: ServerState? + let servers: OrderedSet + let action: (ServerState) -> Void + + @ViewBuilder + private var label: some View { + ZStack { + Color.secondarySystemFill + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + .hoverEffect(.highlight) + + Text(L10n.addUser) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + + if selectedServer == nil { + // For layout, not to be localized + Text("Hidden") + .font(.footnote) + .hidden() + } + } + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + .buttonStyle(.borderless) + .buttonBorderShape(.circle) + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift b/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift index b7d39b98..b0ba5509 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift @@ -6,6 +6,7 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // +import OrderedCollections import SwiftUI extension SelectUserView { @@ -23,17 +24,38 @@ extension SelectUserView { @Binding private var serverSelection: SelectUserServerSelection - @ObservedObject - private var viewModel: SelectUserViewModel - // MARK: - Variables private let areUsersSelected: Bool - private let userCount: Int + private let hasUsers: Bool + private let selectedServer: ServerState? + private let servers: OrderedSet private let onDelete: () -> Void private let toggleAllUsersSelected: () -> Void + // MARK: - Initializer + + init( + isEditing: Binding, + serverSelection: Binding, + selectedServer: ServerState?, + servers: OrderedSet, + areUsersSelected: Bool, + hasUsers: Bool, + onDelete: @escaping () -> Void, + toggleAllUsersSelected: @escaping () -> Void + ) { + self._isEditing = isEditing + self._serverSelection = serverSelection + self.areUsersSelected = areUsersSelected + self.hasUsers = hasUsers + self.selectedServer = selectedServer + self.servers = servers + self.onDelete = onDelete + self.toggleAllUsersSelected = toggleAllUsersSelected + } + // MARK: - Advanced Menu @ViewBuilder @@ -74,39 +96,22 @@ extension SelectUserView { // MARK: - Delete User Button + @ViewBuilder private var deleteUsersButton: some View { - ListRowButton(L10n.delete, role: .destructive) { - onDelete() - } + ListRowButton( + L10n.delete, + role: .destructive, + action: onDelete + ) .frame(width: 400, height: 75) .disabled(!areUsersSelected) } - // MARK: - Initializer - - init( - isEditing: Binding, - serverSelection: Binding, - areUsersSelected: Bool, - viewModel: SelectUserViewModel, - userCount: Int, - onDelete: @escaping () -> Void, - toggleAllUsersSelected: @escaping () -> Void - ) { - self._isEditing = isEditing - self._serverSelection = serverSelection - self.viewModel = viewModel - self.areUsersSelected = areUsersSelected - self.userCount = userCount - self.onDelete = onDelete - self.toggleAllUsersSelected = toggleAllUsersSelected - } - // MARK: - Content View @ViewBuilder private var contentView: some View { - HStack(alignment: .center) { + HStack(alignment: .top, spacing: 20) { if isEditing { deleteUsersButton @@ -130,9 +135,18 @@ extension SelectUserView { .clipShape(RoundedRectangle(cornerRadius: 10)) } } else { + AddUserBottomButton( + selectedServer: selectedServer, + servers: servers + ) { server in + router.route(to: \.userSignIn, server) + } + .hidden(!hasUsers) + ServerSelectionMenu( selection: $serverSelection, - viewModel: viewModel + selectedServer: selectedServer, + servers: servers ) advancedMenu diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift index d0fe4a7a..2821d8bf 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -6,6 +6,7 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // +import OrderedCollections import SwiftUI extension SelectUserView { @@ -17,32 +18,46 @@ extension SelectUserView { @EnvironmentObject private var router: SelectUserCoordinator.Router - @ObservedObject - private var viewModel: SelectUserViewModel - // MARK: - Server Selection @Binding private var serverSelection: SelectUserServerSelection - 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 let selectedServer: ServerState? + private let servers: OrderedSet // MARK: - Initializer init( selection: Binding, - viewModel: SelectUserViewModel + selectedServer: ServerState?, + servers: OrderedSet ) { self._serverSelection = selection - self.viewModel = viewModel + self.selectedServer = selectedServer + self.servers = servers + } + + @ViewBuilder + private var label: some View { + HStack(spacing: 16) { + if let selectedServer { + Image(systemName: "server.rack") + + Text(selectedServer.name) + } else { + Image(systemName: "person.2.fill") + + Text(L10n.allServers) + } + + Image(systemName: "chevron.up.chevron.down") + .foregroundStyle(.secondary) + .font(.subheadline.weight(.semibold)) + } + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + .frame(width: 400, height: 50) } // MARK: - Body @@ -50,14 +65,15 @@ extension SelectUserView { var body: some View { Menu { Picker(L10n.servers, selection: _serverSelection) { - ForEach(viewModel.servers.keys) { server in + ForEach(servers) { server in Button { Text(server.name) Text(server.currentURL.absoluteString) } .tag(SelectUserServerSelection.server(id: server.id)) } - if viewModel.servers.keys.count > 1 { + + if servers.count > 1 { Label(L10n.allServers, systemImage: "person.2.fill") .tag(SelectUserServerSelection.all) } @@ -68,29 +84,13 @@ extension SelectUserView { router.route(to: \.editServer, selectedServer) } } + Button(L10n.addServer, systemImage: "plus") { router.route(to: \.connectToServer) } } } label: { - HStack(spacing: 16) { - switch serverSelection { - case .all: - Image(systemName: "person.2.fill") - Text(L10n.allServers) - case let .server(id): - if let server = viewModel.servers.keys.first(where: { $0.id == id }) { - Image(systemName: "server.rack") - Text(server.name) - } - } - Image(systemName: "chevron.up.chevron.down") - .foregroundStyle(.secondary) - .font(.subheadline.weight(.semibold)) - } - .font(.body.weight(.semibold)) - .foregroundStyle(Color.primary) - .frame(width: 400, height: 50) + label } .menuOrder(.fixed) } diff --git a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift index 2ed0dc83..0349549b 100644 --- a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift @@ -19,70 +19,38 @@ struct SelectUserView: View { // MARK: - User Grid Item Enum - private enum UserGridItem: Hashable { - case user(UserState, server: ServerState) - case addUser - } + typealias UserGridItem = (user: UserState, server: ServerState) // MARK: - Defaults - @Default(.selectUserServerSelection) - private var serverSelection @Default(.selectUserUseSplashscreen) private var selectUserUseSplashscreen - - // MARK: - Environment Variable - - @Environment(\.colorScheme) - private var colorScheme + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + @Default(.selectUserServerSelection) + private var serverSelection // MARK: - State & Environment Objects @EnvironmentObject private var router: SelectUserCoordinator.Router - @StateObject - private var viewModel = SelectUserViewModel() - // MARK: - Select User Variables - @State - private var contentSize: CGSize = .zero - @State - private var gridItems: OrderedSet = [] - @State - private var gridItemSize: CGSize = .zero @State private var isEditingUsers: Bool = false @State - private var padGridItemColumnCount: Int = 1 - @State private var pin: String = "" @State private var scrollViewOffset: CGFloat = 0 @State private var selectedUsers: Set = [] - @State - private var splashScreenImageSource: ImageSource? = nil - - private var users: [UserState] { - gridItems.compactMap { item in - switch item { - case let .user(user, _): - return user - default: - return nil - } - } - } // MARK: - Dialog States @State private var isPresentingConfirmDeleteUsers = false @State - private var isPresentingServers: Bool = false - @State private var isPresentingLocalPin: Bool = false // MARK: - Error State @@ -90,136 +58,89 @@ struct SelectUserView: View { @State private var error: Error? = nil - // MARK: - Selected Server + @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 + serverSelection.server(from: viewModel.servers.keys) } - // MARK: - Make Grid Items + private var splashScreenImageSources: [ImageSource] { + switch (serverSelection, selectUserAllServersSplashscreen) { + case (.all, .all): + return viewModel + .servers + .keys + .shuffled() + .map { $0.splashScreenImageSource() } - private func makeGridItems(for serverSelection: SelectUserServerSelection) -> OrderedSet { + // need to evaluate server with id selection first + case let (.server(id), _), let (.all, .server(id)): + guard let imageSource = viewModel + .servers + .keys + .first(where: { $0.id == id }) else { return [] } + + return [imageSource.splashScreenImageSource()] + } + } + + private var userGridItems: [UserGridItem] { switch serverSelection { case .all: - let items = viewModel.servers + return 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) + .map { UserGridItem(user: $0.user, server: $0.server) } 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] + return [] } - let items = viewModel.servers[server]! + return viewModel.servers[server]! .sorted(using: \.username) - .map { UserGridItem.user($0, server: server) } - .appending(.addUser) - - return OrderedSet(items) + .map { UserGridItem(user: $0, server: server) } } } - // MARK: - Make Splash Screen Image Source - - // 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 Item Offset - - 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 - } - // MARK: - Select User(s) private func select(user: UserState, needsPin: Bool = true) { - Task { @MainActor in - selectedUsers.insert(user) + selectedUsers.insert(user) - switch user.accessPolicy { - case .requireDeviceAuthentication: - // Do nothing, no device authentication on tvOS - break - case .requirePin: - if needsPin { - isPresentingLocalPin = true - return - } - case .none: () + switch user.accessPolicy { + case .requireDeviceAuthentication: + // Do nothing, no device authentication on tvOS + break + case .requirePin: + if needsPin { + isPresentingLocalPin = true + return } - - viewModel.send(.signIn(user, pin: pin)) + case .none: () } + + viewModel.send(.signIn(user, pin: pin)) } // MARK: - Grid Content View @ViewBuilder - private var gridContentView: some View { - let columns = Array(repeating: GridItem(.flexible(), spacing: EdgeInsets.edgePadding), count: 5) + private var userGrid: some View { + CenteredLazyVGrid( + data: userGridItems, + id: \.user.id, + columns: 5, + spacing: EdgeInsets.edgePadding + ) { gridItem in + let user = gridItem.user + let server = gridItem.server - 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 - } - } - - // MARK: - Grid Content View - - @ViewBuilder - private func gridItemView(for item: UserGridItem) -> some View { - switch item { - case let .user(user, server): UserGridButton( user: user, server: server, @@ -234,35 +155,46 @@ struct SelectUserView: View { selectedUsers.insert(user) isPresentingConfirmDeleteUsers = true } - .environment(\.isEditing, isEditingUsers) - .environment(\.isSelected, selectedUsers.contains(user)) - case .addUser: - AddUserButton( - serverSelection: $serverSelection, + } + } + + @ViewBuilder + private var addUserButtonGrid: some View { + CenteredLazyVGrid( + data: [0], + id: \.self, + columns: 5 + ) { _ in + AddUserGridButton( + selectedServer: selectedServer, servers: viewModel.servers.keys ) { server in router.route(to: \.userSignIn, server) } - .environment(\.isEnabled, !isEditingUsers) } } // MARK: - User View @ViewBuilder - private var userView: some View { + private var contentView: some View { VStack { ZStack { Color.clear - .trackingSize($contentSize) VStack(spacing: 0) { Color.clear .frame(height: 100) - gridContentView - .focusSection() + Group { + if userGridItems.isEmpty { + addUserButtonGrid + } else { + userGrid + } + } + .focusSection() } .scrollIfLargerThanContainer(padding: 100) .scrollViewOffset($scrollViewOffset) @@ -271,30 +203,31 @@ struct SelectUserView: View { SelectUserBottomBar( isEditing: $isEditingUsers, serverSelection: $serverSelection, + selectedServer: selectedServer, + servers: viewModel.servers.keys, areUsersSelected: selectedUsers.isNotEmpty, - viewModel: viewModel, - userCount: gridItems.count, - onDelete: { - isPresentingConfirmDeleteUsers = true - } + hasUsers: userGridItems.isNotEmpty ) { - if selectedUsers.count == users.count { + isPresentingConfirmDeleteUsers = true + } toggleAllUsersSelected: { + if selectedUsers.isNotEmpty { selectedUsers.removeAll() } else { - selectedUsers.insert(contentsOf: users) + selectedUsers.insert(contentsOf: userGridItems.map(\.user)) } } .focusSection() } .animation(.linear(duration: 0.1), value: scrollViewOffset) .background { - if let splashScreenImageSource, selectUserUseSplashscreen { + if selectUserUseSplashscreen, splashScreenImageSources.isNotEmpty { ZStack { - ImageView(splashScreenImageSource) + ImageView(splashScreenImageSources) + .pipeline(.Swiftfin.local) .aspectRatio(contentMode: .fill) - .id(splashScreenImageSource) + .id(splashScreenImageSources) .transition(.opacity) - .animation(.linear, value: splashScreenImageSource) + .animation(.linear, value: splashScreenImageSources) Color.black .opacity(0.9) @@ -339,25 +272,6 @@ struct SelectUserView: View { serverSelection = .all } } - - setSplashScreenImageSource() - } - - // MARK: - Did Appear - - private func didAppear() { - viewModel.send(.getServers) - - setSplashScreenImageSource() - } - - // MARK: - Set Splash Screen Image Source - - private func setSplashScreenImageSource() { - splashScreenImageSource = makeSplashScreenImageSource( - serverSelection: serverSelection, - allServersSelection: .all - ) } // MARK: - Body @@ -367,38 +281,25 @@ struct SelectUserView: View { if viewModel.servers.isEmpty { emptyView } else { - userView + contentView } } .ignoresSafeArea() .navigationBarBranding() .onAppear { - didAppear() + viewModel.send(.getServers) } - .onChange(of: isEditingUsers) { _, newValue in - guard !newValue else { return } + .onChange(of: isEditingUsers) { + guard !isEditingUsers else { return } selectedUsers.removeAll() } - .onChange(of: serverSelection) { _, newValue in - gridItems = makeGridItems(for: newValue) - - setSplashScreenImageSource() - } - .onChange(of: isPresentingLocalPin) { _, newValue in - if newValue { + .onChange(of: isPresentingLocalPin) { + if isPresentingLocalPin { pin = "" } else { selectedUsers.removeAll() } } - .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): diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 3243e41f..396690f6 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; + 21951AC22D9D2010002E03E0 /* AddUserBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21951AC12D9D2010002E03E0 /* AddUserBottomButton.swift */; }; + 21BCDEF72D9C822000E1D180 /* AddUserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BCDEF62D9C822000E1D180 /* AddUserGridButton.swift */; }; 4E01446C2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; 4E01446D2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; 4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; }; @@ -607,6 +609,10 @@ E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1194F4D2BEABA9100888DB6 /* NavigationBarCloseButton.swift */; }; E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1194F4F2BEB1E3000888DB6 /* StoredValues+Temp.swift */; }; E119696A2CC99EA9001A58BE /* ServerTaskProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11969692CC99EA9001A58BE /* ServerTaskProgressSection.swift */; }; + E11982BA2DA04F9B0008FC3F /* CenteredLazyVGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11982B92DA04F9B0008FC3F /* CenteredLazyVGrid.swift */; }; + E11982BB2DA05FF50008FC3F /* CenteredLazyVGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11982B92DA04F9B0008FC3F /* CenteredLazyVGrid.swift */; }; + E11982D72DA0E8240008FC3F /* ConditionalMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11982D62DA0E8240008FC3F /* ConditionalMenu.swift */; }; + E11982D82DA0E8240008FC3F /* ConditionalMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11982D62DA0E8240008FC3F /* ConditionalMenu.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 */; }; @@ -874,7 +880,6 @@ E17639F82BF2E25B004DF6AB /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41A92BF077130082B8B2 /* Keychain.swift */; }; E1763A252BF2F77B004DF6AB /* ScrollIfLargerThanContainerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB472BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.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 */; }; E1763A6A2BF3D177004DF6AB /* PublicUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A692BF3D177004DF6AB /* PublicUserButton.swift */; }; @@ -1281,6 +1286,8 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; 12C80CEDC871A21D98141BBE /* VideoRangeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRangeType.swift; sourceTree = ""; }; + 21951AC12D9D2010002E03E0 /* AddUserBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserBottomButton.swift; sourceTree = ""; }; + 21BCDEF62D9C822000E1D180 /* AddUserGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserGridButton.swift; sourceTree = ""; }; 4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; @@ -1740,6 +1747,8 @@ 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 = ""; }; E11969692CC99EA9001A58BE /* ServerTaskProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskProgressSection.swift; sourceTree = ""; }; + E11982B92DA04F9B0008FC3F /* CenteredLazyVGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenteredLazyVGrid.swift; sourceTree = ""; }; + E11982D62DA0E8240008FC3F /* ConditionalMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalMenu.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 = ""; }; @@ -1876,7 +1885,6 @@ 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 = ""; }; E1763A692BF3D177004DF6AB /* PublicUserButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicUserButton.swift; sourceTree = ""; }; @@ -4688,9 +4696,10 @@ E164A8132BE4995800A54B18 /* Components */ = { isa = PBXGroup; children = ( - E1763A282BF3046A004DF6AB /* AddUserButton.swift */, - E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, + 21951AC12D9D2010002E03E0 /* AddUserBottomButton.swift */, + 21BCDEF62D9C822000E1D180 /* AddUserGridButton.swift */, BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */, + E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, ); path = Components; @@ -5122,10 +5131,13 @@ children = ( E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */, E104DC952B9E7E29008F506D /* AssertionFailureView.swift */, + B65CB977628965AA9099742F /* AttributeBadge.swift */, E18E0203288749200022598C /* BlurView.swift */, E145EB212BDCCA43003BF6F3 /* BulletedList.swift */, + E11982B92DA04F9B0008FC3F /* CenteredLazyVGrid.swift */, 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */, E1A1528728FD229500600579 /* ChevronButton.swift */, + E11982D62DA0E8240008FC3F /* ConditionalMenu.swift */, E1153DCB2BBB633B00424D36 /* FastSVGView.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */, @@ -5145,7 +5157,6 @@ 4E7315722D14752400EA2A95 /* UserProfileImage */, E1BE1CED2BDB68CD008176A9 /* UserProfileRow.swift */, E1B5784028F8AFCB00D42911 /* WrappedView.swift */, - B65CB977628965AA9099742F /* AttributeBadge.swift */, ); path = Components; sourceTree = ""; @@ -5954,6 +5965,7 @@ files = ( E1AEFA392BE36C4C00CFAFD8 /* SwiftfinStore+ServerState.swift in Sources */, E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */, + 21BCDEF72D9C822000E1D180 /* AddUserGridButton.swift in Sources */, E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E145EB262BE055AD003BF6F3 /* ServerResponse.swift in Sources */, @@ -5966,6 +5978,7 @@ 4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */, 4EBE06472C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */, 4E5EE5532D67CFAB00982290 /* ImageCard.swift in Sources */, + E11982D82DA0E8240008FC3F /* ConditionalMenu.swift in Sources */, 4E661A2B2CEFE6F400025C99 /* Video3DFormat.swift in Sources */, E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, @@ -6015,7 +6028,6 @@ E1575EA1293E7B1E001665B1 /* String.swift in Sources */, 4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, - E1763A292BF3046A004DF6AB /* AddUserButton.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, 4E661A2F2CEFE77700025C99 /* MetadataField.swift in Sources */, @@ -6072,6 +6084,7 @@ BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, + E11982BB2DA05FF50008FC3F /* CenteredLazyVGrid.swift in Sources */, C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */, E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */, @@ -6186,6 +6199,7 @@ E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, + 21951AC22D9D2010002E03E0 /* AddUserBottomButton.swift in Sources */, E146A9DC2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */, 4E5D3EC82D8920AF003E2772 /* TrailerMenu.swift in Sources */, E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */, @@ -6438,6 +6452,7 @@ 4E661A232CEFE61000025C99 /* ParentalRatingsViewModel.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, 4E10C8112CC030CD0012CC9F /* DeviceDetailsView.swift in Sources */, + E11982D72DA0E8240008FC3F /* ConditionalMenu.swift in Sources */, E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */, 4E49DEE42CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */, @@ -6989,6 +7004,7 @@ E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, 4E1A39342D56C84200BAC1C7 /* ItemViewAttributes.swift in Sources */, 4EC1C8522C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */, + E11982BA2DA04F9B0008FC3F /* CenteredLazyVGrid.swift in Sources */, 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */, DFB7C3DF2C7AA43A00CE7CDC /* UserSignInState.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */, diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 00548809..148133fb 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -259,7 +259,7 @@ "location" : "https://github.com/LePips/VLCUI", "state" : { "branch" : "main", - "revision" : "50d4f6ec05a2d8333952def0d8e45019a4207132" + "revision" : "9e0285b13c666aace61835e8a512512edee822a6" } }, {