Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions BookPlayer.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -820,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 */; };
Expand Down Expand Up @@ -1231,7 +1233,9 @@
465D87532D31965100A4AA47 /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHelper.swift; sourceTree = "<group>"; };
4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionViewModelProtocol.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6A7B8C9D0E1F2 /* MediaServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaServersView.swift; sourceTree = "<group>"; };
4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationSettingsView.swift; sourceTree = "<group>"; };
D4C3B2A1F6E54D78B2A1C3D4 /* IntegrationServerPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerPickerView.swift; sourceTree = "<group>"; };
5126F120258E9F18009965DC /* URL+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+BookPlayer.swift"; sourceTree = "<group>"; };
5CBB29522163A17F00E3A9FF /* ZIPFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZIPFoundation.framework; path = Carthage/Build/iOS/ZIPFoundation.framework; sourceTree = "<group>"; };
620C73C7275DA00300D495AA /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1835,6 +1839,7 @@
35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */,
A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */,
6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */,
D4C3B2A1F6E54D78B2A1C3D4 /* IntegrationServerPickerView.swift */,
);
path = "Connection Screen";
sourceTree = "<group>";
Expand Down Expand Up @@ -3090,6 +3095,7 @@
3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */,
4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */,
B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */,
A1B2C3D4E5F6A7B8C9D0E1F2 /* MediaServersView.swift */,
7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */,
2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */,
7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */,
Expand Down Expand Up @@ -4313,6 +4319,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 */,
Expand Down Expand Up @@ -4635,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 */,
Expand Down
38 changes: 35 additions & 3 deletions BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -27,15 +30,17 @@ 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)
)
}

@State private var showLibraryPicker = false
@State private var showConnectionForm = false
@State private var showServerPicker = false
@State private var isLoadingLibraries = false

private var isReady: Bool {
Expand Down Expand Up @@ -173,14 +178,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 !skipServerPicker && connectionService.connections.count > 1, resolvedLibrary == nil {
showServerPicker = true
} else if resolvedLibrary == nil {
await loadLibraries()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

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
Expand Down Expand Up @@ -48,13 +61,21 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro
@MainActor
func handleSignInAction() async throws {
do {
let wasAdding = isAddingServer
try await connectionService.signIn(
username: form.username,
password: form.password,
serverUrl: form.serverUrl,
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
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,7 +38,7 @@ class AudiobookShelfConnectionService: BPLogger {
}

func setup() {
reloadConnection()
reloadConnections()
}

/// Pings the server to verify it exists and returns the server version
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading