From a1613454570a9a72f3bd9246f4701c2fbaf92d61 Mon Sep 17 00:00:00 2001 From: Matthew Alvernaz Date: Mon, 6 Apr 2026 23:01:33 +0000 Subject: [PATCH 1/2] Reimplement multi-server support for new integration architecture Adapts the multi-server feature to the protocol-based integration framework introduced in the integrations rework. Instead of integration-specific views and services, multi-server support now plugs into the shared IntegrationConnectionViewModelProtocol and generic views in MediaServerIntegration/. Data model (both integrations): - Add id: String (UUID) to connection data with backward-compatible Decodable init for zero-friction migration from single-connection format Connection services (both integrations): - Keychain storage: single object -> [ConnectionData] array - reloadConnections(): tries array format first, migrates old single object on first launch, validates via isConnectionValid, normalizes activeConnectionID - signIn(): appends new connection, deduplicates on url + userID - activateConnection(id:): switch active server - deleteConnection(id:): remove specific server Shared protocol & views: - IntegrationConnectionViewModelProtocol gains servers, isAddingServer, handleSignOutAction(id:), handleActivateAction(id:), handleAddServerAction(), handleCancelAddServerAction() - IntegrationConnectedView: shows all saved servers with per-server sign-out when 2+, plus "Add Another Server" button - IntegrationConnectionView: handles isAddingServer flow with Cancel toolbar button and full connect/sign-in progression - New IntegrationServerPickerView: shared picker for import flow when 2+ servers exist Root views (both integrations): - Show server picker sheet when 2+ servers on launch - Reload library when switching servers --- BookPlayer.xcodeproj/project.pbxproj | 4 + .../AudiobookShelfRootView.swift | 32 ++++- .../AudiobookShelfConnectionViewModel.swift | 56 +++++++- .../AudiobookShelfConnectionData.swift | 32 ++++- .../AudiobookShelfConnectionService.swift | 95 ++++++++++---- BookPlayer/Base.lproj/Localizable.strings | 1 + .../JellyfinConnectionViewModel.swift | 56 +++++++- BookPlayer/Jellyfin/JellyfinRootView.swift | 32 ++++- .../Network/JellyfinConnectionData.swift | 32 ++++- .../Network/JellyfinConnectionService.swift | 124 +++++++++++++----- .../IntegrationConnectedView.swift | 67 ++++++++-- .../IntegrationConnectionView.swift | 119 +++++++++++------ .../IntegrationServerPickerView.swift | 51 +++++++ ...tegrationConnectionViewModelProtocol.swift | 26 ++++ BookPlayer/en.lproj/Localizable.strings | 1 + 15 files changed, 615 insertions(+), 113 deletions(-) create mode 100644 BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerPickerView.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 4fc99eee8..63b4b054d 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -645,6 +645,7 @@ 9236C57F833C70652BDD1FA3 /* TabEditingEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */; }; 9586CECD8FB418C6CFB0A7DD /* IntegrationLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C407B5BD19D3CF9CC64EC7 /* IntegrationLibraryView.swift */; }; 98BA9BA4D6A94BC8BCCE5F8B /* IntegrationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */; }; + A1B2C3D4E5F64789A1B2C3D4 /* IntegrationServerPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C3B2A1F6E54D78B2A1C3D4 /* IntegrationServerPickerView.swift */; }; 99329DB72F3AA8F6003F8E73 /* PlayControlsRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DB62F3AA8F6003F8E73 /* PlayControlsRowView.swift */; }; 99329DBB2F3AAA61003F8E73 /* ListeningProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DBA2F3AAA61003F8E73 /* ListeningProgressView.swift */; }; 99329DBD2F3AAAB4003F8E73 /* NavigationRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DBC2F3AAAB4003F8E73 /* NavigationRowView.swift */; }; @@ -1232,6 +1233,7 @@ 4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHelper.swift; sourceTree = ""; }; 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionViewModelProtocol.swift; sourceTree = ""; }; 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationSettingsView.swift; sourceTree = ""; }; + D4C3B2A1F6E54D78B2A1C3D4 /* IntegrationServerPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerPickerView.swift; sourceTree = ""; }; 5126F120258E9F18009965DC /* URL+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+BookPlayer.swift"; sourceTree = ""; }; 5CBB29522163A17F00E3A9FF /* ZIPFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZIPFoundation.framework; path = Carthage/Build/iOS/ZIPFoundation.framework; sourceTree = ""; }; 620C73C7275DA00300D495AA /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -1835,6 +1837,7 @@ 35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */, A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */, 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */, + D4C3B2A1F6E54D78B2A1C3D4 /* IntegrationServerPickerView.swift */, ); path = "Connection Screen"; sourceTree = ""; @@ -4313,6 +4316,7 @@ files = ( C451596D6866C856F3E5F7D1 /* IntegrationConnectionView.swift in Sources */, 98BA9BA4D6A94BC8BCCE5F8B /* IntegrationSettingsView.swift in Sources */, + A1B2C3D4E5F64789A1B2C3D4 /* IntegrationServerPickerView.swift in Sources */, 66DF1F3E6AFECB623A558F04 /* IntegrationDisconnectedView.swift in Sources */, 17239057BBE31E405AFFBBCD /* IntegrationServerFoundView.swift in Sources */, 0EF52614EF6770D09CA81CE4 /* IntegrationConnectedView.swift in Sources */, diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index 04346d128..64ef295b1 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -36,6 +36,7 @@ struct AudiobookShelfRootView: View { @State private var showLibraryPicker = false @State private var showConnectionForm = false + @State private var showServerPicker = false @State private var isLoadingLibraries = false private var isReady: Bool { @@ -173,14 +174,41 @@ struct AudiobookShelfRootView: View { .onChange(of: connectionViewModel.connectionState) { _, newValue in if newValue == .connected { showConnectionForm = false - if resolvedLibrary == nil { + resolvedLibrary = nil + Task { await loadLibraries() } + } + } + .sheet(isPresented: $showServerPicker) { + NavigationStack { + IntegrationServerPickerView(viewModel: connectionViewModel) { serverID in + connectionViewModel.handleActivateAction(id: serverID) + showServerPicker = false + resolvedLibrary = nil Task { await loadLibraries() } } + .toolbar { + ToolbarItem(placement: .principal) { + Text("AudiobookShelf") + .bpFont(.headline) + .foregroundStyle(theme.primaryColor) + } + ToolbarItemGroup(placement: .cancellationAction) { + Button { showServerPicker = false } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } + .navigationBarTitleDisplayMode(.inline) } + .tint(theme.linkColor) + .environmentObject(theme) } .task { - if connectionService.connection == nil { + if connectionService.connections.isEmpty { showConnectionForm = true + } else if connectionService.connections.count > 1, resolvedLibrary == nil { + showServerPicker = true } else if resolvedLibrary == nil { await loadLibraries() } diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift index 7e906e4c7..1cbbbd4d6 100644 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift +++ b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift @@ -17,9 +17,22 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro @Published var form: IntegrationConnectionFormViewModel @Published var viewMode: IntegrationViewMode = .regular @Published var connectionState: IntegrationConnectionState + @Published var isAddingServer: Bool = false private var disposeBag = Set() + var servers: [IntegrationServerInfo] { + connectionService.connections.map { data in + IntegrationServerInfo( + id: data.id, + serverName: data.serverName, + serverUrl: data.url.absoluteString, + userName: data.userName, + isActive: data.id == connectionService.connection?.id + ) + } + } + init( connectionService: AudiobookShelfConnectionService, mode: IntegrationViewMode = .regular @@ -48,6 +61,7 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro @MainActor func handleSignInAction() async throws { do { + let wasAdding = isAddingServer try await connectionService.signIn( username: form.username, password: form.password, @@ -55,6 +69,13 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro serverName: form.serverName ) + if wasAdding { + isAddingServer = false + } + + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } connectionState = .connected } catch let error as IntegrationError { throw error @@ -67,6 +88,39 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro func handleSignOutAction() { connectionService.deleteConnection() form = IntegrationConnectionFormViewModel() - connectionState = .disconnected + connectionState = connectionService.connections.isEmpty ? .disconnected : .connected + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + } + + func handleSignOutAction(id: String) { + connectionService.deleteConnection(id: id) + if connectionService.connections.isEmpty { + form = IntegrationConnectionFormViewModel() + connectionState = .disconnected + } else if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + } + + func handleActivateAction(id: String) { + connectionService.activateConnection(id: id) + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + } + + func handleAddServerAction() { + isAddingServer = true + form = IntegrationConnectionFormViewModel() + } + + func handleCancelAddServerAction() { + isAddingServer = false + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + connectionState = .connected } } diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift index 30669cc00..069b4b0db 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift @@ -8,13 +8,43 @@ import Foundation -struct AudiobookShelfConnectionData: Codable { +struct AudiobookShelfConnectionData: Codable, Identifiable { + let id: String let url: URL let serverName: String let userID: String let userName: String let apiToken: String var selectedLibraryId: String? + + init( + id: String = UUID().uuidString, + url: URL, + serverName: String, + userID: String, + userName: String, + apiToken: String, + selectedLibraryId: String? = nil + ) { + self.id = id + self.url = url + self.serverName = serverName + self.userID = userID + self.userName = userName + self.apiToken = apiToken + self.selectedLibraryId = selectedLibraryId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + self.url = try container.decode(URL.self, forKey: .url) + self.serverName = try container.decode(String.self, forKey: .serverName) + self.userID = try container.decode(String.self, forKey: .userID) + self.userName = try container.decode(String.self, forKey: .userName) + self.apiToken = try container.decode(String.self, forKey: .apiToken) + self.selectedLibraryId = try container.decodeIfPresent(String.self, forKey: .selectedLibraryId) + } } extension AudiobookShelfConnectionData: CustomDebugStringConvertible { diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift index 493353358..40ce5c4db 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift @@ -11,11 +11,24 @@ import Foundation @Observable class AudiobookShelfConnectionService: BPLogger { + private static let activeConnectionIDKey = "audiobookshelf_active_connection_id" + private let keychainService: KeychainServiceProtocol - var connection: AudiobookShelfConnectionData? + var connections: [AudiobookShelfConnectionData] = [] + var connection: AudiobookShelfConnectionData? { + if let activeConnectionID, + let active = connections.first(where: { $0.id == activeConnectionID }) { + return active + } + return connections.first + } private var urlSession: URLSession + private(set) var activeConnectionID: String? { + get { UserDefaults.standard.string(forKey: Self.activeConnectionIDKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.activeConnectionIDKey) } + } init(keychainService: KeychainServiceProtocol = KeychainService()) { self.keychainService = keychainService @@ -25,7 +38,7 @@ class AudiobookShelfConnectionService: BPLogger { } func setup() { - reloadConnection() + reloadConnections() } /// Pings the server to verify it exists and returns the server version @@ -112,29 +125,46 @@ class AudiobookShelfConnectionService: BPLogger { apiToken: apiToken ) - try keychainService.set( - connectionData, - key: .audiobookshelfConnection - ) - - self.connection = connectionData + // Deduplicate on url + userID + connections.removeAll { $0.url == url && $0.userID == userID } + connections.append(connectionData) + activeConnectionID = connectionData.id + saveConnections() } func saveSelectedLibrary(id: String?) { - guard var data = connection else { return } - data.selectedLibraryId = id - connection = data - try? keychainService.set(data, key: .audiobookshelfConnection) + guard let activeID = connection?.id, + let index = connections.firstIndex(where: { $0.id == activeID }) else { return } + connections[index].selectedLibraryId = id + saveConnections() } - func deleteConnection() { - do { - try keychainService.remove(.audiobookshelfConnection) - } catch { - Self.logger.warning("failed to remove connection data from keychain: \(error)") + func activateConnection(id: String) { + activeConnectionID = id + } + + func deleteConnection(id: String) { + connections.removeAll { $0.id == id } + + if activeConnectionID == id { + activeConnectionID = connections.first?.id } - connection = nil + if connections.isEmpty { + do { + try keychainService.remove(.audiobookshelfConnection) + } catch { + Self.logger.warning("failed to remove connection data from keychain: \(error)") + } + } else { + saveConnections() + } + } + + func deleteConnection() { + if let id = connection?.id { + deleteConnection(id: id) + } } public func fetchLibraries() async throws -> [AudiobookShelfLibrary] { @@ -432,16 +462,33 @@ class AudiobookShelfConnectionService: BPLogger { .appending(queryItems: [URLQueryItem(name: "token", value: connection.apiToken)]) } - private func reloadConnection() { - guard - let storedConnection: AudiobookShelfConnectionData = try? keychainService.get(.audiobookshelfConnection), - isConnectionValid(storedConnection) - else { + private func reloadConnections() { + // Try array format first + if let storedConnections: [AudiobookShelfConnectionData] = try? keychainService.get(.audiobookshelfConnection) { + connections = storedConnections.filter { isConnectionValid($0) } + } else if let single: AudiobookShelfConnectionData = try? keychainService.get(.audiobookshelfConnection), + isConnectionValid(single) { + // Migrate from single-connection format + connections = [single] + saveConnections() + } else { Self.logger.warning("failed to load connection data from keychain") return } - connection = storedConnection + // Normalize activeConnectionID + if connections.isEmpty { + activeConnectionID = nil + } else if let activeID = activeConnectionID, + !connections.contains(where: { $0.id == activeID }) { + activeConnectionID = connections.first?.id + } else if activeConnectionID == nil { + activeConnectionID = connections.first?.id + } + } + + private func saveConnections() { + try? keychainService.set(connections, key: .audiobookshelfConnection) } private func isConnectionValid(_ data: AudiobookShelfConnectionData) -> Bool { diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index 32f5f20f8..c60e4f1e9 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -348,6 +348,7 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_username_placeholder" = "Username"; "integration_password_placeholder" = "Password"; "integration_password_remember_me_label" = "Remember Me"; +"integration_add_server_button" = "Add Another Server"; "settings_integration_manage_connection_title" = "Manage Connection"; "integration_internal_error_invalid_url" = "Internal error: Request URL is invalid: %@"; "integration_internal_error_build_url" = "Internal error: Failed to build request URL"; diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift index 9784e2e23..15cff6893 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift +++ b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift @@ -19,9 +19,22 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, @Published var form: IntegrationConnectionFormViewModel @Published var viewMode: IntegrationViewMode = .regular @Published var connectionState: IntegrationConnectionState + @Published var isAddingServer: Bool = false private var disposeBag = Set() + var servers: [IntegrationServerInfo] { + connectionService.connections.map { data in + IntegrationServerInfo( + id: data.id, + serverName: data.serverName, + serverUrl: data.url.absoluteString, + userName: data.userName, + isActive: data.id == connectionService.connection?.id + ) + } + } + init( connectionService: JellyfinConnectionService, mode: IntegrationViewMode = .regular @@ -50,12 +63,20 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, @MainActor func handleSignInAction() async throws { do { + let wasAdding = isAddingServer try await connectionService.signIn( username: form.username, password: form.password, serverName: form.serverName ) + if wasAdding { + isAddingServer = false + } + + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } connectionState = .connected } catch APIError.unacceptableStatusCode(let statusCode) { switch statusCode { @@ -73,6 +94,39 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, func handleSignOutAction() { connectionService.deleteConnection() form = IntegrationConnectionFormViewModel() - connectionState = .disconnected + connectionState = connectionService.connections.isEmpty ? .disconnected : .connected + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + } + + func handleSignOutAction(id: String) { + connectionService.deleteConnection(id: id) + if connectionService.connections.isEmpty { + form = IntegrationConnectionFormViewModel() + connectionState = .disconnected + } else if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + } + + func handleActivateAction(id: String) { + connectionService.activateConnection(id: id) + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + } + + func handleAddServerAction() { + isAddingServer = true + form = IntegrationConnectionFormViewModel() + } + + func handleCancelAddServerAction() { + isAddingServer = false + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + connectionState = .connected } } diff --git a/BookPlayer/Jellyfin/JellyfinRootView.swift b/BookPlayer/Jellyfin/JellyfinRootView.swift index fdd3c0edf..3e103303e 100644 --- a/BookPlayer/Jellyfin/JellyfinRootView.swift +++ b/BookPlayer/Jellyfin/JellyfinRootView.swift @@ -36,6 +36,7 @@ struct JellyfinRootView: View { @State private var showLibraryPicker = false @State private var showConnectionForm = false + @State private var showServerPicker = false @State private var isLoadingLibraries = false private var isReady: Bool { @@ -136,14 +137,41 @@ struct JellyfinRootView: View { .onChange(of: connectionViewModel.connectionState) { _, newValue in if newValue == .connected { showConnectionForm = false - if resolvedLibrary == nil { + resolvedLibrary = nil + Task { await loadLibraries() } + } + } + .sheet(isPresented: $showServerPicker) { + NavigationStack { + IntegrationServerPickerView(viewModel: connectionViewModel) { serverID in + connectionViewModel.handleActivateAction(id: serverID) + showServerPicker = false + resolvedLibrary = nil Task { await loadLibraries() } } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Jellyfin") + .bpFont(.headline) + .foregroundStyle(theme.primaryColor) + } + ToolbarItemGroup(placement: .cancellationAction) { + Button { showServerPicker = false } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } + .navigationBarTitleDisplayMode(.inline) } + .tint(theme.linkColor) + .environmentObject(theme) } .task { - if connectionService.connection == nil { + if connectionService.connections.isEmpty { showConnectionForm = true + } else if connectionService.connections.count > 1, resolvedLibrary == nil { + showServerPicker = true } else if resolvedLibrary == nil { await loadLibraries() } diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift index 90e66d844..9d82ad607 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift @@ -8,13 +8,43 @@ import Foundation -struct JellyfinConnectionData: Codable { +struct JellyfinConnectionData: Codable, Identifiable { + let id: String let url: URL let serverName: String let userID: String let userName: String let accessToken: String var selectedLibraryId: String? + + init( + id: String = UUID().uuidString, + url: URL, + serverName: String, + userID: String, + userName: String, + accessToken: String, + selectedLibraryId: String? = nil + ) { + self.id = id + self.url = url + self.serverName = serverName + self.userID = userID + self.userName = userName + self.accessToken = accessToken + self.selectedLibraryId = selectedLibraryId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + self.url = try container.decode(URL.self, forKey: .url) + self.serverName = try container.decode(String.self, forKey: .serverName) + self.userID = try container.decode(String.self, forKey: .userID) + self.userName = try container.decode(String.self, forKey: .userName) + self.accessToken = try container.decode(String.self, forKey: .accessToken) + self.selectedLibraryId = try container.decodeIfPresent(String.self, forKey: .selectedLibraryId) + } } extension JellyfinConnectionData: CustomDebugStringConvertible { diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift index ac8f9cbab..8938e60d1 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift @@ -12,18 +12,31 @@ import JellyfinAPI @Observable class JellyfinConnectionService: BPLogger { + private static let activeConnectionIDKey = "jellyfin_active_connection_id" + private let keychainService: KeychainServiceProtocol - var connection: JellyfinConnectionData? + var connections: [JellyfinConnectionData] = [] + var connection: JellyfinConnectionData? { + if let activeConnectionID, + let active = connections.first(where: { $0.id == activeConnectionID }) { + return active + } + return connections.first + } var client: JellyfinClient? + private(set) var activeConnectionID: String? { + get { UserDefaults.standard.string(forKey: Self.activeConnectionIDKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.activeConnectionIDKey) } + } init(keychainService: KeychainServiceProtocol = KeychainService()) { self.keychainService = keychainService } func setup() { - reloadConnection() + reloadConnections() } /// Finds and creates the api-client for the specified server @@ -66,38 +79,69 @@ class JellyfinConnectionService: BPLogger { accessToken: accessToken ) - try keychainService.set( - data, - key: .jellyfinConnection - ) + // Deduplicate on url + userID + connections.removeAll { $0.url == data.url && $0.userID == data.userID } + connections.append(data) + activeConnectionID = data.id + saveConnections() - self.connection = data self.client = client } func saveSelectedLibrary(id: String?) { - guard var data = connection else { return } - data.selectedLibraryId = id - connection = data - try? keychainService.set(data, key: .jellyfinConnection) + guard let activeID = connection?.id, + let index = connections.firstIndex(where: { $0.id == activeID }) else { return } + connections[index].selectedLibraryId = id + saveConnections() } - func deleteConnection() { - if let client { + func activateConnection(id: String) { + activeConnectionID = id + if let data = connection { + client = createClient( + serverUrlString: data.url.absoluteString, + accessToken: data.accessToken + ) + } + } + + func deleteConnection(id: String) { + if let data = connections.first(where: { $0.id == id }), + data.id == connection?.id, + let client { Task { - // we don't care if this throws try await client.signOut() } } - do { - try keychainService.remove(.jellyfinConnection) - } catch { - Self.logger.warning("failed to remove connection data from keychain: \(error)") + connections.removeAll { $0.id == id } + + if activeConnectionID == id { + activeConnectionID = connections.first?.id + } + + if connections.isEmpty { + client = nil + do { + try keychainService.remove(.jellyfinConnection) + } catch { + Self.logger.warning("failed to remove connection data from keychain: \(error)") + } + } else { + saveConnections() + if let data = connection { + client = createClient( + serverUrlString: data.url.absoluteString, + accessToken: data.accessToken + ) + } } + } - connection = nil - client = nil + func deleteConnection() { + if let id = connection?.id { + deleteConnection(id: id) + } } public func fetchTopLevelItems() async throws -> [JellyfinLibraryItem] { @@ -423,20 +467,40 @@ class JellyfinConnectionService: BPLogger { return try await client.send(request) } - private func reloadConnection() { - guard - let storedConnection: JellyfinConnectionData = try? keychainService.get(.jellyfinConnection), - isConnectionValid(storedConnection) - else { + private func reloadConnections() { + // Try array format first + if let storedConnections: [JellyfinConnectionData] = try? keychainService.get(.jellyfinConnection) { + connections = storedConnections.filter { isConnectionValid($0) } + } else if let single: JellyfinConnectionData = try? keychainService.get(.jellyfinConnection), + isConnectionValid(single) { + // Migrate from single-connection format + connections = [single] + saveConnections() + } else { Self.logger.warning("failed to load connection data from keychain") return } - client = createClient( - serverUrlString: storedConnection.url.absoluteString, - accessToken: storedConnection.accessToken - ) - connection = storedConnection + // Normalize activeConnectionID + if connections.isEmpty { + activeConnectionID = nil + } else if let activeID = activeConnectionID, + !connections.contains(where: { $0.id == activeID }) { + activeConnectionID = connections.first?.id + } else if activeConnectionID == nil { + activeConnectionID = connections.first?.id + } + + if let data = connection { + client = createClient( + serverUrlString: data.url.absoluteString, + accessToken: data.accessToken + ) + } + } + + private func saveConnections() { + try? keychainService.set(connections, key: .jellyfinConnection) } private func isConnectionValid(_ data: JellyfinConnectionData) -> Bool { diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift index 4049acae0..68cf53ef8 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift @@ -13,24 +13,67 @@ struct IntegrationConnectedView: Vie @EnvironmentObject var theme: ThemeViewModel var body: some View { - ThemedSection { - HStack { - Text("integration_username_placeholder".localized) + if viewModel.servers.count <= 1 { + // Single server: show the original simple layout + ThemedSection { + HStack { + Text("integration_username_placeholder".localized) + .foregroundStyle(theme.secondaryColor) + Spacer() + Text(viewModel.form.username) + } + } header: { + Text("integration_section_login".localized) + .foregroundStyle(theme.secondaryColor) + } + + ThemedSection { + Button("logout_title".localized, role: .destructive) { + viewModel.handleSignOutAction() + } + .frame(maxWidth: .infinity) + .foregroundStyle(.red) + } + } else { + // Multiple servers: show all servers with per-server actions + ThemedSection { + ForEach(viewModel.servers) { server in + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading) { + Text(server.serverName) + .foregroundStyle(theme.primaryColor) + Text("\(server.userName) — \(server.serverUrl)") + .font(.caption) + .foregroundStyle(theme.secondaryColor) + } + Spacer() + if server.isActive { + Image(systemName: "checkmark") + .foregroundStyle(theme.linkColor) + .accessibilityLabel("Active") + } + } + Button("logout_title".localized, role: .destructive) { + viewModel.handleSignOutAction(id: server.id) + } + .font(.caption) + .foregroundStyle(.red) + } + .padding(.vertical, 4) + } + } header: { + Text("integration_section_login".localized) .foregroundStyle(theme.secondaryColor) - Spacer() - Text(viewModel.form.username) } - } header: { - Text("integration_section_login".localized) - .foregroundStyle(theme.secondaryColor) } ThemedSection { - Button("logout_title".localized, role: .destructive) { - viewModel.handleSignOutAction() + Button { + viewModel.handleAddServerAction() + } label: { + Label("integration_add_server_button".localized, systemImage: "plus.circle") } - .frame(maxWidth: .infinity) - .foregroundStyle(.red) } } } diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift index bdce2f870..2675821fa 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift @@ -21,32 +21,57 @@ struct IntegrationConnectionView: Vi var body: some View { Form { - switch viewModel.connectionState { - case .disconnected: - IntegrationDisconnectedView( - serverUrl: $viewModel.form.serverUrl, - placeholderURL: integrationName == "Jellyfin" - ? "http://jellyfin.example.com:8096" - : "http://audiobookshelf.example.com", - integrationName: integrationName, - onCommit: onConnect - ) - case .foundServer: - IntegrationServerInformationSectionView( - serverName: viewModel.form.serverName, - serverUrl: viewModel.form.serverUrl - ) - IntegrationServerFoundView( - username: $viewModel.form.username, - password: $viewModel.form.password, - onCommit: onSignIn - ) - case .connected: - IntegrationServerInformationSectionView( - serverName: viewModel.form.serverName, - serverUrl: viewModel.form.serverUrl - ) - IntegrationConnectedView(viewModel: viewModel) + if viewModel.isAddingServer { + // Adding a new server from settings — show the connection flow + switch viewModel.connectionState { + case .disconnected, .connected: + IntegrationDisconnectedView( + serverUrl: $viewModel.form.serverUrl, + placeholderURL: integrationName == "Jellyfin" + ? "http://jellyfin.example.com:8096" + : "http://audiobookshelf.example.com", + integrationName: integrationName, + onCommit: onConnect + ) + case .foundServer: + IntegrationServerInformationSectionView( + serverName: viewModel.form.serverName, + serverUrl: viewModel.form.serverUrl + ) + IntegrationServerFoundView( + username: $viewModel.form.username, + password: $viewModel.form.password, + onCommit: onSignIn + ) + } + } else { + switch viewModel.connectionState { + case .disconnected: + IntegrationDisconnectedView( + serverUrl: $viewModel.form.serverUrl, + placeholderURL: integrationName == "Jellyfin" + ? "http://jellyfin.example.com:8096" + : "http://audiobookshelf.example.com", + integrationName: integrationName, + onCommit: onConnect + ) + case .foundServer: + IntegrationServerInformationSectionView( + serverName: viewModel.form.serverName, + serverUrl: viewModel.form.serverUrl + ) + IntegrationServerFoundView( + username: $viewModel.form.username, + password: $viewModel.form.password, + onCommit: onSignIn + ) + case .connected: + IntegrationServerInformationSectionView( + serverName: viewModel.form.serverName, + serverUrl: viewModel.form.serverUrl + ) + IntegrationConnectedView(viewModel: viewModel) + } } } .scrollContentBackground(.hidden) @@ -68,19 +93,35 @@ struct IntegrationConnectionView: Vi } } .toolbar { - ToolbarItem(placement: .principal) { - Text(localizedNavigationTitle) - .bpFont(.headline) - .foregroundStyle(theme.primaryColor) - } - ToolbarItemGroup(placement: .confirmationAction) { - switch viewModel.connectionState { - case .disconnected: - connectToolbarButton - case .foundServer: - signInToolbarButton - case .connected: - EmptyView() + if viewModel.isAddingServer { + ToolbarItem(placement: .cancellationAction) { + Button("cancel_button".localized) { + viewModel.handleCancelAddServerAction() + } + .foregroundStyle(theme.linkColor) + } + ToolbarItemGroup(placement: .confirmationAction) { + if viewModel.connectionState == .foundServer { + signInToolbarButton + } else { + connectToolbarButton + } + } + } else { + ToolbarItem(placement: .principal) { + Text(localizedNavigationTitle) + .bpFont(.headline) + .foregroundStyle(theme.primaryColor) + } + ToolbarItemGroup(placement: .confirmationAction) { + switch viewModel.connectionState { + case .disconnected: + connectToolbarButton + case .foundServer: + signInToolbarButton + case .connected: + EmptyView() + } } } } diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerPickerView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerPickerView.swift new file mode 100644 index 000000000..c27520771 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerPickerView.swift @@ -0,0 +1,51 @@ +// +// IntegrationServerPickerView.swift +// BookPlayer +// +// Created by Matthew Alnaser on 2026-04-06. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +/// Server picker shown in the import flow when 2+ servers are saved. +/// Tap a server to activate it and navigate to its library. +struct IntegrationServerPickerView: View { + @ObservedObject var viewModel: VM + let onServerSelected: (String) -> Void + + @EnvironmentObject var theme: ThemeViewModel + + var body: some View { + Form { + ThemedSection { + ForEach(viewModel.servers) { server in + Button { + onServerSelected(server.id) + } label: { + HStack { + VStack(alignment: .leading) { + Text(server.serverName) + .foregroundStyle(theme.primaryColor) + Text(server.serverUrl) + .font(.caption) + .foregroundStyle(theme.secondaryColor) + } + Spacer() + if server.isActive { + Image(systemName: "checkmark") + .foregroundStyle(theme.linkColor) + } + } + } + .accessibilityLabel("\(server.serverName), \(server.serverUrl)") + } + } header: { + Text("integration_section_login".localized) + .foregroundStyle(theme.secondaryColor) + } + } + .scrollContentBackground(.hidden) + .background(theme.systemBackgroundColor) + } +} diff --git a/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift index 4f83e41db..886ee47d6 100644 --- a/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift +++ b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift @@ -19,6 +19,14 @@ enum IntegrationViewMode { case viewDetails } +struct IntegrationServerInfo: Identifiable { + let id: String + let serverName: String + let serverUrl: String + let userName: String + let isActive: Bool +} + @MainActor protocol IntegrationConnectionViewModelProtocol: ObservableObject { associatedtype FormVM: IntegrationConnectionFormViewModelProtocol @@ -27,7 +35,25 @@ protocol IntegrationConnectionViewModelProtocol: ObservableObject { var viewMode: IntegrationViewMode { get set } var connectionState: IntegrationConnectionState { get set } + /// All saved server connections + var servers: [IntegrationServerInfo] { get } + + /// Whether the user is adding a new server from the settings screen + var isAddingServer: Bool { get set } + func handleConnectAction() async throws func handleSignInAction() async throws func handleSignOutAction() + + /// Sign out a specific server by ID + func handleSignOutAction(id: String) + + /// Switch active server + func handleActivateAction(id: String) + + /// Begin adding a new server from settings + func handleAddServerAction() + + /// Cancel adding a new server + func handleCancelAddServerAction() } diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index f00d4a5d1..a74fb1374 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -349,6 +349,7 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_username_placeholder" = "Username"; "integration_password_placeholder" = "Password"; "integration_password_remember_me_label" = "Remember Me"; +"integration_add_server_button" = "Add Another Server"; "settings_integration_manage_connection_title" = "Manage Connection"; "integration_internal_error_invalid_url" = "Internal error: Request URL is invalid: %@"; "integration_internal_error_build_url" = "Internal error: Failed to build request URL"; From a85e9c1aa9fce1d85a6e0a16523fb7688bc5f748 Mon Sep 17 00:00:00 2001 From: Matthew Alvernaz Date: Thu, 9 Apr 2026 21:31:15 -0700 Subject: [PATCH 2/2] Unify media server UI into a single "Media Servers" entry point Replace the separate "Download from Jellyfin" and "Download from AudiobookShelf" menu items with one "Media Servers" button that opens a unified view showing all saved servers from both integrations. - Add MediaServersView with unified server list, type picker for adding new servers, and in-place add-server sheets - Add skipServerPicker parameter to root views so the caller can bypass the per-integration server picker after activating a server - Add .mediaServers case to IntegrationSheet enum - Add localization keys for the new UI Co-Authored-By: Claude Opus 4.6 (1M context) --- BookPlayer.xcodeproj/project.pbxproj | 4 + .../AudiobookShelfRootView.swift | 8 +- BookPlayer/Base.lproj/Localizable.strings | 5 +- BookPlayer/Jellyfin/JellyfinRootView.swift | 8 +- .../Library/ItemList/ItemListView.swift | 21 +- .../ItemList/Models/ListStateManager.swift | 5 +- BookPlayer/MainView.swift | 9 +- .../MediaServersView.swift | 330 ++++++++++++++++++ BookPlayer/en.lproj/Localizable.strings | 5 +- 9 files changed, 367 insertions(+), 28 deletions(-) create mode 100644 BookPlayer/MediaServerIntegration/MediaServersView.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 63b4b054d..e84bd566a 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -821,6 +821,7 @@ C451596D6866C856F3E5F7D1 /* IntegrationConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D61F68F0A174C7CA44FE8A /* IntegrationConnectionView.swift */; }; C53864BEFAE4D1CEC668A6B3 /* IntegrationLibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F0D505E060FDAEB6DB68E3 /* IntegrationLibraryListView.swift */; }; CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */; }; + F1A2B3C4D5E6F7089A1B2C3D /* MediaServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* MediaServersView.swift */; }; D080B0A77D9844C3A0737170 /* IntegrationConnectionFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */; }; D6BA8F162A4CA94800C2BD9A /* StorageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */; }; D6BA8F182A4D66CD00C2BD9A /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */; }; @@ -1232,6 +1233,7 @@ 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; 4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHelper.swift; sourceTree = ""; }; 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionViewModelProtocol.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* MediaServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaServersView.swift; sourceTree = ""; }; 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationSettingsView.swift; sourceTree = ""; }; D4C3B2A1F6E54D78B2A1C3D4 /* IntegrationServerPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerPickerView.swift; sourceTree = ""; }; 5126F120258E9F18009965DC /* URL+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+BookPlayer.swift"; sourceTree = ""; }; @@ -3093,6 +3095,7 @@ 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */, 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */, B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */, + A1B2C3D4E5F6A7B8C9D0E1F2 /* MediaServersView.swift */, 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */, 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */, 7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */, @@ -4639,6 +4642,7 @@ F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */, 07416E5AD384927D90BFB6EE /* PasskeyCreatingView.swift in Sources */, CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */, + F1A2B3C4D5E6F7089A1B2C3D /* MediaServersView.swift in Sources */, 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */, C3C998E7EA2919BB438B337C /* IntegrationLibraryItemProtocol.swift in Sources */, 760180C62F243705DE6B3224 /* IntegrationError.swift in Sources */, diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index 64ef295b1..526f7b299 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -10,6 +10,9 @@ import SwiftUI struct AudiobookShelfRootView: View { let connectionService: AudiobookShelfConnectionService + /// When `true`, skips the per-integration server picker on launch. + /// Used when the caller (e.g. MediaServersView) has already activated the desired server. + var skipServerPicker: Bool = false @StateObject private var connectionViewModel: AudiobookShelfConnectionViewModel @@ -27,8 +30,9 @@ struct AudiobookShelfRootView: View { @Environment(\.dismiss) var dismiss @Environment(\.listState) private var listState - init(connectionService: AudiobookShelfConnectionService) { + init(connectionService: AudiobookShelfConnectionService, skipServerPicker: Bool = false) { self.connectionService = connectionService + self.skipServerPicker = skipServerPicker self._connectionViewModel = .init( wrappedValue: .init(connectionService: connectionService) ) @@ -207,7 +211,7 @@ struct AudiobookShelfRootView: View { .task { if connectionService.connections.isEmpty { showConnectionForm = true - } else if connectionService.connections.count > 1, resolvedLibrary == nil { + } else if !skipServerPicker && connectionService.connections.count > 1, resolvedLibrary == nil { showServerPicker = true } else if resolvedLibrary == nil { await loadLibraries() diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index c60e4f1e9..4d63c2355 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -348,7 +348,10 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_username_placeholder" = "Username"; "integration_password_placeholder" = "Password"; "integration_password_remember_me_label" = "Remember Me"; -"integration_add_server_button" = "Add Another Server"; +"integration_add_server_button" = "Add Server"; +"media_servers_title" = "Media Servers"; +"media_servers_choose_type_title" = "Choose Server Type"; +"media_servers_add_prompt" = "Add a media server to get started"; "settings_integration_manage_connection_title" = "Manage Connection"; "integration_internal_error_invalid_url" = "Internal error: Request URL is invalid: %@"; "integration_internal_error_build_url" = "Internal error: Failed to build request URL"; diff --git a/BookPlayer/Jellyfin/JellyfinRootView.swift b/BookPlayer/Jellyfin/JellyfinRootView.swift index 3e103303e..8b914f5d1 100644 --- a/BookPlayer/Jellyfin/JellyfinRootView.swift +++ b/BookPlayer/Jellyfin/JellyfinRootView.swift @@ -10,6 +10,9 @@ import SwiftUI struct JellyfinRootView: View { let connectionService: JellyfinConnectionService + /// When `true`, skips the per-integration server picker on launch. + /// Used when the caller (e.g. MediaServersView) has already activated the desired server. + var skipServerPicker: Bool = false @StateObject private var connectionViewModel: JellyfinConnectionViewModel @@ -27,8 +30,9 @@ struct JellyfinRootView: View { @Environment(\.dismiss) var dismiss @Environment(\.listState) private var listState - init(connectionService: JellyfinConnectionService) { + init(connectionService: JellyfinConnectionService, skipServerPicker: Bool = false) { self.connectionService = connectionService + self.skipServerPicker = skipServerPicker self._connectionViewModel = .init( wrappedValue: .init(connectionService: connectionService) ) @@ -170,7 +174,7 @@ struct JellyfinRootView: View { .task { if connectionService.connections.isEmpty { showConnectionForm = true - } else if connectionService.connections.count > 1, resolvedLibrary == nil { + } else if !skipServerPicker && connectionService.connections.count > 1, resolvedLibrary == nil { showServerPicker = true } else if resolvedLibrary == nil { await loadLibraries() diff --git a/BookPlayer/Library/ItemList/ItemListView.swift b/BookPlayer/Library/ItemList/ItemListView.swift index e3f97ba51..fb0c75624 100644 --- a/BookPlayer/Library/ItemList/ItemListView.swift +++ b/BookPlayer/Library/ItemList/ItemListView.swift @@ -358,25 +358,8 @@ struct ItemListView: View { Button("download_from_url_title", systemImage: "link") { activeAlert = .downloadURL("") } - Button( - String( - format: - "download_from_integration_title".localized, - "Jellyfin" - ), - image: .jellyfinIcon - ) { - listState.activeIntegrationSheet = .jellyfin - } - Button( - String( - format: - "download_from_integration_title".localized, - "AudiobookShelf" - ), - image: .audiobookshelfIcon - ) { - listState.activeIntegrationSheet = .audiobookshelf + Button("media_servers_title".localized, systemImage: "server.rack") { + listState.activeIntegrationSheet = .mediaServers } Button("create_playlist_button", systemImage: "folder.badge.plus") { /// Clean up just in case due to how List(selection:) works under the hood diff --git a/BookPlayer/Library/ItemList/Models/ListStateManager.swift b/BookPlayer/Library/ItemList/Models/ListStateManager.swift index 689817640..00db66403 100644 --- a/BookPlayer/Library/ItemList/Models/ListStateManager.swift +++ b/BookPlayer/Library/ItemList/Models/ListStateManager.swift @@ -22,8 +22,11 @@ final class ListStateManager { public var isSearching = false public var isEditing = false - /// Integration sheet presented at MainView level for state preservation + /// Integration sheet presented at MainView level for state preservation. + /// `.mediaServers` shows the unified server list; `.jellyfin` / `.audiobookshelf` + /// open the corresponding library browser directly. enum IntegrationSheet: String, Identifiable { + case mediaServers case jellyfin case audiobookshelf var id: String { rawValue } diff --git a/BookPlayer/MainView.swift b/BookPlayer/MainView.swift index 5cae0f9dc..3e8a07e53 100644 --- a/BookPlayer/MainView.swift +++ b/BookPlayer/MainView.swift @@ -87,10 +87,15 @@ struct MainView: View { } .sheet(item: $listState.activeIntegrationSheet) { sheet in switch sheet { + case .mediaServers: + MediaServersView( + jellyfinService: jellyfinService, + audiobookshelfService: audiobookshelfService + ) case .jellyfin: - JellyfinRootView(connectionService: jellyfinService) + JellyfinRootView(connectionService: jellyfinService, skipServerPicker: true) case .audiobookshelf: - AudiobookShelfRootView(connectionService: audiobookshelfService) + AudiobookShelfRootView(connectionService: audiobookshelfService, skipServerPicker: true) } } .fullScreenCover(isPresented: playerState.isShowingPlayerBinding) { diff --git a/BookPlayer/MediaServerIntegration/MediaServersView.swift b/BookPlayer/MediaServerIntegration/MediaServersView.swift new file mode 100644 index 000000000..3abe0cd38 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/MediaServersView.swift @@ -0,0 +1,330 @@ +// +// MediaServersView.swift +// BookPlayer +// +// Created by Matthew Alnaser on 2026-04-09. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +/// Unified view that displays all saved media servers (Jellyfin and AudiobookShelf) +/// in a single list. Replaces the separate "Download from Jellyfin" and +/// "Download from AudiobookShelf" menu items with one entry point. +/// +/// Flow: +/// - 0 servers: shows a type picker so the user can add their first server. +/// - 1+ servers: shows the unified list; tapping a server activates it and +/// navigates to its library browser (JellyfinRootView / AudiobookShelfRootView). +/// - "Add Server" opens a type picker, then the connection form for that type. +struct MediaServersView: View { + let jellyfinService: JellyfinConnectionService + let audiobookshelfService: AudiobookShelfConnectionService + + @Environment(\.listState) private var listState + @EnvironmentObject var theme: ThemeViewModel + + /// Drives the confirmation dialog for choosing a server type when adding + @State private var showingTypePicker = false + + /// Controls the add-server sheet for each integration type + @State private var addingJellyfin = false + @State private var addingAudiobookshelf = false + + // MARK: - Server Types + + /// Identifies which integration back-end a server belongs to. + enum ServerType { + case jellyfin + case audiobookshelf + + var displayName: String { + switch self { + case .jellyfin: "Jellyfin" + case .audiobookshelf: "AudiobookShelf" + } + } + + var icon: ImageResource { + switch self { + case .jellyfin: .jellyfinIcon + case .audiobookshelf: .audiobookshelfIcon + } + } + } + + /// A single server entry for the unified list, abstracting over Jellyfin and ABS data models. + struct ServerItem: Identifiable { + let id: String + let serverName: String + let serverUrl: String + let userName: String + let type: ServerType + } + + // MARK: - Computed Properties + + /// Combines all saved servers from both services into one list. + private var allServers: [ServerItem] { + let jellyfinServers = jellyfinService.connections.map { data in + ServerItem( + id: data.id, + serverName: data.serverName, + serverUrl: data.url.absoluteString, + userName: data.userName, + type: .jellyfin + ) + } + let absServers = audiobookshelfService.connections.map { data in + ServerItem( + id: data.id, + serverName: data.serverName, + serverUrl: data.url.absoluteString, + userName: data.userName, + type: .audiobookshelf + ) + } + return jellyfinServers + absServers + } + + // MARK: - Body + + var body: some View { + NavigationStack { + Form { + if allServers.isEmpty { + emptyStateSection + } else { + serverListSection + addServerSection + } + } + .scrollContentBackground(.hidden) + .background(theme.systemBackgroundColor) + .navigationTitle("media_servers_title".localized) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("media_servers_title".localized) + .bpFont(.headline) + .foregroundStyle(theme.primaryColor) + } + ToolbarItem(placement: .cancellationAction) { + Button { + listState.activeIntegrationSheet = nil + } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } + } + .tint(theme.linkColor) + .environmentObject(theme) + // Type picker dialog shown when adding a server while others already exist + .confirmationDialog( + "media_servers_choose_type_title".localized, + isPresented: $showingTypePicker, + titleVisibility: .visible + ) { + Button("Jellyfin") { handleAddServer(type: .jellyfin) } + Button("AudiobookShelf") { handleAddServer(type: .audiobookshelf) } + } + // Add-server sheets — each creates a fresh connection VM in "adding" mode + .sheet(isPresented: $addingJellyfin) { + AddJellyfinServerSheet(service: jellyfinService) + .environmentObject(theme) + } + .sheet(isPresented: $addingAudiobookshelf) { + AddAudiobookShelfServerSheet(service: audiobookshelfService) + .environmentObject(theme) + } + } + + // MARK: - Empty State + + /// Shown when no servers are configured. Offers direct type selection buttons + /// so the user can immediately start connecting their first server. + @ViewBuilder + private var emptyStateSection: some View { + ThemedSection { + Button { + handleAddServer(type: .jellyfin) + } label: { + Label { + Text("Jellyfin") + .foregroundStyle(theme.primaryColor) + } icon: { + Image(.jellyfinIcon) + } + } + Button { + handleAddServer(type: .audiobookshelf) + } label: { + Label { + Text("AudiobookShelf") + .foregroundStyle(theme.primaryColor) + } icon: { + Image(.audiobookshelfIcon) + } + } + } header: { + Text("media_servers_add_prompt".localized) + .foregroundStyle(theme.secondaryColor) + } + } + + // MARK: - Server List + + /// Displays all saved servers from both integrations in a single section. + /// Each row shows the integration icon, server name, user, and URL. + @ViewBuilder + private var serverListSection: some View { + ThemedSection { + ForEach(allServers) { server in + Button { + selectServer(server) + } label: { + HStack(spacing: 12) { + Image(server.type.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(server.serverName) + .foregroundStyle(theme.primaryColor) + Text("\(server.userName) — \(server.serverUrl)") + .font(.caption) + .foregroundStyle(theme.secondaryColor) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(theme.secondaryColor) + } + } + .accessibilityLabel( + "\(server.type.displayName), \(server.serverName), \(server.userName), \(server.serverUrl)" + ) + } + } header: { + Text("media_servers_title".localized) + .foregroundStyle(theme.secondaryColor) + } + } + + // MARK: - Add Server Button + + @ViewBuilder + private var addServerSection: some View { + ThemedSection { + Button { + showingTypePicker = true + } label: { + Label("integration_add_server_button".localized, systemImage: "plus.circle") + } + } + } + + // MARK: - Actions + + /// Activates the selected server in its connection service and navigates + /// to the appropriate library browser (Jellyfin or AudiobookShelf root view). + private func selectServer(_ server: ServerItem) { + switch server.type { + case .jellyfin: + jellyfinService.activateConnection(id: server.id) + listState.activeIntegrationSheet = .jellyfin + case .audiobookshelf: + audiobookshelfService.activateConnection(id: server.id) + listState.activeIntegrationSheet = .audiobookshelf + } + } + + /// Routes the "add server" action based on whether the chosen type + /// already has saved connections. + /// + /// - No existing connections of that type: navigates directly to the root view, + /// which will show its built-in connection form. + /// - Existing connections: opens an in-place add-server sheet so the root view + /// doesn't try to load the existing server's library. + private func handleAddServer(type: ServerType) { + switch type { + case .jellyfin: + if jellyfinService.connections.isEmpty { + listState.activeIntegrationSheet = .jellyfin + } else { + addingJellyfin = true + } + case .audiobookshelf: + if audiobookshelfService.connections.isEmpty { + listState.activeIntegrationSheet = .audiobookshelf + } else { + addingAudiobookshelf = true + } + } + } +} + +// MARK: - Add Server Sheets + +/// Sheet for adding a new Jellyfin server when the user already has existing +/// Jellyfin connections. Wraps `IntegrationConnectionView` in "adding" mode +/// and auto-dismisses when the sign-in completes or the user cancels. +private struct AddJellyfinServerSheet: View { + let service: JellyfinConnectionService + + @StateObject private var viewModel: JellyfinConnectionViewModel + @EnvironmentObject var theme: ThemeViewModel + @Environment(\.dismiss) var dismiss + + init(service: JellyfinConnectionService) { + self.service = service + // Create VM then switch to "adding" mode (blank form, disconnected state) + let vm = JellyfinConnectionViewModel(connectionService: service) + vm.handleAddServerAction() + self._viewModel = .init(wrappedValue: vm) + } + + var body: some View { + NavigationStack { + IntegrationConnectionView(viewModel: viewModel, integrationName: "Jellyfin") + .navigationBarTitleDisplayMode(.inline) + } + .tint(theme.linkColor) + .environmentObject(theme) + // isAddingServer flips to false on successful sign-in or cancel + .onChange(of: viewModel.isAddingServer) { _, isAdding in + if !isAdding { dismiss() } + } + } +} + +/// Sheet for adding a new AudiobookShelf server when existing ABS connections exist. +/// Same pattern as `AddJellyfinServerSheet`. +private struct AddAudiobookShelfServerSheet: View { + let service: AudiobookShelfConnectionService + + @StateObject private var viewModel: AudiobookShelfConnectionViewModel + @EnvironmentObject var theme: ThemeViewModel + @Environment(\.dismiss) var dismiss + + init(service: AudiobookShelfConnectionService) { + self.service = service + let vm = AudiobookShelfConnectionViewModel(connectionService: service) + vm.handleAddServerAction() + self._viewModel = .init(wrappedValue: vm) + } + + var body: some View { + NavigationStack { + IntegrationConnectionView(viewModel: viewModel, integrationName: "AudiobookShelf") + .navigationBarTitleDisplayMode(.inline) + } + .tint(theme.linkColor) + .environmentObject(theme) + .onChange(of: viewModel.isAddingServer) { _, isAdding in + if !isAdding { dismiss() } + } + } +} diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index a74fb1374..20feaca93 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -349,7 +349,10 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_username_placeholder" = "Username"; "integration_password_placeholder" = "Password"; "integration_password_remember_me_label" = "Remember Me"; -"integration_add_server_button" = "Add Another Server"; +"integration_add_server_button" = "Add Server"; +"media_servers_title" = "Media Servers"; +"media_servers_choose_type_title" = "Choose Server Type"; +"media_servers_add_prompt" = "Add a media server to get started"; "settings_integration_manage_connection_title" = "Manage Connection"; "integration_internal_error_invalid_url" = "Internal error: Request URL is invalid: %@"; "integration_internal_error_build_url" = "Internal error: Failed to build request URL";