From 859a47803f4d47cedf6e8a58f5b1d5cb03d90307 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Wed, 7 Sep 2022 23:52:19 -0600 Subject: [PATCH] tvOS - Revamp Connect Flow (#563) --- .../MainCoordinator/tvOSMainCoordinator.swift | 10 +- Shared/SwiftfinStore/SwiftfinStore.swift | 6 +- .../ViewModels/ConnectToServerViewModel.swift | 25 +- Shared/ViewModels/UserListViewModel.swift | 2 + .../App/JellyfinPlayer_tvOSApp.swift | 9 - .../CinematicNextUpCardView.swift | 2 +- .../CinematicResumeCardView.swift | 2 +- Swiftfin tvOS/Components/PosterButton.swift | 14 +- Swiftfin tvOS/Components/ServerButton.swift | 53 ++++ .../Components/UserProfileButton.swift | 57 +++++ Swiftfin tvOS/Views/ConnectToServerView.swift | 114 ++++++--- .../ContinueWatchingCard.swift | 2 +- .../Components/AboutView/AboutViewCard.swift | 2 +- .../Components/ActionButtonHStack.swift | 4 +- .../ItemView/Components/PlayButton.swift | 2 +- .../Components/EpisodeCard.swift | 4 +- .../Components/SeriesEpisodesView.swift | 2 +- Swiftfin tvOS/Views/LatestInLibraryView.swift | 2 +- Swiftfin tvOS/Views/ServerListView.swift | 103 +++----- Swiftfin tvOS/Views/UserListView.swift | 127 +++++----- Swiftfin tvOS/Views/UserSignInView.swift | 234 ++++++++++++------ .../Overlays/SmallMenuOverlay.swift | 10 +- Swiftfin.xcodeproj/project.pbxproj | 12 +- Swiftfin/Views/ConnectToServerView.swift | 22 +- .../Views/ItemView/Components/AboutView.swift | 2 +- .../Components/ActionButtonHStack.swift | 4 +- 26 files changed, 524 insertions(+), 302 deletions(-) create mode 100644 Swiftfin tvOS/Components/ServerButton.swift create mode 100644 Swiftfin tvOS/Components/UserProfileButton.swift diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 9406f69d..7cca3e82 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -21,14 +21,6 @@ final class MainCoordinator: NavigationCoordinatable { @Root var liveTV = makeLiveTV - @ViewBuilder - func customize(_ view: AnyView) -> some View { - view.background { - Color.black - .ignoresSafeArea() - } - } - init() { if SessionManager.main.currentLogin != nil { self.stack = NavigationStack(initial: \MainCoordinator.mainTab) @@ -39,6 +31,8 @@ final class MainCoordinator: NavigationCoordinatable { ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk + UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] + // Notification setup for state Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) diff --git a/Shared/SwiftfinStore/SwiftfinStore.swift b/Shared/SwiftfinStore/SwiftfinStore.swift index 16f78ad4..f0519a1f 100644 --- a/Shared/SwiftfinStore/SwiftfinStore.swift +++ b/Shared/SwiftfinStore/SwiftfinStore.swift @@ -18,7 +18,7 @@ enum SwiftfinStore { // Relationships are represented by the related object's IDs or value enum State { - struct Server { + struct Server: Hashable, Identifiable { let uris: Set let currentURI: String let name: String @@ -27,7 +27,7 @@ enum SwiftfinStore { let version: String let userIDs: [String] - fileprivate init( + init( uris: Set, currentURI: String, name: String, @@ -58,7 +58,7 @@ enum SwiftfinStore { } } - struct User { + struct User: Hashable, Identifiable { let username: String let id: String let serverID: String diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 50cc670c..5315f28f 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -26,7 +26,7 @@ final class ConnectToServerViewModel: ViewModel { @RouterObject var router: ConnectToServerCoodinator.Router? @Published - var discoveredServers: Set = [] + var discoveredServers: [SwiftfinStore.State.Server] = [] @Published var searching = false @Published @@ -102,15 +102,26 @@ final class ConnectToServerViewModel: ViewModel { discoveredServers.removeAll() searching = true + var _discoveredServers: Set = [] + + discovery.locateServer { server in + if let server = server { + _discoveredServers.insert(.init( + uris: [], + currentURI: server.url.absoluteString, + name: server.name, + id: server.id, + os: "", + version: "", + usersIDs: [] + )) + } + } + // Timeout after 3 seconds DispatchQueue.main.asyncAfter(deadline: .now() + 3) { self.searching = false - } - - discovery.locateServer { [self] server in - if let server = server { - discoveredServers.insert(server) - } + self.discoveredServers = _discoveredServers.sorted(by: { $0.name < $1.name }) } } diff --git a/Shared/ViewModels/UserListViewModel.swift b/Shared/ViewModels/UserListViewModel.swift index d60da2aa..fe10f75f 100644 --- a/Shared/ViewModels/UserListViewModel.swift +++ b/Shared/ViewModels/UserListViewModel.swift @@ -7,6 +7,7 @@ // import Foundation +import JellyfinAPI import SwiftUI class UserListViewModel: ViewModel { @@ -21,6 +22,7 @@ class UserListViewModel: ViewModel { super.init() + JellyfinAPIAPI.basePath = server.currentURI Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeCurrentLoginURI(_:))) } diff --git a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift b/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift index 398721b2..7e091939 100644 --- a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift +++ b/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift @@ -15,15 +15,6 @@ struct JellyfinPlayer_tvOSApp: App { var body: some Scene { WindowGroup { MainCoordinator().view() - .onAppear { - JellyfinPlayer_tvOSApp.setupAppearance() - } } } - - static func setupAppearance() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - windowScene?.windows.first?.overrideUserInterfaceStyle = .dark - } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift index 1da14e2d..42033435 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift @@ -62,7 +62,7 @@ struct CinematicNextUpCardView: View { } .frame(width: 350, height: 210) } - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) .padding(.top) } .padding(.vertical) diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 4ab66426..17b894b8 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -63,7 +63,7 @@ struct CinematicResumeCardView: View { } .frame(width: 350, height: 210) } - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) .padding(.top) .contextMenu { Button(role: .destructive) { diff --git a/Swiftfin tvOS/Components/PosterButton.swift b/Swiftfin tvOS/Components/PosterButton.swift index bf3290d6..80144339 100644 --- a/Swiftfin tvOS/Components/PosterButton.swift +++ b/Swiftfin tvOS/Components/PosterButton.swift @@ -8,11 +8,6 @@ import SwiftUI -enum PosterButtonWidth { - static let landscape = 490.0 - static let portrait = 250.0 -} - struct PosterButton: View { private let item: Item @@ -26,12 +21,7 @@ struct PosterButton Void + + var body: some View { + Button { + onSelect() + } label: { + HStack { + Image(systemName: "server.rack") + .font(.system(size: 72)) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) + + Text(server.currentURI) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) + } + + Spacer() + } + } + } +} + +extension ServerButton { + init(server: SwiftfinStore.State.Server) { + self.server = server + self.onSelect = {} + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + var copy = self + copy.onSelect = action + return copy + } +} diff --git a/Swiftfin tvOS/Components/UserProfileButton.swift b/Swiftfin tvOS/Components/UserProfileButton.swift new file mode 100644 index 00000000..daef445d --- /dev/null +++ b/Swiftfin tvOS/Components/UserProfileButton.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) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct UserProfileButton: View { + + @FocusState + private var isFocused: Bool + + let user: UserDto + private var action: () -> Void + + init(user: UserDto) { + self.user = user + self.action = {} + } + + init(user: SwiftfinStore.State.User) { + self.init(user: .init(name: user.username, id: user.id)) + } + + var body: some View { + VStack(alignment: .center) { + Button { + action() + } label: { + ImageView(user.profileImageSource(maxWidth: 250, maxHeight: 250)) + .failure { + Image(systemName: "person.fill") + .resizable() + .padding2() + } + .frame(width: 200, height: 200) + } + .buttonStyle(.card) + .focused($isFocused) + + Text(user.name ?? .emptyDash) + .foregroundColor(isFocused ? .primary : .secondary) + } + } +} + +extension UserProfileButton { + func onSelect(_ action: @escaping () -> Void) -> Self { + var copy = self + copy.action = action + return copy + } +} diff --git a/Swiftfin tvOS/Views/ConnectToServerView.swift b/Swiftfin tvOS/Views/ConnectToServerView.swift index 20080245..7d1e8925 100644 --- a/Swiftfin tvOS/Views/ConnectToServerView.swift +++ b/Swiftfin tvOS/Views/ConnectToServerView.swift @@ -12,16 +12,17 @@ import SwiftUI struct ConnectToServerView: View { - @StateObject + @ObservedObject var viewModel: ConnectToServerViewModel @State - var uri = "" + private var uri = "" @Default(.defaultHTTPScheme) - var defaultHTTPScheme + private var defaultHTTPScheme - var body: some View { - List { + @ViewBuilder + private var connectForm: some View { + VStack(alignment: .leading) { Section { TextField(L10n.serverURL, text: $uri) .disableAutocorrection(true) @@ -37,43 +38,94 @@ struct ConnectToServerView: View { viewModel.connectToServer(uri: uri) } label: { HStack { - L10n.connect.text - Spacer() if viewModel.isLoading { ProgressView() } + + L10n.connect.text + .bold() + .font(.callout) } + .frame(height: 75) + .frame(maxWidth: .infinity) + .background(viewModel.isLoading || uri.isEmpty ? .secondary : Color.jellyfinPurple) } .disabled(viewModel.isLoading || uri.isEmpty) + .buttonStyle(.plain) } header: { L10n.connectToJellyfinServer.text } + } + } - Section(header: L10n.localServers.text) { - if viewModel.searching { - ProgressView() + @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 localServers: some View { + VStack(alignment: .center) { + + HStack { + L10n.localServers.text + .font(.title3) + .fontWeight(.semibold) + + SFSymbolButton(systemName: "arrow.clockwise") { + viewModel.discoverServers() } - ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in - Button(action: { - viewModel.connectToServer(uri: discoveredServer.url.absoluteString) - }, label: { - HStack { - Text(discoveredServer.name) - .font(.headline) - Text("• \(discoveredServer.host)") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if viewModel.isLoading { - ProgressView() + .frame(width: 30, height: 30) + .disabled(viewModel.searching || viewModel.isLoading) + } + + if viewModel.searching { + searchingDiscoverServers + .frame(maxHeight: .infinity) + } else { + if viewModel.discoveredServers.isEmpty { + noLocalServersFound + .frame(maxHeight: .infinity) + } else { + ScrollView { + LazyVStack { + ForEach(viewModel.discoveredServers, id: \.self) { server in + ServerButton(server: server) + .onSelect { + viewModel.connectToServer(uri: server.currentURI) + } } } - - }) + .padding() + } } } - .onAppear(perform: self.viewModel.discoverServers) - .headerProminence(.increased) + } + } + + var body: some View { + HStack(alignment: .top) { + connectForm + .frame(maxWidth: .infinity) + + localServers + .frame(maxWidth: .infinity) + } + .navigationTitle(L10n.connect.text) + .onAppear { + viewModel.discoverServers() } .alert(item: $viewModel.errorMessage) { _ in Alert( @@ -82,6 +134,12 @@ struct ConnectToServerView: View { dismissButton: .cancel() ) } - .navigationTitle(L10n.connect) + .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/ContinueWatchingView/ContinueWatchingCard.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift index 712c2c03..4567d09a 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift @@ -58,7 +58,7 @@ struct ContinueWatchingCard: View { } .frame(width: 500, height: 281.25) } - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) .padding(.top) VStack(alignment: .leading) { diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift index 82e58290..55107cf9 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift @@ -37,7 +37,7 @@ extension ItemView.AboutView { .padding2() .frame(width: 700, height: 405) } - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift index 553ffd27..17a607c6 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift @@ -32,7 +32,7 @@ extension ItemView { .frame(height: 100) .frame(maxWidth: .infinity) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) Button { viewModel.toggleFavoriteState() @@ -49,7 +49,7 @@ extension ItemView { .frame(height: 100) .frame(maxWidth: .infinity) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift index a7cf99f5..781fe873 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift @@ -47,7 +47,7 @@ extension ItemView { .cornerRadius(10) } .focused($isFocused) - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) .contextMenu { if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { Button { diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift index cb5e7d3a..82caae98 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift @@ -38,7 +38,7 @@ struct EpisodeCard: View { } .frame(width: 550, height: 308) } - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) Button { router.route(to: \.item, episode) @@ -79,7 +79,7 @@ struct EpisodeCard: View { .frame(width: 510, height: 220) .padding() } - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) } } } diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift index 39470ea1..04ab72c2 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift @@ -59,7 +59,7 @@ extension SeriesEpisodesView { .foregroundColor(.black) } } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .id(season) .focused($focusedSeason, equals: season) } diff --git a/Swiftfin tvOS/Views/LatestInLibraryView.swift b/Swiftfin tvOS/Views/LatestInLibraryView.swift index 46dfe417..188dfccd 100644 --- a/Swiftfin tvOS/Views/LatestInLibraryView.swift +++ b/Swiftfin tvOS/Views/LatestInLibraryView.swift @@ -36,7 +36,7 @@ struct LatestInLibraryView: View { } .posterStyle(type: .portrait, width: 250) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) } .onSelect { item in router.route(to: \.item, item) diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift index 71b23731..5072bdf7 100644 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ b/Swiftfin tvOS/Views/ServerListView.swift @@ -6,55 +6,32 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // -import CoreStore +import CollectionView import SwiftUI struct ServerListView: View { @EnvironmentObject - private var serverListRouter: ServerListCoordinator.Router + private var router: ServerListCoordinator.Router @ObservedObject var viewModel: ServerListViewModel + @State + private var longPressedServer: SwiftfinStore.State.Server? + @ViewBuilder private var listView: some View { ScrollView { LazyVStack { ForEach(viewModel.servers, id: \.id) { server in - Button { - serverListRouter.route(to: \.userList, server) - } label: { - HStack { - Image(systemName: "server.rack") - .font(.system(size: 72)) - .foregroundColor(.primary) - - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title2) - .foregroundColor(.primary) - - Text(server.currentURI) - .font(.footnote) - .disabled(true) - .foregroundColor(.secondary) - - Text(viewModel.userTextFor(server: server)) - .font(.footnote) - .foregroundColor(.primary) - } - - Spacer() + ServerButton(server: server) + .onSelect { + router.route(to: \.userList, server) } - } - .padding(.horizontal, 100) - .contextMenu { - Button(role: .destructive) { - viewModel.remove(server: server) - } label: { - Label(L10n.remove, systemImage: "trash") + .onLongPressGesture { + longPressedServer = server } - } + .padding(.horizontal, 100) } } .padding(.top, 50) @@ -64,24 +41,22 @@ struct ServerListView: View { @ViewBuilder private var noServerView: some View { - VStack { + VStack(spacing: 50) { L10n.connectToJellyfinServerStart.text - .frame(minWidth: 50, maxWidth: 500) + .frame(maxWidth: 500) .multilineTextAlignment(.center) .font(.body) Button { - serverListRouter.route(to: \.connectToServer) + router.route(to: \.connectToServer) } label: { L10n.connect.text .bold() .font(.callout) - .padding(.vertical) - .padding(.horizontal, 30) + .frame(width: 400, height: 75) .background(Color.jellyfinPurple) } - .padding(.top, 40) - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) } } @@ -95,34 +70,34 @@ struct ServerListView: View { } } - @ViewBuilder - private var trailingToolbarContent: some View { - if viewModel.servers.isEmpty { - EmptyView() - } else { - Button { - serverListRouter.route(to: \.connectToServer) - } label: { - Image(systemName: "plus.circle.fill") - } - .contextMenu { - Button { - serverListRouter.route(to: \.basicAppSettings) - } label: { - L10n.settings.text - } - } - } - } - var body: some View { innerBody .navigationTitle(L10n.servers) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - trailingToolbarContent + .if(!viewModel.servers.isEmpty) { view in + view.toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + router.route(to: \.connectToServer) + } label: { + Image(systemName: "plus.circle.fill") + } + .contextMenu { + Button { + router.route(to: \.basicAppSettings) + } label: { + L10n.settings.text + } + } + } } } + .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/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index 447baa55..fb14db70 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -6,6 +6,8 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import CollectionView +import JellyfinAPI import SwiftUI struct UserListView: View { @@ -15,47 +17,39 @@ struct UserListView: View { @ObservedObject var viewModel: UserListViewModel + @State + private var longPressedUser: SwiftfinStore.State.User? + @ViewBuilder private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.users, id: \.id) { user in - Button { - viewModel.signIn(user: user) - } label: { - HStack { - Text(user.username) - .font(.title2) - - Spacer() - - if viewModel.isLoading { - ProgressView() - } - } - } - .padding(.horizontal, 100) - .contextMenu { - Button(role: .destructive) { - viewModel.remove(user: user) - } label: { - Label(L10n.remove, systemImage: "trash") - } - } + CollectionView(items: viewModel.users) { _, user, _ in + UserProfileButton(user: user) + .onSelect { + viewModel.signIn(user: user) + } + .onLongPressGesture { + longPressedUser = user } - } - .padding(.top, 50) } - .padding(.top, 50) + .layout { _, layoutEnvironment in + .grid( + layoutEnvironment: layoutEnvironment, + layoutMode: .adaptive(withMinItemSize: 250), + itemSpacing: 20, + lineSpacing: 20, + sectionInsets: .init(top: 20, leading: 20, bottom: 20, trailing: 20) + ) + } + .padding(50) } @ViewBuilder private var noUserView: some View { - VStack { + VStack(spacing: 50) { L10n.signInGetStarted.text - .frame(minWidth: 50, maxWidth: 500) + .frame(maxWidth: 500) .multilineTextAlignment(.center) - .font(.callout) + .font(.body) Button { userListRouter.route(to: \.userSignIn, viewModel.server) @@ -63,46 +57,51 @@ struct UserListView: View { L10n.signIn.text .bold() .font(.callout) + .frame(width: 400, height: 75) + .background(Color.jellyfinPurple) } - .padding(.top, 40) - } - } - - @ViewBuilder - private var innerBody: some View { - if viewModel.users.isEmpty { - noUserView - .offset(y: -50) - } else { - listView - } - } - - @ViewBuilder - private var toolbarContent: some View { - if viewModel.users.isEmpty { - EmptyView() - } else { - HStack { - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - Image(systemName: "person.crop.circle.fill.badge.plus") - } - } + .buttonStyle(.card) } } var body: some View { - innerBody - .navigationTitle(viewModel.server.name) - .toolbar { + ZStack { + ImageView(ImageAPI.getSplashscreenWithRequestBuilder().url) + .ignoresSafeArea() + + Color.black + .opacity(0.9) + .ignoresSafeArea() + + if viewModel.users.isEmpty { + noUserView + .offset(y: -50) + } else { + listView + } + } + .navigationTitle(viewModel.server.name) + .if(!viewModel.users.isEmpty) { view in + view.toolbar { ToolbarItem(placement: .navigationBarTrailing) { - toolbarContent + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + Image(systemName: "person.crop.circle.fill.badge.plus") + } } } - .onAppear { - viewModel.fetchUsers() - } + } + + .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 index da98671a..e696416c 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.swift @@ -6,18 +6,156 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import CollectionView import JellyfinAPI import Stinsen import SwiftUI struct UserSignInView: View { + enum FocusedField { + case username + case password + } + @ObservedObject var viewModel: UserSignInViewModel @State private var username: String = "" @State private var password: String = "" + @State + private var presentQuickConnect: Bool = false + + @FocusState + private var focusedField: FocusedField? + + @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.signIn(username: username, password: password) + } label: { + HStack { + if viewModel.isLoading { + 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(.plain) + + Button { + presentQuickConnect = true + } label: { + L10n.quickConnect.text + .frame(height: 75) + .frame(maxWidth: .infinity) + .background(Color.secondary) + } + .buttonStyle(.plain) + } 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 { + CollectionView(items: viewModel.publicUsers) { _, user, _ in + UserProfileButton(user: user) + .onSelect { + username = user.name ?? "" + focusedField = .password + } + } + .layout { _, layoutEnvironment in + .grid( + layoutEnvironment: layoutEnvironment, + layoutMode: .adaptive(withMinItemSize: 250), + itemSpacing: 20, + lineSpacing: 20, + sectionInsets: .init(top: 20, leading: 20, bottom: 20, trailing: 20) + ) + } + } + } + } + + @ViewBuilder + private var quickConnect: some View { + ZStack { + + BlurView() + .ignoresSafeArea() + + VStack(alignment: .center) { + L10n.quickConnect.text + .font(.title3) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 20) { + L10n.quickConnectStep1.text + + L10n.quickConnectStep2.text + + L10n.quickConnectStep3.text + } + .padding(.vertical) + + Text(viewModel.quickConnectCode ?? "------") + .tracking(10) + .font(.title) + .monospacedDigit() + .frame(maxWidth: .infinity) + + Button { + presentQuickConnect = false + } label: { + L10n.close.text + .frame(width: 400, height: 75) + } + .buttonStyle(.plain) + } + } + .onAppear { + viewModel.startQuickConnect {} + } + .onDisappear { + viewModel.stopQuickConnectAuthCheck() + } + } var body: some View { ZStack { @@ -29,88 +167,24 @@ struct UserSignInView: View { .ignoresSafeArea() HStack(alignment: .top) { - VStack(alignment: .leading) { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) - - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) - - Button { - viewModel.signIn(username: username, password: password) - } label: { - HStack { - L10n.connect.text - - Spacer() - - if viewModel.isLoading { - ProgressView() - } - } - } - .disabled(viewModel.isLoading || username.isEmpty) - - } header: { - L10n.signInToServer(viewModel.server.name).text - .foregroundColor(.secondary) - } - - Spacer() - - if !viewModel.quickConnectEnabled { - L10n.quickConnectNotEnabled.text - } - } - .frame(maxWidth: .infinity) - .alert(item: $viewModel.errorMessage) { _ in - Alert( - title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel() - ) - } - .navigationTitle(L10n.signIn) - - if viewModel.quickConnectEnabled { - VStack(alignment: .center) { - L10n.quickConnect.text - .font(.title3) - .fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 20) { - L10n.quickConnectStep1.text - - L10n.quickConnectStep2.text - - L10n.quickConnectStep3.text - } - .padding(.vertical) - - Text(viewModel.quickConnectCode ?? "------") - .tracking(10) - .font(.title) - .monospacedDigit() - .frame(maxWidth: .infinity) - } + signInForm + .frame(maxWidth: .infinity) + + publicUsersGrid .frame(maxWidth: .infinity) - .onAppear { - viewModel.startQuickConnect {} - } - .onDisappear { - viewModel.stopQuickConnectAuthCheck() - } - } } + .edgesIgnoringSafeArea(.bottom) + } + .navigationTitle(L10n.signIn) + .alert(item: $viewModel.errorMessage) { _ in + Alert( + title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), + dismissButton: .cancel() + ) + } + .fullScreenCover(isPresented: $presentQuickConnect, onDismiss: nil) { + quickConnect } } } - -// struct UserSignInView_Preivews: PreviewProvider { -// static var previews: some View { -// UserSignInView(viewModel: .init(server: .sample)) -// } -// } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift index 0058df64..617c4185 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift @@ -97,7 +97,7 @@ struct SmallMediaStreamSelectionView: View { .padding() } } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .background(Color.clear) .focused($focusedLayer, equals: .subtitles) .focused($subtitlesFocused) @@ -129,7 +129,7 @@ struct SmallMediaStreamSelectionView: View { .padding() } } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .background(Color.clear) .focused($focusedLayer, equals: .audio) .focused($audioFocused) @@ -161,7 +161,7 @@ struct SmallMediaStreamSelectionView: View { .padding() } } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .background(Color.clear) .focused($focusedLayer, equals: .playbackSpeed) .focused($playbackSpeedFocused) @@ -194,7 +194,7 @@ struct SmallMediaStreamSelectionView: View { .padding() } } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .background(Color.clear) .focused($focusedLayer, equals: .chapters) .focused($chaptersFocused) @@ -335,7 +335,7 @@ struct SmallMediaStreamSelectionView: View { .cornerRadius(10) .frame(width: 350, height: 210) } - .buttonStyle(CardButtonStyle()) + .buttonStyle(.card) VStack(alignment: .leading, spacing: 5) { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 9235afcf..64af33a0 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -476,6 +476,7 @@ E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PosterButton.swift */; }; E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92618288756BD002A7A66 /* DotHStack.swift */; }; E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92619288756BD002A7A66 /* PosterHStack.swift */; }; + E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */; }; E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; E1CCF12F28ABF989006CAC9E /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */; }; @@ -509,6 +510,8 @@ E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */; }; E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */; }; E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */; }; + E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */; }; + E1E9EFEB28C7EA2C00CC1F8B /* UserDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDtoExtensions.swift */; }; E1EBCB42278BD174009FE6E9 /* TruncatedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */; }; E1EBCB44278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB43278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift */; }; E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */; }; @@ -921,6 +924,7 @@ E1C92617288756BD002A7A66 /* PosterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = ""; }; E1C92618288756BD002A7A66 /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; E1C92619288756BD002A7A66 /* PosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; + E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = ""; }; E1CCF12D28ABF989006CAC9E /* PosterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterType.swift; sourceTree = ""; }; E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; @@ -944,6 +948,7 @@ E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = ""; }; E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmCloseOverlay.swift; sourceTree = ""; }; + E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerButton.swift; sourceTree = ""; }; E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedTextView.swift; sourceTree = ""; }; E1EBCB43278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewCoordinator.swift; sourceTree = ""; }; E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; @@ -1217,10 +1222,12 @@ E1C92618288756BD002A7A66 /* DotHStack.swift */, E103A6A1278A7EB500820EC7 /* HomeCinematicView */, E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, + E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */, 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, + 536D3D80267BDFC60004248C /* PortraitItemElement.swift */, E1C92617288756BD002A7A66 /* PosterButton.swift */, E1C92619288756BD002A7A66 /* PosterHStack.swift */, - 536D3D80267BDFC60004248C /* PortraitItemElement.swift */, + E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */, E17885A3278105170094FBCF /* SFSymbolButton.swift */, ); path = Components; @@ -2455,6 +2462,7 @@ buildActionMask = 2147483647; files = ( E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, + E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */, C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, @@ -2486,6 +2494,7 @@ E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, + E1E9EFEB28C7EA2C00CC1F8B /* UserDtoExtensions.swift in Sources */, 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */, E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */, @@ -2618,6 +2627,7 @@ E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */, 6264E88D273850380081A12A /* Strings.swift in Sources */, E1C926102887565C002A7A66 /* PlayButton.swift in Sources */, + E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, diff --git a/Swiftfin/Views/ConnectToServerView.swift b/Swiftfin/Views/ConnectToServerView.swift index 256310c5..24fefd08 100644 --- a/Swiftfin/Views/ConnectToServerView.swift +++ b/Swiftfin/Views/ConnectToServerView.swift @@ -43,9 +43,17 @@ struct ConnectToServerView: View { Button { viewModel.connectToServer(uri: uri) } label: { - L10n.connect.text + HStack { + L10n.connect.text + + Spacer() + + if viewModel.isLoading { + ProgressView() + } + } } - .disabled(uri.isEmpty) + .disabled(uri.isEmpty || viewModel.isLoading) } } header: { L10n.connectToJellyfinServer.text @@ -69,15 +77,15 @@ struct ConnectToServerView: View { Spacer() } } else { - ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in + ForEach(viewModel.discoveredServers, id: \.id) { server in Button { - uri = discoveredServer.url.absoluteString - viewModel.connectToServer(uri: discoveredServer.url.absoluteString) + uri = server.currentURI + viewModel.connectToServer(uri: server.currentURI) } label: { VStack(alignment: .leading, spacing: 5) { - Text(discoveredServer.name) + Text(server.name) .font(.title3) - Text(discoveredServer.host) + Text(server.currentURI) .font(.subheadline) .foregroundColor(.secondary) } diff --git a/Swiftfin/Views/ItemView/Components/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView.swift index 47f7124d..ce03e1bf 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView.swift @@ -68,7 +68,7 @@ extension ItemView { } .frame(width: 330, height: 195) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) } .padding(.horizontal) .if(UIDevice.isIPad) { view in diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift index 0f302ae4..91716923 100644 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift @@ -39,7 +39,7 @@ extension ItemView { // .foregroundStyle(.white) } } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .if(equalSpacing) { view in view.frame(maxWidth: .infinity) } @@ -57,7 +57,7 @@ extension ItemView { // .foregroundStyle(.white) } } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .if(equalSpacing) { view in view.frame(maxWidth: .infinity) }