// // 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 Combine import Defaults import SwiftUI struct ConnectToXtreamView: View { // MARK: - Defaults @Default(.accentColor) private var accentColor @Default(.xtreamServers) private var savedServers // MARK: - Focus Fields @FocusState private var focusedField: Field? enum Field { case name case url case username case password } // MARK: - State & Environment Objects @EnvironmentObject private var router: SelectUserCoordinator.Router @StateObject private var viewModel = ConnectToXtreamViewModel() // MARK: - Connect to Xtream Variables @State private var name: String = "" @State private var url: String = "" @State private var username: String = "" @State private var password: String = "" // MARK: - Error States @State private var error: Error? = nil // MARK: - Connect Section @ViewBuilder private var connectSection: some View { TextField(L10n.name, text: $name) .disableAutocorrection(true) .textInputAutocapitalization(.words) .focused($focusedField, equals: .name) TextField("Server URL", text: $url) .disableAutocorrection(true) .textInputAutocapitalization(.never) .keyboardType(.URL) .focused($focusedField, equals: .url) TextField("Username", text: $username) .disableAutocorrection(true) .textInputAutocapitalization(.never) .focused($focusedField, equals: .username) SecureField("Password", text: $password) .focused($focusedField, equals: .password) if viewModel.state == .connecting || viewModel.state == .testing { ListRowButton(L10n.cancel) { viewModel.send(.cancel) } .foregroundStyle(.red, accentColor) .padding(.vertical) } else { ListRowButton(L10n.connect) { focusedField = nil viewModel.send(.connect(name: name, url: url, username: username, password: password)) } .disabled(url.isEmpty || username.isEmpty || password.isEmpty) .foregroundStyle( accentColor.overlayColor, (url.isEmpty || username.isEmpty || password.isEmpty) ? Color.white.opacity(0.5) : accentColor ) .opacity((url.isEmpty || username.isEmpty || password.isEmpty) ? 0.5 : 1) .padding(.vertical) } } // MARK: - Saved Servers Section @ViewBuilder private var savedServersSection: some View { if savedServers.isEmpty { Text("No saved Xtream servers") .font(.callout) .foregroundColor(.secondary) .frame(maxWidth: .infinity) } else { LazyVGrid( columns: Array(repeating: GridItem(.flexible()), count: 1), spacing: 30 ) { ForEach(savedServers) { server in XtreamServerButton(server: server) { // Select this server Defaults[.currentXtreamServerID] = server.id router.popLast() } .environment( \.isEnabled, viewModel.state != .connecting && viewModel.state != .testing ) } } } } // MARK: - Body var body: some View { SplitLoginWindowView( isLoading: viewModel.state == .connecting || viewModel.state == .testing, leadingTitle: "Connect to Xtream Server", trailingTitle: "Saved Servers" ) { connectSection } trailingContentView: { savedServersSection } .onFirstAppear { focusedField = .url } .onReceive(viewModel.events) { event in switch event { case let .connected(server): viewModel.saveServer(server) Defaults[.currentXtreamServerID] = server.id router.popLast() case let .error(eventError): error = eventError focusedField = .url } } .errorMessage($error) } } // MARK: - Xtream Server Button struct XtreamServerButton: View { @Default(.accentColor) private var accentColor let server: XtreamServer let action: () -> Void @Environment(\.isEnabled) private var isEnabled var body: some View { Button { action() } label: { HStack { VStack(alignment: .leading, spacing: 8) { Text(server.name) .font(.headline) .foregroundColor(.primary) Text(server.url.absoluteString) .font(.subheadline) .foregroundColor(.secondary) .lineLimit(1) Text("User: \(server.username)") .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: "tv") .font(.title) .foregroundColor(accentColor) } .padding() .background(Color.gray.opacity(0.2)) .cornerRadius(10) } .buttonStyle(.card) .disabled(!isEnabled) .opacity(isEnabled ? 1 : 0.5) } }