From a6fbf61c0f82433336f5e0a6fb7eb2bbcc06a399 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Mon, 18 May 2026 12:01:17 -0500 Subject: [PATCH 01/17] Update privacy policy version and effective date --- PRIVACY_POLICY.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index adfa33620..53f53288f 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,16 +1,15 @@ # Privacy Policy -###### version 2.0.0 of This Agreement was created on May 25, 2023. +###### version 2.0.1 of This Agreement was created on May 18, 2026. BookPlayer is an audio book player app, and we believe that an audio book player should provide a good listening experience which also includes peace of mind when it comes to data responsibility. -This Privacy Policy explains what information we collect and why. It applies to the iOS, iPadOS and MacOS apps. +This Privacy Policy explains what information we collect and why. It applies to the iOS, iPadOS, MacOS, WatchOS and Android apps. ## What information do you collect about me? For our base functionality, BookPlayer collects two types of information: - App crash data and performance (using [Sentry](https://sentry.io)). -- App usage (using [TelemetryDeck](https://telemetrydeck.com/privacy)). If you choose to create an account in our app, we store the email address that you share via the Sign in with Apple integration, @@ -47,4 +46,4 @@ We may need to update this Policy to reflect changes in our provided services, o If you have any questions or concerns about our Privacy Policy, please contact us at [support@bookplayer.app](mailto:support@bookplayer.app) -Effective Date: 27 May 2023 \ No newline at end of file +Effective Date: 18 May 2026 From 796a81ed9c4e9e81ec187f8798d06662fc351372 Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Sat, 30 May 2026 10:28:46 -0700 Subject: [PATCH 02/17] Add multi-server support for Jellyfin and AudiobookShelf (#1491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update multi-server work for current develop Brings the branch up to date with develop (Core Data v11 migration, custom-headers PR #1513, sticky-sort, end-of-chapter fix, etc.) and folds in a few iterations on top: - A single \"Media Servers\" entry in the import menu in place of the separate Jellyfin / AudiobookShelf items. Lists every saved server from both integrations with type icons; \"Add Server\" picks the type and goes through the connection flow. - Tap-a-server presents the per-integration library browser as a sheet on top of the unified list, with a leading \"Media Servers\" back button that closes the inner sheet and lands you back on the list. Earlier rework tried to push via NavigationStack but pushing a TabView in a NavigationStack auto-pops on iOS 26 — sheet-on-sheet sidesteps that entirely. - Library picker no longer traps you when there are 2+ libraries and none chosen yet (Cancel is unconditional and falls back to dismissing the whole sheet when there's no library to dismiss to). - Connection-form xmark closes the form instead of dismissing the whole integration view. - Empty-state rows restyled to match the populated server-row layout so they read as tappable. Brand strings consolidated through ServerType.displayName, fonts unified on bpFont. * Jellyfin: preserve customHeaders when switching/deleting connections activateConnection and deleteConnection were calling createClient without passing the active connection's customHeaders, so any server behind a Cloudflare Access (or similar) proxy stopped responding after the user switched servers or signed out of one of several — the next request went out missing the required CF-Access-Client-* headers and was rejected until the app was relaunched (the only path that did pass them was reloadConnections at launch). Centralize the client-rebuild in rebuildClient(for:) so future call sites can't drop headers, and capture the old client before mutating state so the fire-and-forget signOut doesn't race the new client assignment. * Integration root: don't auto-push to add-server form on load failure When fetchLibraries (or the equivalent ABS call) threw, the resulting alert had a single OK button that set showConnectionForm = true. That flow is the right one for "the user wants to sign in again" but it's the wrong one for everything else (transient network, expired token, custom-header proxy hiccup). Users who hit it would re-add their server and end up with a duplicate because the byte-exact URL dedup in the connection service didn't treat trailing-slash / scheme variants as the same connection. Replace the single OK with three explicit buttons — Sign In (the old behavior, now opt-in), Retry (re-call loadLibraries), and Cancel (close the integration sheet, return to the Media Servers list). Same fix on both JellyfinRootView and AudiobookShelfRootView. New "integration_retry_button" localization key seeded with English literals across all 26 .lproj files matching the rest of the fork's localization pattern. * Make connect/sign-in tasks cancellable on sheet dismissal IntegrationConnectionView's onConnect / onSignIn / onStartQuickConnect each kicked off an unstructured Task that the view had no handle to. If the user dismissed the sheet (swipe-to-dismiss or hitting the close button) while a sign-in was in flight, the Task kept running and could persist a connection to the service after the user thought they gave up on the operation. Store each action's Task in @State and cancel it from .onDisappear. Catch CancellationError separately so the dismissal doesn't get surfaced as an alert. The View-side cancel alone isn't sufficient — Swift cooperative cancellation only flags the Task, it doesn't synchronously interrupt an awaiting call, and the service methods kept persisting after the network call returned. Add try Task.checkCancellation() inside JellyfinConnectionService.signIn(username:password:...) and AudiobookShelfConnectionService.signIn(...) immediately after the auth round-trip returns, so the persist step is skipped on cancel. The matching call inside signInWithQuickConnect was already added in the Quick-Connect cancel-race fix. * AudiobookShelf: trim credentials and normalize the server URL Two small input-validation gaps: H18 — pingServer accepts a schemeless string like "abs.example.com" verbatim because URL(string:) parses it as a relative path. The subsequent appendingPathComponent("ping") yields "abs.example.com/ping" which URLSession returns an opaque .unsupportedURL for, and the user has no idea they needed to prepend https://. Normalize in the view model before calling pingServer: trim whitespace, prepend https:// if no scheme. Mirror the result back into the form field so the user can see what we actually used. H19 — Username and password were passed to ABS auth verbatim, so an autocorrect-inserted trailing space on the username is enough to fail otherwise-correct credentials with a 401. Trim both in handleSignInAction. * Canonicalize URLs before deduplicating saved connections Foundation URL equality is byte-exact, so https://server vs https://server/ vs https://server:443 vs https://Server were treated as four different connections by the deduplication in JellyfinConnectionService and AudiobookShelfConnectionService. Users hitting the C2 error alert in either integration would commonly re-add their server slightly differently (often with a trailing slash) and end up with two saved entries, one of which was broken. Add URL.canonicalDedupKey to BookPlayerKit's URL extension: lowercase scheme/host, drop default ports (80/443), strip the trailing slash on non-root paths, and drop userinfo/query/fragment. Both services' removeAll-then-append dedup blocks now compare canonical keys. * Real 401 re-auth flow (C2 architectural) Token-expiry / mid-session-unauthorized had no first-class handling — 401/403 propagated as a generic load failure, the alert offered a single OK that pushed the user into the add-server form, and users who took it ended up with duplicate connections (the C2-minimum fix just rearranged the alert buttons to stop the worst case). Distinguish session-expired from generic failures end to end: - New IntegrationError.sessionExpired(serverName:) carries the offending server name so the alert message is specific ("Your session for Library Server expired. Sign in again to continue."). - Jellyfin: wrap the api-client `send` to catch APIError.unacceptableStatusCode(401|403) and throw .sessionExpired — only when there's actually a saved connection (so pre-sign-in probes inside `findServer` aren't mis-classified). - ABS: centralize HTTP status validation in validateAuthenticatedResponse(_:) and use it across every authenticated data-fetch site (fetchLibraries, fetchItems, fetchItemDetails, search, …). Same connection-presence guard. The signIn path keeps its existing 401 → URLError(.userAuthenticationRequired) mapping ("wrong password" is a different UX than "your session expired"). - Both RootView alerts (JellyfinRootView, AudiobookShelfRootView) special-case `IntegrationError.isSessionExpired`: show Sign In + Cancel only, skip the Retry button that's meaningless when the token's revoked. - Both signIn implementations now preserve the existing connection's id and selectedLibraryId when re-authing on the same (canonical-url, userID) pair — so when the user signs in again they land back on the same library context they had before, not a fresh record. This is the half of the fix that makes "Sign In again" actually transparent UX rather than "you lost your saved library choice". New localization key `integration_error_session_expired` seeded with English literals across all 26 .lproj files. * Address feedback * Replace settings options * Address more feedback * Update localizations * Address feedback * change icon --------- Co-authored-by: Gianni Carlo --- BookPlayer.xcodeproj/project.pbxproj | 6 +- .../AudiobookShelfRootView.swift | 86 ++-- .../AudiobookShelfConnectionViewModel.swift | 167 ++++++- .../AudiobookShelfConnectionData.swift | 8 +- .../AudiobookShelfConnectionService.swift | 254 ++++++---- BookPlayer/Base.lproj/Localizable.strings | 4 + .../JellyfinConnectionViewModel.swift | 141 +++++- BookPlayer/Jellyfin/JellyfinRootView.swift | 84 ++-- .../Network/JellyfinConnectionData.swift | 8 +- .../Network/JellyfinConnectionService.swift | 284 +++++++++--- .../Library/ItemList/ItemListView.swift | 21 +- .../ItemList/Models/ListStateManager.swift | 7 +- BookPlayer/MainView.swift | 12 +- .../IntegrationConnectedView.swift | 3 + .../IntegrationConnectionView.swift | 86 ++-- ...tegrationConnectionViewModelProtocol.swift | 55 ++- .../IntegrationError.swift | 15 + .../MediaServersView.swift | 432 ++++++++++++++++++ BookPlayer/Search/SearchView.swift | 12 +- .../SettingsIntegrationsSectionView.swift | 8 +- BookPlayer/Settings/SettingsScreen.swift | 2 +- BookPlayer/Settings/SettingsView.swift | 22 +- BookPlayer/ar.lproj/Localizable.strings | 4 + BookPlayer/ca.lproj/Localizable.strings | 4 + BookPlayer/cs.lproj/Localizable.strings | 4 + BookPlayer/da.lproj/Localizable.strings | 4 + BookPlayer/de.lproj/Localizable.strings | 4 + BookPlayer/el.lproj/Localizable.strings | 4 + BookPlayer/en.lproj/Localizable.strings | 4 + BookPlayer/es.lproj/Localizable.strings | 4 + BookPlayer/fi.lproj/Localizable.strings | 4 + BookPlayer/fr.lproj/Localizable.strings | 4 + BookPlayer/hu.lproj/Localizable.strings | 4 + BookPlayer/it.lproj/Localizable.strings | 4 + BookPlayer/ja.lproj/Localizable.strings | 4 + BookPlayer/nb.lproj/Localizable.strings | 4 + BookPlayer/nl.lproj/Localizable.strings | 4 + BookPlayer/pl.lproj/Localizable.strings | 4 + BookPlayer/pt-BR.lproj/Localizable.strings | 4 + BookPlayer/pt-PT.lproj/Localizable.strings | 4 + BookPlayer/ro.lproj/Localizable.strings | 4 + BookPlayer/ru.lproj/Localizable.strings | 4 + BookPlayer/sk-SK.lproj/Localizable.strings | 4 + BookPlayer/sv.lproj/Localizable.strings | 4 + BookPlayer/tr.lproj/Localizable.strings | 4 + BookPlayer/uk.lproj/Localizable.strings | 4 + BookPlayer/zh-Hans.lproj/Localizable.strings | 4 + .../Mocks/KeychainServiceMock.swift | 2 +- Shared/Extensions/URL+BookPlayer.swift | 34 ++ Shared/Services/KeychainService.swift | 16 +- 50 files changed, 1526 insertions(+), 343 deletions(-) create mode 100644 BookPlayer/MediaServerIntegration/MediaServersView.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 92b98ca90..76c6e35f2 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -632,6 +632,7 @@ 69343D332133844D000C425E /* VoiceOverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D322133844D000C425E /* VoiceOverService.swift */; }; 69343D36213A07B4000C425E /* VoiceOverServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D35213A07B4000C425E /* VoiceOverServiceTest.swift */; }; 760180C62F243705DE6B3224 /* IntegrationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */; }; + D5E6F7081A2B3C4D5E6F7081 /* MediaServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */; }; 78D8C50324731BBDF9E9281E /* LibraryOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBF775DDE66D0AA08818D45 /* LibraryOptionsView.swift */; }; 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */; }; 8322A3C6479B099448C63C47 /* IntegrationLibraryViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */; }; @@ -1261,6 +1262,7 @@ 41FCA32625E87EC600BFB9E6 /* Audiobook Player 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Audiobook Player 4.xcdatamodel"; sourceTree = ""; }; 424AE6DFF6B641DB69DF3D78 /* IntegrationAudiobookDetailsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationAudiobookDetailsView.swift; sourceTree = ""; }; 43D61F68F0A174C7CA44FE8A /* IntegrationConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionView.swift; sourceTree = ""; }; + B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationCustomHeadersSectionView.swift; sourceTree = ""; }; 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeInlineTip.swift; sourceTree = ""; }; 465D87512D3195D600A4AA47 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; @@ -1748,7 +1750,7 @@ A3511F737DCDEE7A12B713AE /* IntegrationServerFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerFoundView.swift; sourceTree = ""; }; A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerInformationSectionView.swift; sourceTree = ""; }; B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationError.swift; sourceTree = ""; }; - B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationCustomHeadersSectionView.swift; sourceTree = ""; }; + D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaServersView.swift; sourceTree = ""; }; B25063B02760AB32CB8F38E0 /* IntegrationLibraryGridView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryGridView.swift; sourceTree = ""; }; C084D4BC05BF6B413C86C6F0 /* PasskeyCreatingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyCreatingView.swift; sourceTree = ""; }; C30B085E209654E3003F325B /* UIColor+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+BookPlayer.swift"; sourceTree = ""; }; @@ -3169,6 +3171,7 @@ 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */, 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */, B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */, + D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */, 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */, 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */, A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */, @@ -4736,6 +4739,7 @@ 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */, C3C998E7EA2919BB438B337C /* IntegrationLibraryItemProtocol.swift in Sources */, 760180C62F243705DE6B3224 /* IntegrationError.swift in Sources */, + D5E6F7081A2B3C4D5E6F7081 /* MediaServersView.swift in Sources */, 18D0AD99D1AAA10976F75C11 /* BPNavigation.swift in Sources */, 9236C57F833C70652BDD1FA3 /* TabEditingEnvironmentKey.swift in Sources */, 11A972EC3C4426DD4D02867E /* TagsFlowLayout.swift in Sources */, diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index 377535d80..6104d5deb 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -128,9 +128,40 @@ struct AudiobookShelfRootView: View { "error_title".localized, isPresented: .init(get: { loadError != nil }, set: { if !$0 { loadError = nil } }), actions: { - Button("ok_button".localized) { - loadError = nil - showConnectionForm = true + // Library/identity loads can fail for many reasons (transient network, token + // expired, server moved, custom-header proxy issue). The previous "OK" button + // unconditionally pushed the user into the add-server form, which led people + // to re-add their server and end up with a duplicate. + // + // For the specific "session expired" case (401/403 mid-session) we already know + // the URL and customHeaders are fine — just the token is stale — so we show a + // narrower set of actions that funnel into the existing connection's sign-in + // form (which preserves customHeaders + selectedLibraryId). + if (loadError as? IntegrationError)?.isSessionExpired == true { + // Session expired: Retry would just hit the same 401, so omit it. + Button("integration_connection_details_title".localized) { + loadError = nil + connectionViewModel.signInFlow = nil + showConnectionForm = true + } + Button("cancel_button".localized, role: .cancel) { + loadError = nil + dismiss() + } + } else { + Button("integration_retry_button".localized) { + loadError = nil + Task { await loadLibraries() } + } + Button("integration_connection_details_title".localized) { + loadError = nil + connectionViewModel.signInFlow = nil + showConnectionForm = true + } + Button("cancel_button".localized, role: .cancel) { + loadError = nil + dismiss() + } } }, message: { Text(loadError?.localizedDescription ?? "") } @@ -139,17 +170,13 @@ struct AudiobookShelfRootView: View { NavigationStack { IntegrationConnectionView(viewModel: connectionViewModel, integrationName: "AudiobookShelf") .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - Button { dismiss() } label: { - Image(systemName: "xmark") - .foregroundStyle(theme.linkColor) - } - } - if connectionService.connection != nil { - ToolbarItemGroup(placement: .confirmationAction) { - Button("integration_connect_button") { - showConnectionForm = false - Task { await loadLibraries() } + // Outer X only when we're NOT in Add Server mode — IntegrationConnectionView's + // own Cancel button handles that case (and just dismisses the form sheet). + if !connectionViewModel.isAddingServer { + ToolbarItemGroup(placement: .cancellationAction) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) } } } @@ -158,11 +185,9 @@ struct AudiobookShelfRootView: View { } .tint(theme.linkColor) .environmentObject(theme) - .interactiveDismissDisabled() } .sheet(isPresented: $showLibraryPicker) { libraryPickerSheet - .interactiveDismissDisabled(resolvedLibrary == nil) } .environmentObject(theme) .onChange(of: availableLibraries) { _, libraries in @@ -170,16 +195,14 @@ struct AudiobookShelfRootView: View { showLibraryPicker = true } } - .onChange(of: connectionViewModel.connectionState) { _, newValue in - if newValue == .connected { - showConnectionForm = false - if resolvedLibrary == nil { - Task { await loadLibraries() } - } - } + .onChange(of: connectionViewModel.signInCompletedAt) { _, newValue in + guard newValue != nil else { return } + showConnectionForm = false + resolvedLibrary = nil + Task { await loadLibraries() } } .task { - if connectionService.connection == nil { + if connectionService.connections.isEmpty { showConnectionForm = true } else if resolvedLibrary == nil { await loadLibraries() @@ -226,9 +249,16 @@ struct AudiobookShelfRootView: View { .navigationTitle("library_title".localized) .navigationBarTitleDisplayMode(.inline) .toolbar { - if resolvedLibrary != nil { - ToolbarItem(placement: .cancellationAction) { - Button("done_title".localized) { showLibraryPicker = false } + ToolbarItem(placement: .cancellationAction) { + Button("cancel_button".localized) { + // No library chosen yet — there's nothing to browse, so back out + // to the server picker rather than leave the user on a disabled view. + if resolvedLibrary == nil { + showLibraryPicker = false + dismiss() + } else { + showLibraryPicker = false + } } } } @@ -338,7 +368,7 @@ private struct AudiobookShelfTabRoot: View { } } .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { + ToolbarItem(placement: .cancellationAction) { Menu { Button { showConnectionDetails = true diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift index f97772060..435eff782 100644 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift +++ b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift @@ -16,28 +16,68 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro @Published var form: IntegrationConnectionFormViewModel @Published var viewMode: IntegrationViewMode = .regular - @Published var connectionState: IntegrationConnectionState + @Published var signInFlow: SignInStep? + @Published private(set) var signInCompletedAt: Date? + @Published var isAddingServer: Bool = false private var disposeBag = Set() + /// When non-nil, the VM operates on this specific connection (read its data on init, + /// route logout / custom-headers updates to its id) regardless of which connection is + /// active in the service. Used by `MediaServersView`'s per-server info sheet so editing + /// one server doesn't change the active connection. + let targetConnectionId: String? + + /// URL captured at `handleConnectAction` time, after normalization and a successful + /// `pingServer`. `handleSignInAction` uses this rather than `form.serverUrl` so that + /// any edit the user makes to the form between Connect and Sign In can't redirect the + /// credentials to a server we never validated. Mirrors Jellyfin's `pendingServer`. + private var pingedURL: String? + + var servers: [IntegrationServerInfo] { + connectionService.connections.map { data in + IntegrationServerInfo( + id: data.id, + serverName: data.serverName, + serverUrl: data.url.absoluteString, + userName: data.userName + ) + } + } + init( connectionService: AudiobookShelfConnectionService, - mode: IntegrationViewMode = .regular + mode: IntegrationViewMode = .regular, + connectionId: String? = nil ) { self.connectionService = connectionService + self.targetConnectionId = connectionId self._viewMode = .init(initialValue: mode) let form = IntegrationConnectionFormViewModel() - if let data = connectionService.connection { - form.setValues( - url: data.url.absoluteString, - serverName: data.serverName, - userName: data.userName, - customHeaders: data.customHeaders - ) - self._connectionState = .init(initialValue: .connected) - } else { - self._connectionState = .init(initialValue: .disconnected) + switch mode { + case .addServer: + // Dedicated Add Server flow: start clean, no pre-population from active connection. + self._signInFlow = .init(initialValue: .enteringServerURL) + self._isAddingServer = .init(initialValue: true) + case .regular, .viewDetails: + // If `connectionId` is provided, pull from that specific saved connection so this VM + // can edit a non-active server. Otherwise fall back to whichever one is active. + let data = connectionId.flatMap { id in + connectionService.connections.first(where: { $0.id == id }) + } ?? connectionService.connection + + if let data { + form.setValues( + url: data.url.absoluteString, + serverName: data.serverName, + userName: data.userName, + customHeaders: data.customHeaders + ) + self._signInFlow = .init(initialValue: nil) + } else { + self._signInFlow = .init(initialValue: .enteringServerURL) + } } self._form = .init(initialValue: form) @@ -45,26 +85,48 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro @MainActor func handleConnectAction() async throws { + let normalizedURL = Self.normalizedServerURL(form.serverUrl) + // Reflect the normalization back into the form so the user sees what we actually used. + if normalizedURL != form.serverUrl { + form.serverUrl = normalizedURL + } let serverName = try await connectionService.pingServer( - at: form.serverUrl, + at: normalizedURL, customHeaders: form.customHeadersDictionary() ) - connectionState = .foundServer + pingedURL = normalizedURL + signInFlow = .enteringCredentials form.serverName = serverName } @MainActor func handleSignInAction() async throws { + guard let serverUrl = pingedURL else { + throw IntegrationError.urlMalformed(nil) + } + // Drop the captured URL after this method runs — on success the connection is + // persisted, on failure the user must re-validate via Connect anyway. + defer { pingedURL = nil } do { + // ABS auth doesn't trim whitespace server-side, so iOS autocorrect inserting a trailing + // space on the username is enough to silently reject otherwise-correct credentials. + let username = form.username.trimmingCharacters(in: .whitespacesAndNewlines) + let password = form.password.trimmingCharacters(in: .whitespacesAndNewlines) try await connectionService.signIn( - username: form.username, - password: form.password, - serverUrl: form.serverUrl, + username: username, + password: password, + serverUrl: serverUrl, serverName: form.serverName, customHeaders: form.customHeadersDictionary() ) - connectionState = .connected + isAddingServer = false + + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + signInFlow = nil + signInCompletedAt = Date() } catch let error as IntegrationError { throw error } catch { @@ -72,15 +134,78 @@ final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelPro } } + /// Normalize the user-typed server URL before we send a request: + /// - Trim whitespace. + /// - Prepend `https://` if no scheme is present, so `URL(string:)` parses it as an absolute + /// URL rather than a relative path. Without this, "abs.example.com" becomes a URL with + /// a nil host and the eventual /ping POST fails with an opaque URLError. + static func normalizedServerURL(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + let lowered = trimmed.lowercased() + if lowered.hasPrefix("http://") || lowered.hasPrefix("https://") { + return trimmed + } + return "https://" + trimmed + } + @MainActor func handleSignOutAction() { - connectionService.deleteConnection() + // If this VM was scoped to a specific connection, route the deletion there. + // Otherwise act on the active one (the cog → Connection Details flow). + if let targetId = targetConnectionId { + connectionService.deleteConnection(id: targetId) + } else { + connectionService.deleteConnection() + } form = IntegrationConnectionFormViewModel() - connectionState = .disconnected + signInFlow = connectionService.connections.isEmpty ? .enteringServerURL : nil + 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() + signInFlow = .enteringServerURL + } 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 + signInFlow = .enteringServerURL + form = IntegrationConnectionFormViewModel() + } + + func handleCancelAddServerAction() { + isAddingServer = false + signInFlow = nil + // Drop any URL captured from a half-finished Connect → Sign In flow so a stale + // value can't get reused by a later sign-in attempt. + pingedURL = nil + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } } @MainActor func handleCustomHeadersUpdate() { - connectionService.updateCustomHeaders(form.customHeadersDictionary()) + let headers = form.customHeadersDictionary() + if let targetId = targetConnectionId { + connectionService.updateCustomHeaders(id: targetId, headers) + } else { + connectionService.updateCustomHeaders(headers) + } } } diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift index b301a8b64..b8d531ffb 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift @@ -8,7 +8,8 @@ import Foundation -struct AudiobookShelfConnectionData: Codable { +struct AudiobookShelfConnectionData: Codable, Identifiable { + let id: String let url: URL let serverName: String let userID: String @@ -18,10 +19,11 @@ struct AudiobookShelfConnectionData: Codable { var customHeaders: [String: String] = [:] enum CodingKeys: String, CodingKey { - case url, serverName, userID, userName, apiToken, selectedLibraryId, customHeaders + case id, url, serverName, userID, userName, apiToken, selectedLibraryId, customHeaders } init( + id: String = UUID().uuidString, url: URL, serverName: String, userID: String, @@ -30,6 +32,7 @@ struct AudiobookShelfConnectionData: Codable { selectedLibraryId: String? = nil, customHeaders: [String: String] = [:] ) { + self.id = id self.url = url self.serverName = serverName self.userID = userID @@ -41,6 +44,7 @@ struct AudiobookShelfConnectionData: Codable { 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) diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift index bf95e8bdb..0db6eabd6 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift @@ -9,15 +9,29 @@ import BookPlayerKit import Foundation +@MainActor @Observable class AudiobookShelfConnectionService: BPLogger { - private let keychainService: KeychainServiceProtocol + private static let activeConnectionIDKey = "audiobookshelf_active_connection_id" - var connection: AudiobookShelfConnectionData? - private var urlSession: URLSession + private nonisolated let keychainService: KeychainServiceProtocol + var connections: [AudiobookShelfConnectionData] = [] + var connection: AudiobookShelfConnectionData? { + if let activeConnectionID, + let active = connections.first(where: { $0.id == activeConnectionID }) { + return active + } + return connections.first + } + private let 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()) { + nonisolated init(keychainService: KeychainServiceProtocol = KeychainService()) { self.keychainService = keychainService let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 15 @@ -25,7 +39,7 @@ class AudiobookShelfConnectionService: BPLogger { } func setup() { - reloadConnection() + reloadConnections() } /// Pings the server to verify it exists and returns the server version @@ -46,10 +60,13 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) + // `pingServer` is an unauthenticated probe (typically for Add Server). Do NOT route + // its non-2xx responses through `validateAuthenticatedResponse`, which would mis-throw + // `.sessionExpired(serverName: )` and push the user toward + // re-authenticating an unrelated connection. guard let httpResponse = response as? HTTPURLResponse else { throw IntegrationError.unexpectedResponse(code: nil) } - guard (200...299).contains(httpResponse.statusCode) else { throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) } @@ -89,6 +106,10 @@ class AudiobookShelfConnectionService: BPLogger { request.httpBody = try JSONSerialization.data(withJSONObject: credentials) let (data, response) = try await urlSession.data(for: request) + // Bail out before persisting if the caller cancelled while the auth round-trip was + // in flight (e.g. the user swiped the sheet down). Otherwise the cancelled sign-in + // still ends up saved. + try Task.checkCancellation() guard let httpResponse = response as? HTTPURLResponse else { throw IntegrationError.unexpectedResponse(code: nil) @@ -110,45 +131,92 @@ class AudiobookShelfConnectionService: BPLogger { throw IntegrationError.unexpectedResponse(code: nil) } + // On re-auth, preserve the existing connection's id + selectedLibraryId so the user + // picks up exactly where they left off (same library context, same outbound references) + // instead of being reset to a fresh record. + let existing = connections.first { + $0.url.canonicalDedupKey == url.canonicalDedupKey && $0.userID == userID + } let connectionData = AudiobookShelfConnectionData( + id: existing?.id ?? UUID().uuidString, url: url, serverName: serverName, userID: userID, userName: username, apiToken: apiToken, + selectedLibraryId: existing?.selectedLibraryId, customHeaders: customHeaders ) - try keychainService.set( - connectionData, - key: .audiobookshelfConnection - ) + // Deduplicate on canonical-url + userID so that trailing-slash, port, and scheme-case + // variants of the same logical server don't accumulate as separate connections. + connections.removeAll { + $0.url.canonicalDedupKey == url.canonicalDedupKey && $0.userID == userID + } + connections.append(connectionData) + activeConnectionID = connectionData.id + saveConnections() - self.connection = connectionData + // If we just replaced a previous token for this same logical server, revoke the old + // one server-side so it doesn't linger in ABS's token list. + if let stale = existing, stale.apiToken != apiToken { + revokeTokenInBackground(connection: stale) + } } func updateCustomHeaders(_ headers: [String: String]) { - guard var data = connection else { return } - data.customHeaders = headers - connection = data - try? keychainService.set(data, key: .audiobookshelfConnection) + guard let activeID = connection?.id else { return } + updateCustomHeaders(id: activeID, headers) + } + + /// Persist `headers` to the connection with the given id, regardless of which is active. + func updateCustomHeaders(id: String, _ headers: [String: String]) { + guard let index = connections.firstIndex(where: { $0.id == id }) else { return } + connections[index].customHeaders = headers + 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) { + guard connections.contains(where: { $0.id == id }) else { return } + activeConnectionID = id + } + + func deleteConnection(id: String) { + // Capture the connection BEFORE removing so we can fire a server-side logout. + let removed = connections.first(where: { $0.id == id }) + + connections.removeAll { $0.id == id } + + if activeConnectionID == id { + activeConnectionID = connections.first?.id + } + + if connections.isEmpty { + do { + try keychainService.remove(.audiobookshelfConnection) + } catch { + Self.logger.warning("failed to remove connection data from keychain: \(error)") + } + } else { + saveConnections() } - connection = nil + if let removed { + revokeTokenInBackground(connection: removed) + } + } + + func deleteConnection() { + if let id = connection?.id { + deleteConnection(id: id) + } } public func fetchLibraries() async throws -> [AudiobookShelfLibrary] { @@ -164,13 +232,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() let librariesResponse = try decoder.decode(AudiobookShelfLibrariesResponse.self, from: data) @@ -247,13 +309,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() let itemsResponse = try decoder.decode(AudiobookShelfItemsResponse.self, from: data) @@ -299,13 +355,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() let authorResponse = try decoder.decode(AudiobookShelfAuthorWithItemsResponse.self, from: data) @@ -328,13 +378,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() return try decoder.decode(AudiobookShelfLibraryFilterData.self, from: data) @@ -371,13 +415,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() let collectionsResponse = try decoder.decode(AudiobookShelfCollectionsResponse.self, from: data) @@ -399,13 +437,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() return try decoder.decode(AudiobookShelfCollection.self, from: data) @@ -452,13 +484,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() let searchResponse = try decoder.decode(AudiobookShelfSearchResponse.self, from: data) @@ -482,13 +508,7 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw IntegrationError.unexpectedResponse(code: nil) - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) - } + _ = try validateAuthenticatedResponse(response) let decoder = JSONDecoder() let detailsResponse = try decoder.decode(AudiobookShelfItemDetailsResponse.self, from: data) @@ -527,16 +547,76 @@ class AudiobookShelfConnectionService: BPLogger { return request } - 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) } + if connections.count != storedConnections.count { + saveConnections() + } + } 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) + } + + /// Fire-and-forget POST to ABS's `/logout` to revoke a connection's apiToken server-side. + /// Called after we drop a connection locally (delete, or re-auth replacing a stale token) + /// so the server doesn't accumulate orphan tokens. Failures are intentionally swallowed — + /// the local state has already moved on and there's no UX-meaningful recovery. + private func revokeTokenInBackground(connection: AudiobookShelfConnectionData) { + let url = connection.url.appendingPathComponent("logout") + let apiToken = connection.apiToken + let headers = connection.customHeaders + Task { [urlSession] in + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + _ = try? await urlSession.data(for: request) + } + } + + /// Validates the HTTP response from an authenticated data-fetch call. + /// + /// - Returns the `HTTPURLResponse` on 2xx. + /// - Throws `IntegrationError.sessionExpired` on 401/403 **when a saved connection exists**, + /// so the UI can offer a Sign-In-only recovery path. Pre-sign-in probes (`pingServer`) + /// fall through to the generic path instead of pretending a session expired. + /// - Throws `IntegrationError.unexpectedResponse` otherwise. + private func validateAuthenticatedResponse(_ response: URLResponse) throws -> HTTPURLResponse { + guard let http = response as? HTTPURLResponse else { + throw IntegrationError.unexpectedResponse(code: nil) + } + if let serverName = connection?.serverName, + http.statusCode == 401 || http.statusCode == 403 { + throw IntegrationError.sessionExpired(serverName: serverName) + } + guard (200...299).contains(http.statusCode) else { + throw IntegrationError.unexpectedResponse(code: http.statusCode) + } + return http } private func isConnectionValid(_ data: AudiobookShelfConnectionData) -> Bool { diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index b0c4a7b5d..b290adf74 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -342,6 +342,7 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_connection_details_title" = "Connection Details"; "integration_connect_button" = "Connect"; "integration_sign_in_button" = "Sign In"; +"integration_retry_button" = "Retry"; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; "integration_section_server" = "Server"; @@ -351,6 +352,8 @@ 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 Server"; +"media_servers_title" = "Media Servers"; "integration_custom_headers_title" = "Custom HTTP Headers"; "integration_custom_headers_footer" = "Headers added here are attached to every request sent to this server."; "integration_custom_headers_key_placeholder" = "Header name"; @@ -363,6 +366,7 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_error_unexpected_response" = "Unexpected server response"; "integration_error_unexpected_response_with_code" = "Unexpected server response (Code: %d - %@)"; "integration_error_unauthorized" = "Sign In failed. Check your username and password."; +"integration_error_session_expired" = "Your session for %@ expired. Sign in again to continue."; "integration_selection_count" = "%d of %d Items"; "file_size_unknown" = "Unknown size"; "runtime_unknown" = "Unknown duration"; diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift index af5e6ffa5..623fc1f06 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift +++ b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift @@ -7,9 +7,7 @@ // import BookPlayerKit -import Combine import Get -import JellyfinAPI import SwiftUI @MainActor @@ -18,28 +16,64 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, @Published var form: IntegrationConnectionFormViewModel @Published var viewMode: IntegrationViewMode = .regular - @Published var connectionState: IntegrationConnectionState + @Published var signInFlow: SignInStep? + @Published private(set) var signInCompletedAt: Date? + @Published var isAddingServer: Bool = false - private var disposeBag = Set() + /// Transient handle returned by `findServer`. Held across the connect → sign-in + /// transition so the service can commit it without touching `self.client` mid-flight. + private var pendingServer: JellyfinConnectionService.PendingServer? + + /// When non-nil, the VM operates on this specific connection (read its data on init, + /// route logout / custom-headers updates to its id) regardless of which connection is + /// active in the service. Used by `MediaServersView`'s per-server info sheet so editing + /// one server doesn't change the active connection. + let targetConnectionId: String? + + var servers: [IntegrationServerInfo] { + connectionService.connections.map { data in + IntegrationServerInfo( + id: data.id, + serverName: data.serverName, + serverUrl: data.url.absoluteString, + userName: data.userName + ) + } + } init( connectionService: JellyfinConnectionService, - mode: IntegrationViewMode = .regular + mode: IntegrationViewMode = .regular, + connectionId: String? = nil ) { self.connectionService = connectionService + self.targetConnectionId = connectionId self._viewMode = .init(initialValue: mode) let form = IntegrationConnectionFormViewModel() - if let data = connectionService.connection { - form.setValues( - url: data.url.absoluteString, - serverName: data.serverName, - userName: data.userName, - customHeaders: data.customHeaders - ) - self._connectionState = .init(initialValue: .connected) - } else { - self._connectionState = .init(initialValue: .disconnected) + switch mode { + case .addServer: + // Dedicated Add Server flow: start clean, no pre-population from active connection. + self._signInFlow = .init(initialValue: .enteringServerURL) + self._isAddingServer = .init(initialValue: true) + case .regular, .viewDetails: + // If `connectionId` is provided, pull from that specific saved connection so this VM + // can edit a non-active server. Otherwise fall back to whichever one is active. + let data = connectionId.flatMap { id in + connectionService.connections.first(where: { $0.id == id }) + } ?? connectionService.connection + + if let data { + form.setValues( + url: data.url.absoluteString, + serverName: data.serverName, + userName: data.userName, + customHeaders: data.customHeaders + ) + self._signInFlow = .init(initialValue: nil) + } else { + self._signInFlow = .init(initialValue: .enteringServerURL) + } } self._form = .init(initialValue: form) @@ -47,25 +81,40 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, @MainActor func handleConnectAction() async throws { - let serverName = try await connectionService.findServer( + let pending = try await connectionService.findServer( at: form.serverUrl, customHeaders: form.customHeadersDictionary() ) - connectionState = .foundServer - form.serverName = serverName + pendingServer = pending + signInFlow = .enteringCredentials + form.serverName = pending.serverName } @MainActor func handleSignInAction() async throws { + guard let pending = pendingServer else { + throw IntegrationError.noClient("Jellyfin") + } + // Always drop the transient `pending` after this method runs — on success the + // service commits it as `self.client`, on failure it's no longer reusable + // (credentials may be wrong, URL may be stale relative to what the user typed next). + defer { pendingServer = nil } do { try await connectionService.signIn( + pending: pending, username: form.username, password: form.password, serverName: form.serverName, customHeaders: form.customHeadersDictionary() ) - connectionState = .connected + isAddingServer = false + + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } + signInFlow = nil + signInCompletedAt = Date() } catch APIError.unacceptableStatusCode(let statusCode) { switch statusCode { case 400...499: @@ -80,13 +129,61 @@ final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, @MainActor func handleSignOutAction() { - connectionService.deleteConnection() + // If this VM was scoped to a specific connection, route the deletion there. + // Otherwise act on the active one (the cog → Connection Details flow). + if let targetId = targetConnectionId { + connectionService.deleteConnection(id: targetId) + } else { + connectionService.deleteConnection() + } form = IntegrationConnectionFormViewModel() - connectionState = .disconnected + signInFlow = connectionService.connections.isEmpty ? .enteringServerURL : nil + 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() + signInFlow = .enteringServerURL + } 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 + signInFlow = .enteringServerURL + form = IntegrationConnectionFormViewModel() + } + + func handleCancelAddServerAction() { + isAddingServer = false + signInFlow = nil + // Drop any transient `pending` from a half-finished Connect → Sign In flow, + // so a stale `JellyfinClient` can't get reused by a later sign-in attempt. + pendingServer = nil + if let data = connectionService.connection { + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) + } } @MainActor func handleCustomHeadersUpdate() { - connectionService.updateCustomHeaders(form.customHeadersDictionary()) + let headers = form.customHeadersDictionary() + if let targetId = targetConnectionId { + connectionService.updateCustomHeaders(id: targetId, headers) + } else { + connectionService.updateCustomHeaders(headers) + } } } diff --git a/BookPlayer/Jellyfin/JellyfinRootView.swift b/BookPlayer/Jellyfin/JellyfinRootView.swift index f40e6985b..787407e22 100644 --- a/BookPlayer/Jellyfin/JellyfinRootView.swift +++ b/BookPlayer/Jellyfin/JellyfinRootView.swift @@ -91,9 +91,35 @@ struct JellyfinRootView: View { "error_title".localized, isPresented: .init(get: { loadError != nil }, set: { if !$0 { loadError = nil } }), actions: { - Button("ok_button".localized) { - loadError = nil - showConnectionForm = true + // Library/identity loads can fail for many reasons (transient network, token + // expired, server moved, custom-header proxy issue). Offer Retry where it + // could help, and Connection Details as the manual recovery path (shows the + // saved-server list with sign-out, matching prod behavior). + if (loadError as? IntegrationError)?.isSessionExpired == true { + // Session expired: Retry would just hit the same 401, so omit it. + Button("integration_connection_details_title".localized) { + loadError = nil + connectionViewModel.signInFlow = nil + showConnectionForm = true + } + Button("cancel_button".localized, role: .cancel) { + loadError = nil + dismiss() + } + } else { + Button("integration_retry_button".localized) { + loadError = nil + Task { await loadLibraries() } + } + Button("integration_connection_details_title".localized) { + loadError = nil + connectionViewModel.signInFlow = nil + showConnectionForm = true + } + Button("cancel_button".localized, role: .cancel) { + loadError = nil + dismiss() + } } }, message: { Text(loadError?.localizedDescription ?? "") } @@ -102,17 +128,13 @@ struct JellyfinRootView: View { NavigationStack { IntegrationConnectionView(viewModel: connectionViewModel, integrationName: "Jellyfin") .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - Button { dismiss() } label: { - Image(systemName: "xmark") - .foregroundStyle(theme.linkColor) - } - } - if connectionService.connection != nil { - ToolbarItemGroup(placement: .confirmationAction) { - Button("integration_connect_button") { - showConnectionForm = false - Task { await loadLibraries() } + // Outer X only when we're NOT in Add Server mode — IntegrationConnectionView's + // own Cancel button handles that case (and just dismisses the form sheet). + if !connectionViewModel.isAddingServer { + ToolbarItemGroup(placement: .cancellationAction) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) } } } @@ -121,11 +143,9 @@ struct JellyfinRootView: View { } .tint(theme.linkColor) .environmentObject(theme) - .interactiveDismissDisabled() } .sheet(isPresented: $showLibraryPicker) { libraryPickerSheet - .interactiveDismissDisabled(resolvedLibrary == nil) } .environmentObject(theme) .onChange(of: availableLibraries) { _, libraries in @@ -133,16 +153,14 @@ struct JellyfinRootView: View { showLibraryPicker = true } } - .onChange(of: connectionViewModel.connectionState) { _, newValue in - if newValue == .connected { - showConnectionForm = false - if resolvedLibrary == nil { - Task { await loadLibraries() } - } - } + .onChange(of: connectionViewModel.signInCompletedAt) { _, newValue in + guard newValue != nil else { return } + showConnectionForm = false + resolvedLibrary = nil + Task { await loadLibraries() } } .task { - if connectionService.connection == nil { + if connectionService.connections.isEmpty { showConnectionForm = true } else if resolvedLibrary == nil { await loadLibraries() @@ -182,9 +200,16 @@ struct JellyfinRootView: View { .navigationTitle("library_title".localized) .navigationBarTitleDisplayMode(.inline) .toolbar { - if resolvedLibrary != nil { - ToolbarItem(placement: .cancellationAction) { - Button("done_title".localized) { showLibraryPicker = false } + ToolbarItem(placement: .cancellationAction) { + Button("cancel_button".localized) { + // No library chosen yet — there's nothing to browse, so back out + // to the server picker rather than leave the user on a disabled view. + if resolvedLibrary == nil { + showLibraryPicker = false + dismiss() + } else { + showLibraryPicker = false + } } } } @@ -265,7 +290,7 @@ private struct JellyfinTabRoot: View { destinationView(for: destination) } .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { + ToolbarItem(placement: .cancellationAction) { cogMenu } if let onSwitchLibrary { @@ -453,7 +478,7 @@ where ViewModel.Item == JellyfinLibraryItem { ) } .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { + ToolbarItem(placement: .cancellationAction) { JellyfinTabRoot.cogMenuView( theme: theme, connectionService: connectionService, @@ -579,6 +604,7 @@ extension JellyfinTabRoot { Image(systemName: "gearshape") .foregroundStyle(theme.linkColor) } + .accessibilityLabel("settings_title") } static func connectionDetailsSheetView( diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift index 264336f98..1e73b4bc2 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift @@ -8,7 +8,8 @@ import Foundation -struct JellyfinConnectionData: Codable { +struct JellyfinConnectionData: Codable, Identifiable { + let id: String let url: URL let serverName: String let userID: String @@ -18,10 +19,11 @@ struct JellyfinConnectionData: Codable { var customHeaders: [String: String] = [:] enum CodingKeys: String, CodingKey { - case url, serverName, userID, userName, accessToken, selectedLibraryId, customHeaders + case id, url, serverName, userID, userName, accessToken, selectedLibraryId, customHeaders } init( + id: String = UUID().uuidString, url: URL, serverName: String, userID: String, @@ -30,6 +32,7 @@ struct JellyfinConnectionData: Codable { selectedLibraryId: String? = nil, customHeaders: [String: String] = [:] ) { + self.id = id self.url = url self.serverName = serverName self.userID = userID @@ -41,6 +44,7 @@ struct JellyfinConnectionData: Codable { 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) diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift index 5402a5f16..c44450e77 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift @@ -37,51 +37,85 @@ final class JellyfinHeaderInjector: APIClientDelegate, @unchecked Sendable { } } +@MainActor @Observable class JellyfinConnectionService: BPLogger { - private let keychainService: KeychainServiceProtocol + private static let activeConnectionIDKey = "jellyfin_active_connection_id" - var connection: JellyfinConnectionData? + private nonisolated let keychainService: KeychainServiceProtocol + + 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 var headerInjector: JellyfinHeaderInjector? + private(set) var activeConnectionID: String? { + get { UserDefaults.standard.string(forKey: Self.activeConnectionIDKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.activeConnectionIDKey) } + } - init(keychainService: KeychainServiceProtocol = KeychainService()) { + nonisolated init(keychainService: KeychainServiceProtocol = KeychainService()) { self.keychainService = keychainService } func setup() { - reloadConnection() + reloadConnections() } - /// Finds and creates the api-client for the specified server + /// Transient result of validating a server's reachability. Carries the client + + /// header injector that the caller hands back to ``signIn(pending:...)`` to commit + /// the connection. Until that commit, neither `self.client` nor `self.headerInjector` + /// are mutated, so an in-flight Add Server flow can't corrupt the active library + /// session. + struct PendingServer { + let serverName: String + let client: JellyfinClient + let injector: JellyfinHeaderInjector + } + + /// Validates server reachability without mutating service state. Returns a + /// ``PendingServer`` that the caller hands back to ``signIn(pending:...)``. public func findServer( at absolutePath: String, customHeaders: [String: String] = [:] - ) async throws -> String { - guard let client = createClient(serverUrlString: absolutePath, customHeaders: customHeaders) else { + ) async throws -> PendingServer { + guard let (client, injector) = makeClientAndInjector( + serverUrlString: absolutePath, + customHeaders: customHeaders + ) else { throw IntegrationError.noClient("Jellyfin") } let publicSystemInfo = try await client.send(Paths.getPublicSystemInfo) - self.client = client - - return publicSystemInfo.value.serverName ?? "" + return PendingServer( + serverName: publicSystemInfo.value.serverName ?? "", + client: client, + injector: injector + ) } - /// Sign into the server using the api-client initialized in ``findServer(at:)`` + /// Sign in using a ``PendingServer`` returned from ``findServer(at:customHeaders:)``. + /// Only on success does this method commit to `self.client` / `self.headerInjector` + /// and append to `connections`. public func signIn( + pending: PendingServer, username: String, password: String, serverName: String, customHeaders: [String: String] = [:] ) async throws { - guard let client else { - throw IntegrationError.noClient("Jellyfin") - } - - let result = try await client.signIn(username: username, password: password) + let result = try await pending.client.signIn(username: username, password: password) + // Bail out before persisting if the caller cancelled while the auth round-trip was + // in flight (e.g. the user swiped the sheet down). Otherwise the cancelled sign-in + // still ends up saved. + try Task.checkCancellation() guard let accessToken = result.accessToken, @@ -90,56 +124,127 @@ class JellyfinConnectionService: BPLogger { throw IntegrationError.unexpectedResponse(code: nil) } + // If this is a re-auth on an existing (canonical-url, userID) pair, preserve the + // existing connection's id and selectedLibraryId so the user picks up exactly where + // they left off — same library context, same outbound references — and not a fresh + // connection record they have to re-configure. + let existing = connections.first { + $0.url.canonicalDedupKey == pending.client.configuration.url.canonicalDedupKey + && $0.userID == userID + } let data = JellyfinConnectionData( - url: client.configuration.url, + id: existing?.id ?? UUID().uuidString, + url: pending.client.configuration.url, serverName: serverName, userID: userID, userName: username, accessToken: accessToken, + selectedLibraryId: existing?.selectedLibraryId, customHeaders: customHeaders ) - try keychainService.set( - data, - key: .jellyfinConnection - ) - - self.connection = data - self.client = client - headerInjector?.setCustomHeaders(customHeaders) + // Deduplicate on canonical-url + userID — at most one of these can match `existing` above. + connections.removeAll { + $0.url.canonicalDedupKey == data.url.canonicalDedupKey && $0.userID == data.userID + } + connections.append(data) + activeConnectionID = data.id + saveConnections() + + // Commit the transient client + injector now that the connection is persisted. + self.client = pending.client + self.headerInjector = pending.injector + pending.injector.setCustomHeaders(customHeaders) } func updateCustomHeaders(_ headers: [String: String]) { - guard var data = connection else { return } - data.customHeaders = headers - connection = data - try? keychainService.set(data, key: .jellyfinConnection) - headerInjector?.setCustomHeaders(headers) + guard let activeID = connection?.id else { return } + updateCustomHeaders(id: activeID, headers) + } + + /// Persist `headers` to the connection with the given id, regardless of which is active. + /// If `id` happens to be the active connection, the live header injector is also updated. + func updateCustomHeaders(id: String, _ headers: [String: String]) { + guard let index = connections.firstIndex(where: { $0.id == id }) else { return } + connections[index].customHeaders = headers + saveConnections() + if id == connection?.id { + headerInjector?.setCustomHeaders(headers) + } } 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) { + guard connections.contains(where: { $0.id == id }) else { return } + activeConnectionID = id + rebuildClient(for: connection) + } + + func deleteConnection(id: String) { + // Capture the old client BEFORE we mutate state, so the fire-and-forget + // signOut doesn't race with the rebuildClient call below (and so signOut + // is invoked on the connection the user actually asked us to remove). + let oldClientToSignOut: JellyfinClient? = { + guard let data = connections.first(where: { $0.id == id }), + data.id == connection?.id else { return nil } + return client + }() + + connections.removeAll { $0.id == id } + + if activeConnectionID == id { + activeConnectionID = connections.first?.id + } + + if connections.isEmpty { + rebuildClient(for: nil) + do { + try keychainService.remove(.jellyfinConnection) + } catch { + Self.logger.warning("failed to remove connection data from keychain: \(error)") + } + } else { + saveConnections() + rebuildClient(for: connection) + } + + if let oldClientToSignOut { Task { - // we don't care if this throws - try await client.signOut() + do { + try await oldClientToSignOut.signOut() + } catch { + Self.logger.warning("Jellyfin signOut during delete failed: \(error.localizedDescription)") + } } } + } - do { - try keychainService.remove(.jellyfinConnection) - } catch { - Self.logger.warning("failed to remove connection data from keychain: \(error)") + /// Rebuilds `client` (and the underlying `headerInjector`) for the given saved connection, + /// or clears it when `data == nil`. Centralized so callers can't forget to forward + /// `customHeaders` — earlier copies of `activateConnection` / `deleteConnection` dropped them, + /// which broke connections behind Cloudflare Access until the next app launch. + private func rebuildClient(for data: JellyfinConnectionData?) { + guard let data else { + client = nil + return } + client = createClient( + serverUrlString: data.url.absoluteString, + accessToken: data.accessToken, + customHeaders: data.customHeaders + ) + } - connection = nil - client = nil + func deleteConnection() { + if let id = connection?.id { + deleteConnection(id: id) + } } public func fetchTopLevelItems() async throws -> [JellyfinLibraryItem] { @@ -466,35 +571,76 @@ class JellyfinConnectionService: BPLogger { throw IntegrationError.noClient("Jellyfin") } - return try await client.send(request) + do { + return try await client.send(request) + } catch APIError.unacceptableStatusCode(let status) + where (status == 401 || status == 403) && connection != nil { + // Mid-session unauthorized — the saved access token is no longer accepted (server + // invalidated, revoked, or password rotated). Surface this as a typed re-auth signal so + // the UI can offer a "sign in again to {server}" path instead of dumping the user into + // the generic add-server form (which is the C2 trap: users re-add the same server and + // end up with duplicates). + // + // The `connection != nil` clause guards pre-sign-in calls (e.g. the api-client probe + // inside `findServer`) — we only re-auth when there's actually a session to re-auth. + throw IntegrationError.sessionExpired( + serverName: connection?.serverName ?? "Jellyfin" + ) + } } - 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) } + if connections.count != storedConnections.count { + saveConnections() + } + } 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, - customHeaders: storedConnection.customHeaders - ) - 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, + customHeaders: data.customHeaders + ) + } + } + + private func saveConnections() { + try? keychainService.set(connections, key: .jellyfinConnection) } private func isConnectionValid(_ data: JellyfinConnectionData) -> Bool { return !data.userID.isEmpty && !data.accessToken.isEmpty } - private func createClient( + /// Pure factory: builds a client + injector pair without touching `self`. Used by + /// ``findServer(at:customHeaders:)`` (which must not mutate state) and by + /// ``createClient(serverUrlString:accessToken:customHeaders:)`` (which commits). + private func makeClientAndInjector( serverUrlString: String, accessToken: String? = nil, customHeaders: [String: String] = [:] - ) -> JellyfinClient? { + ) -> (JellyfinClient, JellyfinHeaderInjector)? { let mainBundleInfo = Bundle.main.infoDictionary let clientName = mainBundleInfo?[kCFBundleNameKey as String] as? String let clientVersion = mainBundleInfo?[kCFBundleVersionKey as String] as? String @@ -513,12 +659,30 @@ class JellyfinConnectionService: BPLogger { version: clientVersion ) let injector = JellyfinHeaderInjector(customHeaders: customHeaders) - self.headerInjector = injector - return JellyfinClient( + let client = JellyfinClient( configuration: configuration, delegate: injector, accessToken: accessToken ) + return (client, injector) + } + + /// Builds a client + commits the underlying injector to `self.headerInjector`. + /// Used by ``rebuildClient(for:)`` to swap in a saved connection's client. + private func createClient( + serverUrlString: String, + accessToken: String? = nil, + customHeaders: [String: String] = [:] + ) -> JellyfinClient? { + guard let (client, injector) = makeClientAndInjector( + serverUrlString: serverUrlString, + accessToken: accessToken, + customHeaders: customHeaders + ) else { + return nil + } + self.headerInjector = injector + return client } func createItemDownloadUrl(_ item: JellyfinLibraryItem) throws -> URL { diff --git a/BookPlayer/Library/ItemList/ItemListView.swift b/BookPlayer/Library/ItemList/ItemListView.swift index bc2ea11c4..cfe736213 100644 --- a/BookPlayer/Library/ItemList/ItemListView.swift +++ b/BookPlayer/Library/ItemList/ItemListView.swift @@ -379,25 +379,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..d6d10e110 100644 --- a/BookPlayer/Library/ItemList/Models/ListStateManager.swift +++ b/BookPlayer/Library/ItemList/Models/ListStateManager.swift @@ -22,10 +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. + /// MediaServersView is the unified entry point — per-integration libraries are + /// pushed on top of it as sub-sheets when the user taps a server row. enum IntegrationSheet: String, Identifiable { - case jellyfin - case audiobookshelf + case mediaServers var id: String { rawValue } } var activeIntegrationSheet: IntegrationSheet? diff --git a/BookPlayer/MainView.swift b/BookPlayer/MainView.swift index 5cae0f9dc..7f3a87fc0 100644 --- a/BookPlayer/MainView.swift +++ b/BookPlayer/MainView.swift @@ -87,10 +87,14 @@ struct MainView: View { } .sheet(item: $listState.activeIntegrationSheet) { sheet in switch sheet { - case .jellyfin: - JellyfinRootView(connectionService: jellyfinService) - case .audiobookshelf: - AudiobookShelfRootView(connectionService: audiobookshelfService) + case .mediaServers: + NavigationStack { + MediaServersView( + jellyfinService: jellyfinService, + audiobookshelfService: audiobookshelfService, + style: .libraryEntry + ) + } } } .fullScreenCover(isPresented: playerState.isShowingPlayerBinding) { diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift index 4049acae0..06dc54689 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift @@ -8,6 +8,9 @@ import SwiftUI +/// In-library "Connection Details" view: shows only the *active* connection's +/// username and a Log out action. Multi-server management lives in +/// `MediaServersView`, not here. struct IntegrationConnectedView: View { @ObservedObject var viewModel: VM @EnvironmentObject var theme: ThemeViewModel diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift index 33d3572e3..dc9a70f32 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift @@ -17,12 +17,19 @@ struct IntegrationConnectionView: Vi @State private var isLoading = false @State private var error: Error? + /// Tracks the in-flight network task for connect/sign-in/Quick-Connect-start so the view + /// can cancel it on dismissal. Without this, swiping the sheet down while a sign-in is + /// still in flight would let the view model persist a connection the user thought they + /// gave up on. + @State private var actionTask: Task? + @EnvironmentObject var theme: ThemeViewModel + @Environment(\.dismiss) private var dismiss var body: some View { Form { - switch viewModel.connectionState { - case .disconnected: + switch viewModel.signInFlow { + case .enteringServerURL: IntegrationDisconnectedView( serverUrl: $viewModel.form.serverUrl, placeholderURL: integrationName == "Jellyfin" @@ -34,7 +41,7 @@ struct IntegrationConnectionView: Vi IntegrationCustomHeadersSectionView( customHeaders: $viewModel.form.customHeaders ) - case .foundServer: + case .enteringCredentials: IntegrationServerInformationSectionView( serverName: viewModel.form.serverName, serverUrl: viewModel.form.serverUrl @@ -47,7 +54,10 @@ struct IntegrationConnectionView: Vi IntegrationCustomHeadersSectionView( customHeaders: $viewModel.form.customHeaders ) - case .connected: + case .none: + // Not in sign-in flow → render the connection-details UI (server info, custom + // headers, logout) for the active connection. Multi-server management is in + // `MediaServersView`, not here. IntegrationServerInformationSectionView( serverName: viewModel.form.serverName, serverUrl: viewModel.form.serverUrl @@ -78,48 +88,71 @@ 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() + dismiss() + } + .foregroundStyle(theme.linkColor) + } + ToolbarItemGroup(placement: .confirmationAction) { + switch viewModel.signInFlow { + case .enteringCredentials: signInToolbarButton + case .enteringServerURL, .none: connectToolbarButton + } + } + } else { + ToolbarItem(placement: .principal) { + Text(localizedNavigationTitle) + .bpFont(.headline) + .foregroundStyle(theme.primaryColor) + } + ToolbarItemGroup(placement: .confirmationAction) { + switch viewModel.signInFlow { + case .enteringServerURL: connectToolbarButton + case .enteringCredentials: signInToolbarButton + case .none: EmptyView() + } } } } .tint(theme.linkColor) + .onDisappear { + actionTask?.cancel() + actionTask = nil + } } // MARK: Utils func onConnect() { + actionTask?.cancel() isLoading = true - Task { + actionTask = Task { @MainActor in + defer { isLoading = false } do { try await viewModel.handleConnectAction() - isLoading = false + try Task.checkCancellation() + } catch is CancellationError { + // Sheet dismissed mid-flight; nothing to surface. } catch { - isLoading = false self.error = error } } } func onSignIn() { + actionTask?.cancel() isLoading = true - Task { + actionTask = Task { @MainActor in + defer { isLoading = false } do { try await viewModel.handleSignInAction() - isLoading = false + try Task.checkCancellation() + } catch is CancellationError { + return } catch { - isLoading = false self.error = error } } @@ -128,10 +161,9 @@ struct IntegrationConnectionView: Vi // MARK: - Navigation Title private var localizedNavigationTitle: String { - switch viewModel.connectionState { - case .disconnected, .foundServer: integrationName - case .connected: "integration_connection_details_title".localized - } + viewModel.signInFlow == nil + ? "integration_connection_details_title".localized + : integrationName } // MARK: - Navigation Buttons diff --git a/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift index 8acfd8d31..5f3e54f2f 100644 --- a/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift +++ b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift @@ -8,15 +8,31 @@ import SwiftUI -enum IntegrationConnectionState { - case disconnected - case foundServer - case connected +/// Current step of the sign-in flow. `nil` means the user is not actively +/// signing in — the view should show the connection-details UI for the +/// active connection (server info + custom headers + logout). Multi-server +/// management lives in `MediaServersView`, not here. +enum SignInStep { + /// User is entering the server URL (initial step). + case enteringServerURL + /// Server has been validated; user is entering credentials. + case enteringCredentials } enum IntegrationViewMode { + /// Bound to a live library session; pre-populates form from the active connection. case regular + /// Cog → Connection Details flow; pre-populates form, shows connection-details UI. case viewDetails + /// Dedicated Add Server flow; starts with empty form, no active-connection state leaks. + case addServer +} + +struct IntegrationServerInfo: Identifiable { + let id: String + let serverName: String + let serverUrl: String + let userName: String } @MainActor @@ -25,12 +41,39 @@ protocol IntegrationConnectionViewModelProtocol: ObservableObject { var form: FormVM { get set } var viewMode: IntegrationViewMode { get set } - var connectionState: IntegrationConnectionState { get set } + + /// Drives what the view renders. + /// `.enteringServerURL` → URL form; `.enteringCredentials` → credentials form; `nil` → connection-details UI. + var signInFlow: SignInStep? { get set } + + /// Timestamp of the last successful sign-in. Observers use this as a signal + /// to react to real sign-in completions (distinct from cancellations). + var signInCompletedAt: Date? { get } + + /// All saved server connections + var servers: [IntegrationServerInfo] { get } + + /// Whether the user is adding a new server from the settings screen + /// (vs the initial-connect flow). Used by the toolbar to surface a Cancel + /// button when adding from Settings. + 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() + /// Persist any changes made to the custom-headers list while the connection is already live. - /// Called by the headers-editor UI in the `.connected` state. func handleCustomHeadersUpdate() } diff --git a/BookPlayer/MediaServerIntegration/IntegrationError.swift b/BookPlayer/MediaServerIntegration/IntegrationError.swift index d6b097176..546b0dbf7 100644 --- a/BookPlayer/MediaServerIntegration/IntegrationError.swift +++ b/BookPlayer/MediaServerIntegration/IntegrationError.swift @@ -14,6 +14,19 @@ enum IntegrationError: Error, LocalizedError { case noClient(_ integrationName: String) case unexpectedResponse(code: Int?) case clientError(code: Int) + /// The server rejected our credentials mid-session (401/403 from a non-sign-in call). Carries + /// the offending connection's server name so the UI can prompt "Sign in again to {name}" + /// instead of showing the generic add-server form. The view layer special-cases this so the + /// existing connection (URL, custom headers, selected library) is preserved across re-auth. + case sessionExpired(serverName: String) + + /// True when the error is a recoverable re-auth case — the UI should offer a Sign-In-only + /// recovery path that preserves the existing connection instead of treating it as a generic + /// load failure with the full Retry / Sign-In / Cancel set. + var isSessionExpired: Bool { + if case .sessionExpired = self { return true } + return false + } var errorDescription: String? { switch self { @@ -44,6 +57,8 @@ enum IntegrationError: Error, LocalizedError { HTTPURLResponse.localizedString(forStatusCode: code) ) } + case .sessionExpired(let serverName): + String(format: "integration_error_session_expired".localized, serverName) } } } diff --git a/BookPlayer/MediaServerIntegration/MediaServersView.swift b/BookPlayer/MediaServerIntegration/MediaServersView.swift new file mode 100644 index 000000000..d623aa482 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/MediaServersView.swift @@ -0,0 +1,432 @@ +// +// MediaServersView.swift +// BookPlayer +// +// Created by Gianni Carlo on 5/26/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +/// Unified screen for media-server integrations. Has two presentation styles: +/// +/// - `.libraryEntry`: sheet presented from the Library tab. Tap a row to activate +/// that server and open its library; `(i)` row accessory opens Connection Details +/// without changing the active connection. +/// - `.settings`: pushed into the Settings navigation stack. Row tap opens +/// Connection Details directly (no library entry from here); `(i)` is hidden +/// because the whole row is the same action. +/// +/// In both styles: per-section `[+]` button to add a server, Edit mode + swipe-to- +/// delete to remove servers. +struct MediaServersView: View { + enum Style { + /// Sheet from Library tab. Row tap activates + opens library. `(i)` is visible. + case libraryEntry + /// Pushed into Settings nav stack. Row tap opens Connection Details. `(i)` hidden. + case settings + } + + let jellyfinService: JellyfinConnectionService + let audiobookshelfService: AudiobookShelfConnectionService + let style: Style + + init( + jellyfinService: JellyfinConnectionService, + audiobookshelfService: AudiobookShelfConnectionService, + style: Style = .libraryEntry + ) { + self.jellyfinService = jellyfinService + self.audiobookshelfService = audiobookshelfService + self.style = style + } + + /// Single sheet-presentation state, driven by an enum so SwiftUI only ever sees + /// one `.sheet(item:)` modifier. Stacking multiple sibling `.sheet(item:)` lets + /// the second presentation drop silently when state changes happen back-to-back. + @State private var presentedSheet: SheetRoute? + @State private var editMode: EditMode = .inactive + + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + section( + title: "Jellyfin", + kind: .jellyfin, + servers: jellyfinService.connections.map(ServerRow.init) + ) + section( + title: "AudiobookShelf", + kind: .audiobookshelf, + servers: audiobookshelfService.connections.map(ServerRow.init) + ) + } + .applyListStyle(with: theme, background: theme.systemBackgroundColor) + .navigationTitle("media_servers_title".localized) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if style == .libraryEntry { + // In settings, the parent NavigationStack provides a back button — no X needed. + ToolbarItem(placement: .cancellationAction) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } + ToolbarItem(placement: .primaryAction) { + EditButton() + } + } + .environment(\.editMode, $editMode) + .tint(theme.linkColor) + .sheet(item: $presentedSheet) { route in + switch route { + case .addServer(let kind): + addServerSheet(for: kind) + case .library(let kind): + librarySheet(for: kind) + case .connectionDetails(let connectionId, let kind): + connectionDetailsSheet(connectionId: connectionId, kind: kind) + } + } + } + + // MARK: - Section builder + + @ViewBuilder + private func section(title: String, kind: IntegrationKind, servers: [ServerRow]) -> some View { + ThemedSection { + ForEach(servers) { server in + rowView(server, kind: kind) + } + .onDelete { indexSet in + for index in indexSet { + delete(servers[index], kind: kind) + } + } + } header: { + HStack { + Text(title) + .foregroundStyle(theme.secondaryColor) + Spacer() + Button { + presentedSheet = .addServer(kind) + } label: { + Image(systemName: "plus.circle.fill") + .imageScale(.large) + .foregroundStyle(theme.linkColor) + } + .accessibilityLabel("integration_add_server_button".localized) + } + } + } + + @ViewBuilder + private func rowView(_ server: ServerRow, kind: IntegrationKind) -> some View { + HStack { + Button { + switch style { + case .libraryEntry: + select(server, kind: kind) + case .settings: + // In Settings we don't navigate into the server; the row IS the + // Connection Details action. + presentedSheet = .connectionDetails(connectionId: server.id, kind: kind) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(server.serverName) + .foregroundStyle(theme.primaryColor) + Text(server.userName) + .font(.caption) + .foregroundStyle(theme.secondaryColor) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if style == .libraryEntry { + Button { + // Show Connection Details scoped to THIS server (without activating it, + // so the user's current active connection isn't changed by tapping info). + presentedSheet = .connectionDetails(connectionId: server.id, kind: kind) + } label: { + Image(systemName: "info.circle") + .imageScale(.large) + .foregroundStyle(theme.linkColor) + } + .buttonStyle(.borderless) + .accessibilityHidden(true) + } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + delete(server, kind: kind) + } label: { + Label("logout_title".localized, systemImage: "trash") + } + } + // In `.libraryEntry` mode, VoiceOver merges the row's main button + trailing + // `(i)` button into a single element, hiding the info action. Surface it via + // the actions rotor. (In `.settings`, row tap already opens Connection Details.) + .accessibilityActions { + if style == .libraryEntry { + Button("integration_connection_details_title".localized) { + presentedSheet = .connectionDetails(connectionId: server.id, kind: kind) + } + } + } + } + + // MARK: - Actions + + private func select(_ server: ServerRow, kind: IntegrationKind) { + switch kind { + case .jellyfin: + jellyfinService.activateConnection(id: server.id) + case .audiobookshelf: + audiobookshelfService.activateConnection(id: server.id) + } + presentedSheet = .library(kind) + } + + private func delete(_ server: ServerRow, kind: IntegrationKind) { + switch kind { + case .jellyfin: + jellyfinService.deleteConnection(id: server.id) + case .audiobookshelf: + audiobookshelfService.deleteConnection(id: server.id) + } + } + + // MARK: - Sheets + + @ViewBuilder + private func addServerSheet(for kind: IntegrationKind) -> some View { + switch kind { + case .jellyfin: + AddServerJellyfinSheet(connectionService: jellyfinService) + .environmentObject(theme) + case .audiobookshelf: + AddServerAudiobookShelfSheet(connectionService: audiobookshelfService) + .environmentObject(theme) + } + } + + @ViewBuilder + private func librarySheet(for kind: IntegrationKind) -> some View { + switch kind { + case .jellyfin: + JellyfinRootView(connectionService: jellyfinService) + case .audiobookshelf: + AudiobookShelfRootView(connectionService: audiobookshelfService) + } + } + + @ViewBuilder + private func connectionDetailsSheet(connectionId: String, kind: IntegrationKind) -> some View { + switch kind { + case .jellyfin: + ConnectionDetailsJellyfinSheet( + connectionService: jellyfinService, + connectionId: connectionId + ) + .environmentObject(theme) + case .audiobookshelf: + ConnectionDetailsAudiobookShelfSheet( + connectionService: audiobookshelfService, + connectionId: connectionId + ) + .environmentObject(theme) + } + } +} + +/// Unified route enum driving the single `.sheet(item:)` modifier on MediaServersView. +private enum SheetRoute: Identifiable { + case addServer(IntegrationKind) + case library(IntegrationKind) + case connectionDetails(connectionId: String, kind: IntegrationKind) + + var id: String { + switch self { + case .addServer(let kind): + return "add-\(kind.rawValue)" + case .library(let kind): + return "lib-\(kind.rawValue)" + case .connectionDetails(let connectionId, let kind): + return "details-\(kind.rawValue)-\(connectionId)" + } + } +} + +// MARK: - Helper types + +enum IntegrationKind: String, Identifiable { + case jellyfin + case audiobookshelf + var id: String { rawValue } +} + +private struct ServerRow: Identifiable { + let id: String + let serverName: String + let serverUrl: String + let userName: String + let customHeaders: [String: String] + + init(_ data: JellyfinConnectionData) { + self.id = data.id + self.serverName = data.serverName + self.serverUrl = data.url.absoluteString + self.userName = data.userName + self.customHeaders = data.customHeaders + } + + init(_ data: AudiobookShelfConnectionData) { + self.id = data.id + self.serverName = data.serverName + self.serverUrl = data.url.absoluteString + self.userName = data.userName + self.customHeaders = data.customHeaders + } +} + + +// MARK: - Add Server sheets +// Each wraps `IntegrationConnectionView` with a fresh VM constructed in `.addServer` mode, +// so its in-flight state is fully isolated from the active library session. + +private struct AddServerJellyfinSheet: View { + let connectionService: JellyfinConnectionService + @StateObject private var viewModel: JellyfinConnectionViewModel + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.dismiss) private var dismiss + + init(connectionService: JellyfinConnectionService) { + self.connectionService = connectionService + self._viewModel = .init( + wrappedValue: JellyfinConnectionViewModel( + connectionService: connectionService, + mode: .addServer + ) + ) + } + + var body: some View { + NavigationStack { + IntegrationConnectionView(viewModel: viewModel, integrationName: "Jellyfin") + .navigationBarTitleDisplayMode(.inline) + } + .tint(theme.linkColor) + .environmentObject(theme) + .onChange(of: viewModel.signInCompletedAt) { _, newValue in + if newValue != nil { dismiss() } + } + } +} + +private struct AddServerAudiobookShelfSheet: View { + let connectionService: AudiobookShelfConnectionService + @StateObject private var viewModel: AudiobookShelfConnectionViewModel + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.dismiss) private var dismiss + + init(connectionService: AudiobookShelfConnectionService) { + self.connectionService = connectionService + self._viewModel = .init( + wrappedValue: AudiobookShelfConnectionViewModel( + connectionService: connectionService, + mode: .addServer + ) + ) + } + + var body: some View { + NavigationStack { + IntegrationConnectionView(viewModel: viewModel, integrationName: "AudiobookShelf") + .navigationBarTitleDisplayMode(.inline) + } + .tint(theme.linkColor) + .environmentObject(theme) + .onChange(of: viewModel.signInCompletedAt) { _, newValue in + if newValue != nil { dismiss() } + } + } +} + +// MARK: - Connection details sheets (per-server, scoped via `connectionId`) +// Reuses `IntegrationConnectionView`'s saved-list rendering (signInFlow == nil), +// but the VM is initialized with a specific `connectionId` so the form data and +// the destructive actions (logout, customHeaders update) target THAT server +// rather than the service's active connection. + +private struct ConnectionDetailsJellyfinSheet: View { + let connectionService: JellyfinConnectionService + @StateObject private var viewModel: JellyfinConnectionViewModel + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.dismiss) private var dismiss + + init(connectionService: JellyfinConnectionService, connectionId: String) { + self.connectionService = connectionService + self._viewModel = .init( + wrappedValue: JellyfinConnectionViewModel( + connectionService: connectionService, + mode: .viewDetails, + connectionId: connectionId + ) + ) + } + + var body: some View { + NavigationStack { + IntegrationConnectionView(viewModel: viewModel, integrationName: "Jellyfin") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("done_title".localized) { dismiss() } + .foregroundStyle(theme.linkColor) + } + } + } + .tint(theme.linkColor) + .environmentObject(theme) + } +} + +private struct ConnectionDetailsAudiobookShelfSheet: View { + let connectionService: AudiobookShelfConnectionService + @StateObject private var viewModel: AudiobookShelfConnectionViewModel + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.dismiss) private var dismiss + + init(connectionService: AudiobookShelfConnectionService, connectionId: String) { + self.connectionService = connectionService + self._viewModel = .init( + wrappedValue: AudiobookShelfConnectionViewModel( + connectionService: connectionService, + mode: .viewDetails, + connectionId: connectionId + ) + ) + } + + var body: some View { + NavigationStack { + IntegrationConnectionView(viewModel: viewModel, integrationName: "AudiobookShelf") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("done_title".localized) { dismiss() } + .foregroundStyle(theme.linkColor) + } + } + } + .tint(theme.linkColor) + .environmentObject(theme) + } +} diff --git a/BookPlayer/Search/SearchView.swift b/BookPlayer/Search/SearchView.swift index 7310ddea0..987bb645d 100644 --- a/BookPlayer/Search/SearchView.swift +++ b/BookPlayer/Search/SearchView.swift @@ -22,7 +22,7 @@ struct SearchView: View { } var body: some View { - NavigationView { + NavigationStack { Group { if viewModel.searchText.isEmpty { recentItemsView @@ -50,10 +50,13 @@ struct SearchView: View { private var recentItemsView: some View { List { - Section("recent_title".localized) { + ThemedSection { ForEach(viewModel.filteredRecentItems) { item in rowView(item) } + } header: { + Text("recent_title".localized) + .foregroundStyle(theme.secondaryColor) } } .listStyle(.insetGrouped) @@ -62,10 +65,13 @@ struct SearchView: View { private var searchResultsView: some View { List { ForEach(viewModel.searchSections, id: \.folderName) { section in - Section(section.displayName) { + ThemedSection { ForEach(section.items) { item in rowView(item) } + } header: { + Text(section.displayName) + .foregroundStyle(theme.secondaryColor) } } } diff --git a/BookPlayer/Settings/Sections/SettingsIntegrationsSectionView.swift b/BookPlayer/Settings/Sections/SettingsIntegrationsSectionView.swift index d2be88181..dcff141f9 100644 --- a/BookPlayer/Settings/Sections/SettingsIntegrationsSectionView.swift +++ b/BookPlayer/Settings/Sections/SettingsIntegrationsSectionView.swift @@ -14,12 +14,8 @@ struct SettingsIntegrationsSectionView: View { var body: some View { ThemedSection { - NavigationLink(value: SettingsScreen.jellyfin) { - Text("Jellyfin") - .bpFont(.body) - } - NavigationLink(value: SettingsScreen.audiobookshelf) { - Text("AudiobookShelf") + NavigationLink(value: SettingsScreen.mediaServers) { + Text("media_servers_title") .bpFont(.body) } NavigationLink(value: SettingsScreen.hardcover) { diff --git a/BookPlayer/Settings/SettingsScreen.swift b/BookPlayer/Settings/SettingsScreen.swift index 9127090fd..26595e99d 100644 --- a/BookPlayer/Settings/SettingsScreen.swift +++ b/BookPlayer/Settings/SettingsScreen.swift @@ -13,7 +13,7 @@ enum SettingsScreen: String, Hashable { case controls, autoplay, autolock case storage, syncbackup case shortcuts - case jellyfin, audiobookshelf, hardcover + case mediaServers, hardcover case tipjar case credits } diff --git a/BookPlayer/Settings/SettingsView.swift b/BookPlayer/Settings/SettingsView.swift index aa6942ce5..cdd070702 100644 --- a/BookPlayer/Settings/SettingsView.swift +++ b/BookPlayer/Settings/SettingsView.swift @@ -113,23 +113,13 @@ struct SettingsView: View { StorageCloudDeletedViewModel(folderURL: DataManager.getBackupFolderURL()) ) ) - case .jellyfin: + case .mediaServers: view = AnyView( - IntegrationSettingsView(integrationName: "Jellyfin") { - JellyfinConnectionViewModel( - connectionService: jellyfinService, - mode: .viewDetails - ) - } - ) - case .audiobookshelf: - view = AnyView( - IntegrationSettingsView(integrationName: "AudiobookShelf") { - AudiobookShelfConnectionViewModel( - connectionService: audiobookshelfService, - mode: .viewDetails - ) - } + MediaServersView( + jellyfinService: jellyfinService, + audiobookshelfService: audiobookshelfService, + style: .settings + ) ) case .hardcover: view = AnyView( diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index b3e5d0f18..8e74a3bbd 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -341,6 +341,9 @@ "integration_connection_details_title" = "تفاصيل الاتصال"; "integration_connect_button" = "الاتصال"; "integration_sign_in_button" = "تسجيل الدخول"; +"integration_retry_button" = "إعادة المحاولة"; +"integration_add_server_button" = "إضافة خادم"; +"media_servers_title" = "خوادم الوسائط"; "integration_section_server_url" = "عنوان URL للخادم"; "integration_section_server_url_footer" = "الاتصال بخادم @% الخاص بك"; "integration_section_server" = "الخادم"; @@ -362,6 +365,7 @@ "integration_error_unexpected_response" = "استجابة غير متوقعة من الخادم"; "integration_error_unexpected_response_with_code" = "استجابة غير متوقعة من الخادم (الرمز: %d - %@ )"; "integration_error_unauthorized" = "فشل تسجيل الدخول. تحقق من اسم المستخدم وكلمة المرور."; +"integration_error_session_expired" = "انتهت صلاحية جلستك على %@. سجّل الدخول مرة أخرى للمتابعة."; "integration_selection_count" = "%d من %d عناصر"; "file_size_unknown" = "حجم غير معروف"; "runtime_unknown" = "مدة غير معروفة"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index f94fa333f..85db2072e 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -341,6 +341,9 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "integration_connection_details_title" = "Detalls de connexió"; "integration_connect_button" = "Connecta't"; "integration_sign_in_button" = "Inicieu la sessió"; +"integration_retry_button" = "Torna-ho a provar"; +"integration_add_server_button" = "Afegeix un servidor"; +"media_servers_title" = "Servidors multimèdia"; "integration_section_server_url" = "URL del servidor"; "integration_section_server_url_footer" = "Connecteu-vos al vostre servidor %@"; "integration_section_server" = "Servidor"; @@ -362,6 +365,7 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "integration_error_unexpected_response" = "Resposta del servidor inesperada"; "integration_error_unexpected_response_with_code" = "Resposta del servidor inesperada (Codi: %d - %@)"; "integration_error_unauthorized" = "No s'ha pogut iniciar la sessió. Comproveu el vostre nom d'usuari i contrasenya."; +"integration_error_session_expired" = "La teva sessió a %@ ha caducat. Torna a iniciar la sessió per continuar."; "integration_selection_count" = "%d de %d Elements"; "file_size_unknown" = "Mida desconeguda"; "runtime_unknown" = "Durada desconeguda"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index 77b1588d9..6d3f5ef34 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Podrobnosti o připojení"; "integration_connect_button" = "Připojit"; "integration_sign_in_button" = "Přihlaste se"; +"integration_retry_button" = "Zkusit znovu"; +"integration_add_server_button" = "Přidat server"; +"media_servers_title" = "Mediální servery"; "integration_section_server_url" = "Adresa URL serveru"; "integration_section_server_url_footer" = "Připojte se k serveru %@"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Neočekávaná odpověď serveru"; "integration_error_unexpected_response_with_code" = "Neočekávaná odpověď serveru (kód: %d – %@ )"; "integration_error_unauthorized" = "Přihlášení se nezdařilo. Zkontrolujte své uživatelské jméno a heslo."; +"integration_error_session_expired" = "Vaše relace pro %@ vypršela. Pro pokračování se znovu přihlaste."; "integration_selection_count" = "%d z %d Poživek"; "file_size_unknown" = "Neznámá velikost"; "runtime_unknown" = "Neznámá doba trvání"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index b98fa46c8..fcb435576 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Tilslutningsdetaljer"; "integration_connect_button" = "Forbinde"; "integration_sign_in_button" = "Log ind"; +"integration_retry_button" = "Prøv igen"; +"integration_add_server_button" = "Tilføj server"; +"media_servers_title" = "Medieservere"; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Opret forbindelse til din %@-server"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Uventet serversvar"; "integration_error_unexpected_response_with_code" = "Uventet serversvar (Kode: %d - %@ )"; "integration_error_unauthorized" = "Log ind mislykkedes. Tjek dit brugernavn og adgangskode."; +"integration_error_session_expired" = "Din session for %@ er udløbet. Log ind igen for at fortsætte."; "integration_selection_count" = "%d af %d Elementer"; "file_size_unknown" = "Ukendt størrelse"; "runtime_unknown" = "Ukendt varighed"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index d205155a6..d7586ee20 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Verbindungsdetails"; "integration_connect_button" = "Verbinden"; "integration_sign_in_button" = "Anmelden"; +"integration_retry_button" = "Erneut versuchen"; +"integration_add_server_button" = "Server hinzufügen"; +"media_servers_title" = "Medienserver"; "integration_section_server_url" = "Server-URL"; "integration_section_server_url_footer" = "Stelle eine Verbindung zu deinem %@-Server her"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Unerwartete Serverantwort"; "integration_error_unexpected_response_with_code" = "Unerwartete Serverantwort (Code: %d - %@ )"; "integration_error_unauthorized" = "Anmeldung fehlgeschlagen. Überprüfe deinen Benutzernamen und dein Passwort."; +"integration_error_session_expired" = "Deine Sitzung für %@ ist abgelaufen. Melde dich erneut an, um fortzufahren."; "integration_selection_count" = "%d von %d Elementen"; "file_size_unknown" = "Unbekannte Größe"; "runtime_unknown" = "Unbekannte Dauer"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 34f5f3692..07569f1a1 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -341,6 +341,9 @@ "integration_connection_details_title" = "Στοιχεία σύνδεσης"; "integration_connect_button" = "Συνδέω"; "integration_sign_in_button" = "Είσοδος"; +"integration_retry_button" = "Επανάληψη"; +"integration_add_server_button" = "Προσθήκη διακομιστή"; +"media_servers_title" = "Διακομιστές πολυμέσων"; "integration_section_server_url" = "URL διακομιστή"; "integration_section_server_url_footer" = "Συνδεθείτε στον διακομιστή %@"; "integration_section_server" = "Υπηρέτης"; @@ -362,6 +365,7 @@ "integration_error_unexpected_response" = "Απροσδόκητη απόκριση διακομιστή"; "integration_error_unexpected_response_with_code" = "Μη αναμενόμενη απόκριση διακομιστή (Κωδικός: %d - %@ )"; "integration_error_unauthorized" = "Η σύνδεση απέτυχε. Ελέγξτε το όνομα χρήστη και τον κωδικό πρόσβασής σας."; +"integration_error_session_expired" = "Η συνεδρία σας για το %@ έληξε. Συνδεθείτε ξανά για να συνεχίσετε."; "integration_selection_count" = "%d από %d Στοιχεία"; "file_size_unknown" = "Άγνωστο μέγεθος"; "runtime_unknown" = "Άγνωστη διάρκεια"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index 711b6edce..adb65e9c1 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -343,6 +343,7 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_connection_details_title" = "Connection Details"; "integration_connect_button" = "Connect"; "integration_sign_in_button" = "Sign In"; +"integration_retry_button" = "Retry"; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; "integration_section_server" = "Server"; @@ -352,6 +353,8 @@ 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 Server"; +"media_servers_title" = "Media Servers"; "integration_custom_headers_title" = "Custom HTTP Headers"; "integration_custom_headers_footer" = "Headers added here are attached to every request sent to this server."; "integration_custom_headers_key_placeholder" = "Header name"; @@ -364,6 +367,7 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_error_unexpected_response" = "Unexpected server response"; "integration_error_unexpected_response_with_code" = "Unexpected server response (Code: %d - %@)"; "integration_error_unauthorized" = "Sign In failed. Check your username and password."; +"integration_error_session_expired" = "Your session for %@ expired. Sign in again to continue."; "integration_selection_count" = "%d of %d Items"; "file_size_unknown" = "Unknown size"; "runtime_unknown" = "Unknown duration"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 35116f00a..8b905e90c 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Detalles de la conexión"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Iniciar sesión"; +"integration_retry_button" = "Reintentar"; +"integration_add_server_button" = "Añadir servidor"; +"media_servers_title" = "Servidores multimedia"; "integration_section_server_url" = "URL del servidor"; "integration_section_server_url_footer" = "Conéctese a su servidor %@"; "integration_section_server" = "Servidor"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Respuesta inesperada del servidor"; "integration_error_unexpected_response_with_code" = "Respuesta inesperada del servidor (Código: %d - %@ )"; "integration_error_unauthorized" = "Error al iniciar sesión. Verifique su nombre de usuario y contraseña."; +"integration_error_session_expired" = "Tu sesión en %@ ha caducado. Inicia sesión de nuevo para continuar."; "integration_selection_count" = "%d de %d Elementos"; "file_size_unknown" = "Tamaño desconocido"; "runtime_unknown" = "Duración desconocida"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index e0385eeaa..942d8813a 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Yhteyden tiedot"; "integration_connect_button" = "Yhdistä"; "integration_sign_in_button" = "Kirjaudu sisään"; +"integration_retry_button" = "Yritä uudelleen"; +"integration_add_server_button" = "Lisää palvelin"; +"media_servers_title" = "Mediapalvelimet"; "integration_section_server_url" = "Palvelimen URL-osoite"; "integration_section_server_url_footer" = "Yhdistä %@-palvelimeesi"; "integration_section_server" = "Palvelin"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Odottamaton palvelimen vastaus"; "integration_error_unexpected_response_with_code" = "Odottamaton palvelimen vastaus (Koodi: %d - %@ )"; "integration_error_unauthorized" = "Kirjautuminen epäonnistui. Tarkista käyttäjätunnuksesi ja salasanasi."; +"integration_error_session_expired" = "Istuntosi palvelimelle %@ on vanhentunut. Kirjaudu uudelleen jatkaaksesi."; "integration_selection_count" = "%d / %d Kohdetta"; "file_size_unknown" = "Tuntematon koko"; "runtime_unknown" = "Tuntematon kesto"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 6b127f129..1f072964a 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Détails de connexion"; "integration_connect_button" = "Connecter"; "integration_sign_in_button" = "Se connecter"; +"integration_retry_button" = "Réessayer"; +"integration_add_server_button" = "Ajouter un serveur"; +"media_servers_title" = "Serveurs multimédias"; "integration_section_server_url" = "URL du serveur"; "integration_section_server_url_footer" = "Connectez-vous à votre serveur %@"; "integration_section_server" = "Serveur"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Réponse inattendue du serveur"; "integration_error_unexpected_response_with_code" = "Réponse inattendue du serveur (Code : %d - %@ )"; "integration_error_unauthorized" = "La connexion a échoué. Vérifiez votre nom d'utilisateur et votre mot de passe."; +"integration_error_session_expired" = "Votre session pour %@ a expiré. Reconnectez-vous pour continuer."; "integration_selection_count" = "%d sur %d Éléments"; "file_size_unknown" = "Taille inconnue"; "runtime_unknown" = "Durée inconnue"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 22061c098..1b0aad49d 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -341,6 +341,9 @@ "integration_connection_details_title" = "Csatlakozás részletei"; "integration_connect_button" = "Csatlakozás"; "integration_sign_in_button" = "Bejelentkezés"; +"integration_retry_button" = "Újrapróbálkozás"; +"integration_add_server_button" = "Kiszolgáló hozzáadása"; +"media_servers_title" = "Médiakiszolgálók"; "integration_section_server_url" = "Szerver URL"; "integration_section_server_url_footer" = "Csatlakozás %@ szerverhez"; "integration_section_server" = "Szerver"; @@ -362,6 +365,7 @@ "integration_error_unexpected_response" = "Váratlan szerver válasz"; "integration_error_unexpected_response_with_code" = "Váratlan szerverválasz (Kód: %d - %@ )"; "integration_error_unauthorized" = "Sikertelen bejelentkezés. Ellenőrizze felhasználónevét és jelszavát."; +"integration_error_session_expired" = "A(z) %@ munkamenete lejárt. A folytatáshoz jelentkezz be újra."; "file_size_unknown" = "Ismeretlen méret"; "runtime_unknown" = "Ismeretlen időtartam"; "Swipe rows to see download options" = "Csúsztassa el a sorokat a letöltési lehetőségek megtekintéséhez"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index 10c6d8351..e5fbc6ce8 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Dettagli di connessione"; "integration_connect_button" = "Collegare"; "integration_sign_in_button" = "Registrazione"; +"integration_retry_button" = "Riprova"; +"integration_add_server_button" = "Aggiungi server"; +"media_servers_title" = "Server multimediali"; "integration_section_server_url" = "URL del server"; "integration_section_server_url_footer" = "Connettiti al tuo server %@"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Risposta imprevista del server"; "integration_error_unexpected_response_with_code" = "Risposta imprevista del server (codice: %d - %@ )"; "integration_error_unauthorized" = "Accesso non riuscito. Controlla il tuo nome utente e la tua password."; +"integration_error_session_expired" = "La tua sessione per %@ è scaduta. Accedi di nuovo per continuare."; "integration_selection_count" = "%d di %d Elementi"; "file_size_unknown" = "Dimensioni sconosciute"; "runtime_unknown" = "Durata sconosciuta"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index a51abf1eb..4f2047fe5 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -341,6 +341,9 @@ "integration_connection_details_title" = "接続の詳細"; "integration_connect_button" = "接続"; "integration_sign_in_button" = "サインイン"; +"integration_retry_button" = "再試行"; +"integration_add_server_button" = "サーバーを追加"; +"media_servers_title" = "メディアサーバー"; "integration_section_server_url" = "サーバURL"; "integration_section_server_url_footer" = "%@サーバに接続する"; "integration_section_server" = "サーバ"; @@ -362,6 +365,7 @@ "integration_error_unexpected_response" = "サーバからの予期しない応答"; "integration_error_unexpected_response_with_code" = "サーバからの予期しない応答(コード: %d - %@)"; "integration_error_unauthorized" = "サインインできませんでした。ユーザ名とパスワードを確認してください。"; +"integration_error_session_expired" = "%@ のセッションの有効期限が切れました。続行するには再度サインインしてください。"; "integration_selection_count" = "%d / %d アイテム"; "file_size_unknown" = "不明なサイズ"; "runtime_unknown" = "不明な再生時間"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index def19e01c..6e3a2a863 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -340,6 +340,9 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "integration_connection_details_title" = "Tilkoblingsdetaljer"; "integration_connect_button" = "Koble til"; "integration_sign_in_button" = "Logg på"; +"integration_retry_button" = "Prøv igjen"; +"integration_add_server_button" = "Legg til server"; +"media_servers_title" = "Medieservere"; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Koble til %@-serveren din"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "integration_error_unexpected_response" = "Uventet serverrespons"; "integration_error_unexpected_response_with_code" = "Uventet serverrespons (kode: %d - %@ )"; "integration_error_unauthorized" = "Pålogging mislyktes. Sjekk brukernavnet og passordet ditt."; +"integration_error_session_expired" = "Økten din for %@ er utløpt. Logg inn på nytt for å fortsette."; "integration_selection_count" = "%d av %d Elementer"; "file_size_unknown" = "Ukjent størrelse"; "runtime_unknown" = "Ukjent varighet"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index c81154072..0fb607ef4 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Verbindingsgegevens"; "integration_connect_button" = "Verbinden"; "integration_sign_in_button" = "Aanmelden"; +"integration_retry_button" = "Opnieuw proberen"; +"integration_add_server_button" = "Server toevoegen"; +"media_servers_title" = "Mediaservers"; "integration_section_server_url" = "Server-URL"; "integration_section_server_url_footer" = "Maak verbinding met uw %@-server"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Onverwachte serverreactie"; "integration_error_unexpected_response_with_code" = "Onverwachte serverrespons (Code: %d - %@ )"; "integration_error_unauthorized" = "Aanmelden mislukt. Controleer uw gebruikersnaam en wachtwoord."; +"integration_error_session_expired" = "Je sessie voor %@ is verlopen. Meld je opnieuw aan om door te gaan."; "integration_selection_count" = "%d van %d Items"; "file_size_unknown" = "Onbekende grootte"; "runtime_unknown" = "Onbekende duur"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index a0e772293..fcb4eb468 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Szczegóły połączenia"; "integration_connect_button" = "Łączyć"; "integration_sign_in_button" = "Zalogować się"; +"integration_retry_button" = "Spróbuj ponownie"; +"integration_add_server_button" = "Dodaj serwer"; +"media_servers_title" = "Serwery multimediów"; "integration_section_server_url" = "Adres URL serwera"; "integration_section_server_url_footer" = "Połącz się ze swoim serwerem %@"; "integration_section_server" = "Serwer"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Nieoczekiwana odpowiedź serwera"; "integration_error_unexpected_response_with_code" = "Nieoczekiwana odpowiedź serwera (Kod: %d - %@ )"; "integration_error_unauthorized" = "Logowanie nie powiodło się. Sprawdź swoją nazwę użytkownika i hasło."; +"integration_error_session_expired" = "Twoja sesja dla %@ wygasła. Zaloguj się ponownie, aby kontynuować."; "integration_selection_count" = "%d z %d Elementów"; "file_size_unknown" = "Nieznany rozmiar"; "runtime_unknown" = "Nieznany czas trwania"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index 0b973add5..28e938875 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Detalhes da conexão"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Entrar"; +"integration_retry_button" = "Tentar novamente"; +"integration_add_server_button" = "Adicionar servidor"; +"media_servers_title" = "Servidores de mídia"; "integration_section_server_url" = "URL do servidor"; "integration_section_server_url_footer" = "Conecte-se ao seu servidor %@"; "integration_section_server" = "Servidor"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Resposta inesperada do servidor"; "integration_error_unexpected_response_with_code" = "Resposta inesperada do servidor (Código: %d - %@ )"; "integration_error_unauthorized" = "Falha ao entrar. Verifique seu nome de usuário e senha."; +"integration_error_session_expired" = "Sua sessão em %@ expirou. Entre novamente para continuar."; "integration_selection_count" = "%d de %d Itens"; "file_size_unknown" = "Tamanho desconhecido"; "runtime_unknown" = "Duração desconhecida"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index 2a210d32b..dcfee280a 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Detalhes da conexão"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Entrar"; +"integration_retry_button" = "Tentar novamente"; +"integration_add_server_button" = "Adicionar servidor"; +"media_servers_title" = "Servidores de multimédia"; "integration_section_server_url" = "URL do servidor"; "integration_section_server_url_footer" = "Conecte-se ao seu servidor %@"; "integration_section_server" = "Servidor"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Resposta inesperada do servidor"; "integration_error_unexpected_response_with_code" = "Resposta inesperada do servidor (Código: %d - %@ )"; "integration_error_unauthorized" = "Falha ao entrar. Verifique seu nome de usuário e senha."; +"integration_error_session_expired" = "A sua sessão em %@ expirou. Inicie sessão novamente para continuar."; "integration_selection_count" = "%d de %d Itens"; "file_size_unknown" = "Tamanho desconhecido"; "runtime_unknown" = "Duração desconhecida"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index e9a00e371..4732aeb21 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Detalii de conectare"; "integration_connect_button" = "Conectați-vă"; "integration_sign_in_button" = "Conectare"; +"integration_retry_button" = "Reîncearcă"; +"integration_add_server_button" = "Adaugă server"; +"media_servers_title" = "Servere media"; "integration_section_server_url" = "Adresa URL a serverului"; "integration_section_server_url_footer" = "Conectați-vă la serverul dvs. %@"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Răspuns neașteptat al serverului"; "integration_error_unexpected_response_with_code" = "Răspuns neașteptat al serverului (Cod: %d - %@ )"; "integration_error_unauthorized" = "Conectarea a eșuat. Verificați-vă numele de utilizator și parola."; +"integration_error_session_expired" = "Sesiunea ta pentru %@ a expirat. Conectează-te din nou pentru a continua."; "integration_selection_count" = "%d din %d Elemente"; "file_size_unknown" = "Dimensiune necunoscută"; "runtime_unknown" = "Durată necunoscută"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index f5a5d3b92..c06fc21bd 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Подробности подключения"; "integration_connect_button" = "Подключиться"; "integration_sign_in_button" = "Войти"; +"integration_retry_button" = "Повторить"; +"integration_add_server_button" = "Добавить сервер"; +"media_servers_title" = "Медиасерверы"; "integration_section_server_url" = "URL-адрес сервера"; "integration_section_server_url_footer" = "Подключитесь к вашему серверу %@"; "integration_section_server" = "Сервер"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Неожиданный ответ сервера"; "integration_error_unexpected_response_with_code" = "Неожиданный ответ сервера (Код: %d - %@)"; "integration_error_unauthorized" = "Ошибка входа. Проверьте имя пользователя и пароль."; +"integration_error_session_expired" = "Срок действия сеанса для %@ истёк. Войдите снова, чтобы продолжить."; "integration_selection_count" = "%d из %d Элементов"; "file_size_unknown" = "Неизвестный размер"; "runtime_unknown" = "Неизвестная продолжительность"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index b54262626..d38cea317 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -341,6 +341,9 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "integration_connection_details_title" = "Podrobnosti pripojenia"; "integration_connect_button" = "Pripojiť sa"; "integration_sign_in_button" = "Prihlásiť sa"; +"integration_retry_button" = "Skúsiť znova"; +"integration_add_server_button" = "Pridať server"; +"media_servers_title" = "Mediálne servery"; "integration_section_server_url" = "URL servera"; "integration_section_server_url_footer" = "Pripojenie sa k serveru %@"; "integration_section_server" = "Server"; @@ -362,6 +365,7 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "integration_error_unexpected_response" = "Neočakávaná odpoveď servera"; "integration_error_unexpected_response_with_code" = "Neočakávaná odpoveď servera (kód: %d – %@)"; "integration_error_unauthorized" = "Prihlásenie zlyhalo. Skontrolujte svoje používateľské meno a heslo."; +"integration_error_session_expired" = "Vaša relácia pre %@ vypršala. Pre pokračovanie sa znova prihláste."; "integration_selection_count" = "%d z %d Položiek"; "file_size_unknown" = "Neznáma veľkosť"; "runtime_unknown" = "Neznáme trvanie"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index e65414965..47c56897f 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Anslutningsdetaljer"; "integration_connect_button" = "Ansluta"; "integration_sign_in_button" = "Logga in"; +"integration_retry_button" = "Försök igen"; +"integration_add_server_button" = "Lägg till server"; +"media_servers_title" = "Medieservrar"; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Anslut till din %@-server"; "integration_section_server" = "Server"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Oväntat serversvar"; "integration_error_unexpected_response_with_code" = "Oväntat serversvar (kod: %d - %@ )"; "integration_error_unauthorized" = "Inloggning misslyckades. Kontrollera ditt användarnamn och lösenord."; +"integration_error_session_expired" = "Din session för %@ har upphört. Logga in igen för att fortsätta."; "integration_selection_count" = "%d av %d Objekt"; "file_size_unknown" = "Okänd storlek"; "runtime_unknown" = "Okänd varaktighet"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index a225da55c..da1ac6be7 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Bağlantı Ayrıntıları"; "integration_connect_button" = "Bağlamak"; "integration_sign_in_button" = "Kayıt olmak"; +"integration_retry_button" = "Yeniden dene"; +"integration_add_server_button" = "Sunucu ekle"; +"media_servers_title" = "Medya sunucuları"; "integration_section_server_url" = "Sunucu URL'si"; "integration_section_server_url_footer" = "%@ sunucunuza bağlanın"; "integration_section_server" = "Sunucu"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Beklenmeyen sunucu yanıtı"; "integration_error_unexpected_response_with_code" = "Beklenmeyen sunucu yanıtı (Kod: %d - %@ )"; "integration_error_unauthorized" = "Giriş başarısız. Kullanıcı adınızı ve şifrenizi kontrol edin."; +"integration_error_session_expired" = "%@ için oturumunuzun süresi doldu. Devam etmek için tekrar oturum açın."; "integration_selection_count" = "%d / %d Öğe"; "file_size_unknown" = "Bilinmeyen boyut"; "runtime_unknown" = "Bilinmeyen süre"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 2f5f6dac1..451748433 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "Деталі підключення"; "integration_connect_button" = "Підключитися"; "integration_sign_in_button" = "Увійти"; +"integration_retry_button" = "Повторити"; +"integration_add_server_button" = "Додати сервер"; +"media_servers_title" = "Медіасервери"; "integration_section_server_url" = "URL-адреса сервера"; "integration_section_server_url_footer" = "Підключіться до свого сервера %@"; "integration_section_server" = "Сервер"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "Неочікувана відповідь сервера"; "integration_error_unexpected_response_with_code" = "Неочікувана відповідь сервера (Код: %d - %@ )"; "integration_error_unauthorized" = "Помилка входу. Перевірте своє ім'я користувача та пароль."; +"integration_error_session_expired" = "Термін дії сеансу для %@ минув. Увійдіть знову, щоб продовжити."; "integration_selection_count" = "%d з %d Елементів"; "file_size_unknown" = "Невідомий розмір"; "runtime_unknown" = "Невідома тривалість"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index b6bf910e5..dbb97db69 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -340,6 +340,9 @@ "integration_connection_details_title" = "连接详细信息"; "integration_connect_button" = "连接"; "integration_sign_in_button" = "登入"; +"integration_retry_button" = "重试"; +"integration_add_server_button" = "添加服务器"; +"media_servers_title" = "媒体服务器"; "integration_section_server_url" = "服务器 URL"; "integration_section_server_url_footer" = "连接到您的 %@ 服务器"; "integration_section_server" = "服务器"; @@ -361,6 +364,7 @@ "integration_error_unexpected_response" = "意外的服务器响应"; "integration_error_unexpected_response_with_code" = "意外的服务器响应(代码: %d - %@ )"; "integration_error_unauthorized" = "登录失败。请检查您的用户名和密码。"; +"integration_error_session_expired" = "您在 %@ 的会话已过期。请重新登录以继续。"; "integration_selection_count" = "%d / %d 项"; "file_size_unknown" = "未知尺寸"; "runtime_unknown" = "持续时间未知"; diff --git a/BookPlayerTests/Mocks/KeychainServiceMock.swift b/BookPlayerTests/Mocks/KeychainServiceMock.swift index 4dbd112be..d4b524edf 100644 --- a/BookPlayerTests/Mocks/KeychainServiceMock.swift +++ b/BookPlayerTests/Mocks/KeychainServiceMock.swift @@ -9,7 +9,7 @@ import BookPlayerKit import Combine -class KeychainServiceMock: KeychainServiceProtocol { +final class KeychainServiceMock: KeychainServiceProtocol, @unchecked Sendable { var valueUpdatedPublisher = PassthroughSubject() private let encoder = JSONEncoder() diff --git a/Shared/Extensions/URL+BookPlayer.swift b/Shared/Extensions/URL+BookPlayer.swift index a268b2110..30499c6f1 100644 --- a/Shared/Extensions/URL+BookPlayer.swift +++ b/Shared/Extensions/URL+BookPlayer.swift @@ -5,6 +5,40 @@ public extension URL { return self.deletingPathExtension().lastPathComponent } + /// Canonical form used for media-server connection deduplication. Two URLs that point at the + /// same server but differ only in trivial ways — scheme/host case, default ports, trailing + /// slash — collapse to the same canonical string here. + /// + /// Used by `JellyfinConnectionService` and `AudiobookShelfConnectionService` to dedupe saved + /// connections so the user doesn't end up with two entries for one server when they re-type + /// the URL after a token expiry (or add the same server from two slightly-different inputs). + var canonicalDedupKey: String { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return absoluteString + } + components.scheme = components.scheme?.lowercased() + components.host = components.host?.lowercased() + components.user = nil + components.password = nil + components.fragment = nil + components.query = nil + + if let port = components.port, + (components.scheme == "http" && port == 80) + || (components.scheme == "https" && port == 443) { + components.port = nil + } + + // Trim any trailing slash, including the root "/". Without this, `https://example.com` + // and `https://example.com/` produced different canonical keys, defeating dedup for + // the most common user variation. + while components.path.hasSuffix("/") { + components.path.removeLast() + } + + return components.url?.absoluteString ?? absoluteString + } + func relativePath(to baseURL: URL) -> String { let lastPath = self.path.components(separatedBy: baseURL.path).last ?? "" if !lastPath.isEmpty, diff --git a/Shared/Services/KeychainService.swift b/Shared/Services/KeychainService.swift index e3ac9af34..ea6fc0365 100644 --- a/Shared/Services/KeychainService.swift +++ b/Shared/Services/KeychainService.swift @@ -10,7 +10,7 @@ import Combine import Foundation import Security -public protocol KeychainServiceProtocol { +public protocol KeychainServiceProtocol: Sendable { /// Publishes anytime a value of the `KeychainKeys` is modified var valueUpdatedPublisher: PassthroughSubject { get } @@ -36,13 +36,19 @@ public enum KeychainKeys: String { case hardcoverToken = "hardcover_token" } -public class KeychainService: KeychainServiceProtocol { +public final class KeychainService: KeychainServiceProtocol, Sendable { let service = Bundle.main.configurationString(for: .bundleIdentifier) - private let encoder: JSONEncoder = JSONEncoder() - private let decoder: JSONDecoder = JSONDecoder() + /// JSONEncoder/Decoder aren't Sendable in Foundation, but we only use them + /// inside synchronous methods on transient state — never share them across + /// concurrency domains. `nonisolated(unsafe)` documents that exception + /// without weakening the class-level Sendable guarantee. + nonisolated(unsafe) private let encoder: JSONEncoder = JSONEncoder() + nonisolated(unsafe) private let decoder: JSONDecoder = JSONDecoder() - public var valueUpdatedPublisher = PassthroughSubject() + /// PassthroughSubject isn't marked Sendable in Combine at our deployment target. + /// It's internally thread-safe via Combine's send() implementation. + nonisolated(unsafe) public let valueUpdatedPublisher = PassthroughSubject() public init() {} From b508fb670a85b806006a579967c539450740fd60 Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Sat, 30 May 2026 12:09:48 -0700 Subject: [PATCH 03/17] Don't crash auto-play when the audio session is held by another process (#1524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: don't crash when audio session activation fails PlayerManager.play(autoPlayed:) called fatalError() if either setCategory or setActive(true) threw. Background auto-play (CarPlay, headphones, lock-screen, scheduled) routinely hits cases where another process holds the audio session (phone call, navigation, voice memo) or the OS denies activation, and the app would hard-crash with EXC_BREAKPOINT in libswiftCore from the fatalError trap. Replace with a log-and-return: NSLog the underlying error, clear the queued-playback flag, bail out of this play attempt. A missed auto-play is strictly better than the app dying in the background. Crash signature: TestFlight feedback build 5.19.0 (20260428053421), iPhone 15 Pro / iOS 26.5, role=Background, in PlayerManager.swift:876. * fix: recover when audio session is contested instead of bailing silently Follow-up to e66eb627. Make the recovery match how iOS audio apps normally behave: bind the interruption observer before attempting activation, and on failure keep playbackQueued = true so the existing handleAudioInterruptions path will retry play(autoPlayed:) when iOS reports the session is free again (call ended, navigation done, voice memo dismissed). Previously the catch left playbackQueued = nil, so a contested auto-play was a dead-end until the user manually pressed play. * fix: gate audio-session recovery to beta and retry with backoff Addresses review feedback on #1524. The previous recovery relied solely on AVAudioSession's `.ended` interrupt notification to retry play. That notification only fires for sessions that were already active and got interrupted — it does not fire when `setActive(true)` itself throws, which is precisely the unrecoverable state described in review (force-quit was the only fix). Without an alternative trigger the UI would sit in the queued/loading state indefinitely. Changes: - Gate the non-fatal path to beta (TestFlight / DEBUG) builds. Release builds keep the original `fatalError` so the App Store version isn't masked while the recovery is unproven. - Add `scheduleAudioSessionRecovery`: bounded retry at 1s/3s/8s using the deactivate-then-reactivate pattern that's known to unstick the contested-session state. On success, resume playback; on exhaustion, clear `playbackQueued` and surface an alert with the same actionable hint the crash provided ("close and reopen"). - Cancel any pending retry from `play`, `pause`, and `stopPlayback` so user-initiated state changes aren't clobbered by a late retry. * fix: use AppEnvironment.isTestFlight for the recovery gate Replaces a duplicated TestFlight check with the existing AppEnvironment.isTestFlight helper used elsewhere in the project (TipJar, AccountService, PurchasesManager). Behavior change: DEBUG builds no longer take the recovery path — they keep the original fatalError so the failure is loud during development. Only true TestFlight installs run the bounded retry, which matches the "just to see if we get reports" intent of the gate. * simplify report * cleanup --------- Co-authored-by: Gianni Carlo --- BookPlayer/Player/PlayerManager.swift | 72 ++++++++++++++++++++++++++- Shared/Constants.swift | 5 ++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index fb6d0e1d7..54634b69e 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -11,6 +11,7 @@ import AVFoundation import Combine import Foundation import MediaPlayer +import Sentry // swiftlint:disable:next file_length @@ -39,6 +40,9 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { @Published private var isFetchingRemoteURL: Bool? /// Prevent loop from automatic URL refreshes private var canFetchRemoteURL = true + /// Set when audio-session activation fails in the current process, so a later + /// successful activation can tell whether recovery required an app relaunch. + private var audioSessionFailedThisSession = false private var hasObserverRegistered = false private var observeStatus: Bool = false { didSet { @@ -872,6 +876,45 @@ extension PlayerManager { play(autoPlayed: false) } + /// Persist a marker so the next successful activation can report whether — and how — + /// the audio session recovered. Beta builds only. + private func markAudioSessionFailure(_ error: NSError) { + let defaults = UserDefaults.standard + defaults.set(Date().timeIntervalSince1970, forKey: Constants.UserDefaults.audioSessionFailureTimestamp) + defaults.set(error.code, forKey: Constants.UserDefaults.audioSessionFailureCode) + audioSessionFailedThisSession = true + } + + /// If a prior activation failure was recorded, report to Sentry that the session + /// recovered — including whether it took an app relaunch (force-quit / OS kill) to + /// get audio back. Beta builds only. + private func reportAudioSessionRecoveryIfNeeded() { + guard AppEnvironment.isTestFlight else { return } + let defaults = UserDefaults.standard + guard + let failedAt = defaults.object(forKey: Constants.UserDefaults.audioSessionFailureTimestamp) as? Double + else { return } + + // A marker written in this same process means the session self-healed; a marker + // from a previous launch means audio only returned after the process restarted. + let requiredRelaunch = !audioSessionFailedThisSession + + SentrySDK.capture(message: "Audio session recovered after activation failure") { scope in + scope.setLevel(.info) + scope.setFingerprint(["audio-session-recovery"]) + scope.setTag(value: "audio_session_recovery", key: "playback_failure") + scope.setContext(value: [ + "secondsSinceFailure": Int(Date().timeIntervalSince1970 - failedAt), + "requiredAppRelaunch": requiredRelaunch, + "previousErrorCode": defaults.integer(forKey: Constants.UserDefaults.audioSessionFailureCode), + ], key: "audio_session") + } + + defaults.removeObject(forKey: Constants.UserDefaults.audioSessionFailureTimestamp) + defaults.removeObject(forKey: Constants.UserDefaults.audioSessionFailureCode) + audioSessionFailedThisSession = false + } + func play(autoPlayed: Bool) { playTask?.cancel() playTask = Task { @MainActor in @@ -893,8 +936,35 @@ extension PlayerManager { options: [] ) try audioSession.setActive(true) + reportAudioSessionRecoveryIfNeeded() } catch { - fatalError("Failed to activate the audio session, \(error), description: \(error.localizedDescription)") + guard AppEnvironment.isTestFlight else { + fatalError("Failed to activate the audio session, \(error), description: \(error.localizedDescription)") + } + /// Beta-only: don't hard-crash when the audio session can't be activated. + /// This happens when another process holds the session in a stuck state + /// that (so far) only a force-quit clears. Report it to Sentry so we can + /// confirm whether the stuck state still occurs, and leave the play button + /// inert — a missed auto-play is strictly better than the app dying in the + /// background. + let nsError = error as NSError + SentrySDK.capture(error: error) { scope in + scope.setLevel(.error) + scope.setFingerprint(["audio-session-activation-failure"]) + scope.setTag(value: "audio_session_activation", key: "playback_failure") + scope.setContext(value: [ + "errorCode": nsError.code, + "errorDomain": nsError.domain, + "isOtherAudioPlaying": AVAudioSession.sharedInstance().isOtherAudioPlaying, + "applicationState": UIApplication.shared.applicationState == .active ? "active" : "background", + "autoPlayed": autoPlayed, + "relativePath": currentItem.relativePath, + ], key: "audio_session") + } + markAudioSessionFailure(nsError) + NSLog("[PlayerManager] Audio session activation failed: %@", error.localizedDescription) + playbackQueued = nil + return } createOrUpdateAutomaticBookmark( diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 8d4c94207..611a8f17d 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -111,6 +111,11 @@ public enum Constants { /// JSON-encoded `[String: ISO-8601-Date]` of synced-pref keys awaiting server flush public static let userPreferencesDirty = "userPreferences.dirty" + + /// Beta-only audio-session recovery telemetry: epoch seconds of the last activation failure + public static let audioSessionFailureTimestamp = "audioSession.failureTimestamp" + /// Beta-only audio-session recovery telemetry: error code of the last activation failure + public static let audioSessionFailureCode = "audioSession.failureCode" } public enum SkipInterval { From 11f9f0a25c88eb3e019c7b06e602f32a92f40b28 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sun, 31 May 2026 14:20:47 -0500 Subject: [PATCH 04/17] Fix searching on iOS 18 (#1544) * Fix searching on iOS 18 * Address feedback --- .../Generated/AutoMockable.generated.swift | 8 +- .../Library/ItemList/ItemListView.swift | 11 ++ .../Library/ItemList/ItemListViewModel.swift | 103 ++++++++++++++---- .../ItemList/Models/ItemListSearchScope.swift | 11 ++ .../Services/LibraryServiceTests.swift | 90 +++++++++++++++ Shared/Services/LibraryService.swift | 20 +++- 6 files changed, 214 insertions(+), 29 deletions(-) diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index 10c90358f..fd3b9e1b6 100644 --- a/BookPlayer/Generated/AutoMockable.generated.swift +++ b/BookPlayer/Generated/AutoMockable.generated.swift @@ -364,11 +364,11 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol { var filterContentsAtQueryScopeLimitOffsetCalled: Bool { return filterContentsAtQueryScopeLimitOffsetCallsCount > 0 } - var filterContentsAtQueryScopeLimitOffsetReceivedArguments: (relativePath: String?, query: String?, scope: SimpleItemType, limit: Int?, offset: Int?)? - var filterContentsAtQueryScopeLimitOffsetReceivedInvocations: [(relativePath: String?, query: String?, scope: SimpleItemType, limit: Int?, offset: Int?)] = [] + var filterContentsAtQueryScopeLimitOffsetReceivedArguments: (relativePath: String?, query: String?, scope: SimpleItemType?, limit: Int?, offset: Int?)? + var filterContentsAtQueryScopeLimitOffsetReceivedInvocations: [(relativePath: String?, query: String?, scope: SimpleItemType?, limit: Int?, offset: Int?)] = [] var filterContentsAtQueryScopeLimitOffsetReturnValue: [SimpleLibraryItem]? - var filterContentsAtQueryScopeLimitOffsetClosure: ((String?, String?, SimpleItemType, Int?, Int?) -> [SimpleLibraryItem]?)? - func filterContents(at relativePath: String?, query: String?, scope: SimpleItemType, limit: Int?, offset: Int?) -> [SimpleLibraryItem]? { + var filterContentsAtQueryScopeLimitOffsetClosure: ((String?, String?, SimpleItemType?, Int?, Int?) -> [SimpleLibraryItem]?)? + func filterContents(at relativePath: String?, query: String?, scope: SimpleItemType?, limit: Int?, offset: Int?) -> [SimpleLibraryItem]? { filterContentsAtQueryScopeLimitOffsetCallsCount += 1 filterContentsAtQueryScopeLimitOffsetReceivedArguments = (relativePath: relativePath, query: query, scope: scope, limit: limit, offset: offset) filterContentsAtQueryScopeLimitOffsetReceivedInvocations.append((relativePath: relativePath, query: query, scope: scope, limit: limit, offset: offset)) diff --git a/BookPlayer/Library/ItemList/ItemListView.swift b/BookPlayer/Library/ItemList/ItemListView.swift index cfe736213..a8915abba 100644 --- a/BookPlayer/Library/ItemList/ItemListView.swift +++ b/BookPlayer/Library/ItemList/ItemListView.swift @@ -149,6 +149,7 @@ struct ItemListView: View { ForEach(model.filteredResults, id: \.id) { item in rowView(item) .accessibilityRotorEntry(id: item.id, in: customRotorNamespace) + .moveDisabled(model.isFiltering) } .onMove { source, destination in model.reorderItems(source: source, destination: destination) @@ -164,6 +165,16 @@ struct ItemListView: View { contextMenuContent() } } + .overlay { + if model.isFiltering && model.filteredResults.isEmpty { + if model.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + /// Scope-only filter (e.g. "Folders") with no matches — no query text to echo. + ContentUnavailableView.search + } else { + ContentUnavailableView.search(text: model.query) + } + } + } .accessibilityElement(children: .contain) .accessibilityRotor("books_title") { customBookRotor(with: scrollView) diff --git a/BookPlayer/Library/ItemList/ItemListViewModel.swift b/BookPlayer/Library/ItemList/ItemListViewModel.swift index 0d2f7ec86..60d199623 100644 --- a/BookPlayer/Library/ItemList/ItemListViewModel.swift +++ b/BookPlayer/Library/ItemList/ItemListViewModel.swift @@ -38,27 +38,19 @@ final class ItemListViewModel: ObservableObject { } } - var filteredResults: [SimpleLibraryItem] { - var filteredItems = items + /// Query and scope are now applied at the database layer (see `scheduleSearch`), so the rendered + /// results are simply the loaded items. + var filteredResults: [SimpleLibraryItem] { items } - switch scope { - case .books: filteredItems.removeAll { $0.type == .folder } - case .folders: filteredItems.removeAll { $0.type != .folder } - case .all: break - } - - if !query.isEmpty { - filteredItems = filteredItems.filter { - $0.title.localizedCaseInsensitiveContains(query) - || $0.details.localizedCaseInsensitiveContains(query) - || $0.originalFileName.localizedCaseInsensitiveContains(query) - } - } - return filteredItems + /// True when the list should show database-filtered results instead of the paginated browse. + var isFiltering: Bool { + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || scope != .all } var isListEmpty: Bool { - items.isEmpty && !canLoadMore + /// While searching, an empty result set must keep showing the list (with its search bar), not + /// the "add files" empty state. + items.isEmpty && !canLoadMore && !isFiltering } var navigationTitle: String { @@ -84,8 +76,18 @@ final class ItemListViewModel: ObservableObject { var pendingMoveItemIdentifiers: [LibraryItemRef]? /// Search - @Published var scope: ItemListSearchScope = .all - @Published var query = "" + @Published var scope: ItemListSearchScope = .all { + didSet { + guard oldValue != scope else { return } + scheduleSearch() + } + } + @Published var query = "" { + didSet { + guard oldValue != query else { return } + scheduleSearch() + } + } @Published var isSearchFocused: Bool = false { didSet { listState.isSearching = isSearchFocused @@ -94,6 +96,8 @@ final class ItemListViewModel: ObservableObject { } } } + /// Debounced, cancellable task backing the database search. + private var searchTask: Task? init( libraryNode: LibraryNode, @@ -162,6 +166,64 @@ final class ItemListViewModel: ObservableObject { isLoading = false } + // MARK: - Search + + /// Reacts to query/scope changes: debounces then runs a scoped database search, or restores the + /// paginated browse immediately when the search is cleared. + /// + /// Because the view model is `@MainActor`, `runSearch` (after its `Task.sleep`) and + /// `restoreBrowse` can never interleave; cancelling the in-flight task and re-checking + /// `Task.isCancelled` after the only suspension point keeps a stale search from overwriting a + /// restored browse. + private func scheduleSearch() { + searchTask?.cancel() + + guard isFiltering else { + restoreBrowse() + return + } + + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let scope = self.scope.itemTypeScope + + searchTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled, let self else { return } + self.runSearch(query: trimmedQuery.isEmpty ? nil : trimmedQuery, scope: scope) + } + } + + private func runSearch(query: String?, scope: SimpleItemType?) { + let results = + libraryService.filterContents( + at: libraryNode.folderRelativePath, + query: query, + scope: scope, + limit: nil, + offset: nil + ) ?? [] + + items = results + offset = items.count + /// Search returns the full result set in a single fetch, so there is nothing left to page in. + canLoadMore = false + } + + /// Restores the first page of the normal paginated browse after a search is cleared. + private func restoreBrowse() { + let pageSize = 13 + let fetchedItems = + libraryService.fetchContents( + at: libraryNode.folderRelativePath, + limit: pageSize, + offset: 0 + ) ?? [] + + items = fetchedItems + offset = items.count + canLoadMore = fetchedItems.count == pageSize + } + func prefetchIfNeeded(for item: SimpleLibraryItem) async { guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } @@ -253,6 +315,9 @@ final class ItemListViewModel: ObservableObject { source: IndexSet, destination: Int ) { + /// Reordering a filtered subset would corrupt orderRank, so ignore moves while searching. + guard !isFiltering else { return } + libraryService.reorderItems( inside: libraryNode.folderRelativePath, fromOffsets: source, diff --git a/BookPlayer/Library/ItemList/Models/ItemListSearchScope.swift b/BookPlayer/Library/ItemList/Models/ItemListSearchScope.swift index fed0b534c..75b0e8a5c 100644 --- a/BookPlayer/Library/ItemList/Models/ItemListSearchScope.swift +++ b/BookPlayer/Library/ItemList/Models/ItemListSearchScope.swift @@ -6,6 +6,7 @@ // Copyright © 2025 BookPlayer LLC. All rights reserved. // +import BookPlayerKit import SwiftUI enum ItemListSearchScope: String, CaseIterable, Identifiable { @@ -20,4 +21,14 @@ enum ItemListSearchScope: String, CaseIterable, Identifiable { case .folders: "folders_title" } } + + /// Maps the UI scope to the Core Data item type used by `LibraryService.filterContents`. + /// `nil` means no type restriction (all items). + var itemTypeScope: SimpleItemType? { + switch self { + case .all: nil + case .books: .book + case .folders: .folder + } + } } diff --git a/BookPlayerTests/Services/LibraryServiceTests.swift b/BookPlayerTests/Services/LibraryServiceTests.swift index a15362859..517e076d7 100644 --- a/BookPlayerTests/Services/LibraryServiceTests.swift +++ b/BookPlayerTests/Services/LibraryServiceTests.swift @@ -1499,6 +1499,96 @@ class ModifyLibraryTests: LibraryServiceTests { XCTAssert(fetchedResults?.first?.relativePath == book4.relativePath) XCTAssert(fetchedResults?.last?.relativePath == book3.relativePath) } + + func testFilterContentsSearchesFolderSubtreeRecursively() throws { + let parent = try StubFactory.folder(dataManager: self.sut.dataManager, title: "Parent") + try self.sut.moveItems([LibraryItemRef(relativePath: parent.relativePath, uuid: parent.uuid)], inside: nil) + + let child = try StubFactory.folder(dataManager: self.sut.dataManager, title: "Child") + try self.sut.moveItems([LibraryItemRef(relativePath: child.relativePath, uuid: child.uuid)], inside: parent.relativePath) + + let nestedBook = StubFactory.book(dataManager: self.sut.dataManager, title: "nestedBook", duration: 100) + try self.sut.moveItems([LibraryItemRef(relativePath: nestedBook.relativePath, uuid: nestedBook.uuid)], inside: child.relativePath) + self.sut.dataManager.saveContext() + + // A book two levels deep is found when searching the parent folder (recursive subtree). + let nestedResults = self.sut.filterContents(at: parent.relativePath, query: "nestedBook", scope: .book, limit: nil, offset: nil) + + XCTAssert(nestedResults?.count == 1) + XCTAssert(nestedResults?.first?.relativePath == nestedBook.relativePath) + } + + func testFilterContentsExcludesSiblingFolderWithSharedPrefix() throws { + let folder = try StubFactory.folder(dataManager: self.sut.dataManager, title: "Folder") + let folderTwo = try StubFactory.folder(dataManager: self.sut.dataManager, title: "FolderTwo") + try self.sut.moveItems([ + LibraryItemRef(relativePath: folder.relativePath, uuid: folder.uuid), + LibraryItemRef(relativePath: folderTwo.relativePath, uuid: folderTwo.uuid), + ], inside: nil) + + let bookA = StubFactory.book(dataManager: self.sut.dataManager, title: "bookA", duration: 100) + try self.sut.moveItems([LibraryItemRef(relativePath: bookA.relativePath, uuid: bookA.uuid)], inside: folder.relativePath) + let bookB = StubFactory.book(dataManager: self.sut.dataManager, title: "bookB", duration: 100) + try self.sut.moveItems([LibraryItemRef(relativePath: bookB.relativePath, uuid: bookB.uuid)], inside: folderTwo.relativePath) + self.sut.dataManager.saveContext() + + // Scoping to "Folder" must not leak items from the prefix-sharing sibling "FolderTwo". + let results = self.sut.filterContents(at: folder.relativePath, query: nil, scope: .book, limit: nil, offset: nil) + + XCTAssert(results?.count == 1) + XCTAssert(results?.first?.relativePath == bookA.relativePath) + } + + func testFilterContentsGlobalSearchAcrossFolders() throws { + let rootBook = StubFactory.book(dataManager: self.sut.dataManager, title: "alphaRoot", duration: 100) + let folder = try StubFactory.folder(dataManager: self.sut.dataManager, title: "folder") + try self.sut.moveItems([ + LibraryItemRef(relativePath: rootBook.relativePath, uuid: rootBook.uuid), + LibraryItemRef(relativePath: folder.relativePath, uuid: folder.uuid), + ], inside: nil) + let nestedBook = StubFactory.book(dataManager: self.sut.dataManager, title: "alphaNested", duration: 100) + try self.sut.moveItems([LibraryItemRef(relativePath: nestedBook.relativePath, uuid: nestedBook.uuid)], inside: folder.relativePath) + self.sut.dataManager.saveContext() + + // A nil path searches the whole library, matching books at the root and inside folders. + let results = self.sut.filterContents(at: nil, query: "alpha", scope: .book, limit: nil, offset: nil) + + XCTAssert(results?.count == 2) + } + + func testFilterContentsNilScopeReturnsBooksAndFolders() throws { + let book = StubFactory.book(dataManager: self.sut.dataManager, title: "matchBook", duration: 100) + let folder = try StubFactory.folder(dataManager: self.sut.dataManager, title: "matchFolder") + try self.sut.moveItems([ + LibraryItemRef(relativePath: book.relativePath, uuid: book.uuid), + LibraryItemRef(relativePath: folder.relativePath, uuid: folder.uuid), + ], inside: nil) + self.sut.dataManager.saveContext() + + // A nil scope matches all item types. + let all = self.sut.filterContents(at: nil, query: "match", scope: nil, limit: nil, offset: nil) + XCTAssert(all?.count == 2) + + let onlyBooks = self.sut.filterContents(at: nil, query: "match", scope: .book, limit: nil, offset: nil) + XCTAssert(onlyBooks?.count == 1) + XCTAssert(onlyBooks?.first?.relativePath == book.relativePath) + + let onlyFolders = self.sut.filterContents(at: nil, query: "match", scope: .folder, limit: nil, offset: nil) + XCTAssert(onlyFolders?.count == 1) + XCTAssert(onlyFolders?.first?.relativePath == folder.relativePath) + } + + func testFilterContentsMatchesOriginalFileName() throws { + let book = StubFactory.book(dataManager: self.sut.dataManager, title: "Pride and Prejudice", duration: 100) + try self.sut.moveItems([LibraryItemRef(relativePath: book.relativePath, uuid: book.uuid)], inside: nil) + self.sut.dataManager.saveContext() + + // The title/details don't contain ".txt"; the match comes from originalFileName. + let results = self.sut.filterContents(at: nil, query: ".txt", scope: .book, limit: nil, offset: nil) + + XCTAssert(results?.count == 1) + XCTAssert(results?.first?.relativePath == book.relativePath) + } // swiftlint:enable force_cast } diff --git a/Shared/Services/LibraryService.swift b/Shared/Services/LibraryService.swift index d9b54a9b2..0499570e7 100644 --- a/Shared/Services/LibraryService.swift +++ b/Shared/Services/LibraryService.swift @@ -60,11 +60,12 @@ public protocol LibraryServiceProtocol: AnyObject { func getItems(notIn relativePaths: [String], parentFolder: String?) -> [SimpleLibraryItem]? /// Fetch a property from a stored library item func getItemProperty(_ property: String, relativePath: String) -> Any? - /// Search + /// Search the items within a folder subtree (recursive). A `nil` relativePath searches the + /// whole library; a `nil` scope matches all item types. func filterContents( at relativePath: String?, query: String?, - scope: SimpleItemType, + scope: SimpleItemType?, limit: Int?, offset: Int? ) -> [SimpleLibraryItem]? @@ -446,7 +447,7 @@ public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { private func buildFilterPredicate( relativePath: String?, query: String?, - scope: SimpleItemType + scope: SimpleItemType? ) -> NSPredicate { var predicates = [NSPredicate]() @@ -459,6 +460,8 @@ public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { predicates.append( NSPredicate(format: "%K != \(SimpleItemType.folder.rawValue)", #keyPath(LibraryItem.type)) ) + case .none: + break } if let query = query, @@ -466,18 +469,23 @@ public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { { predicates.append( NSPredicate( - format: "%K CONTAINS[cd] %@ OR %K CONTAINS[cd] %@", + format: "%K CONTAINS[cd] %@ OR %K CONTAINS[cd] %@ OR %K CONTAINS[cd] %@", #keyPath(LibraryItem.title), query, #keyPath(LibraryItem.details), + query, + #keyPath(LibraryItem.originalFileName), query ) ) } + /// Scope to the folder's subtree (recursive). Matching the item's own `relativePath` against + /// the `"/"` prefix catches direct children and nested items, while the trailing slash + /// avoids matching sibling folders that share a name prefix (e.g. "Sci-Fi" vs "Sci-Fi 2"). if let relativePath = relativePath { predicates.append( - NSPredicate(format: "%K == %@", #keyPath(LibraryItem.folder.relativePath), relativePath) + NSPredicate(format: "%K BEGINSWITH %@", #keyPath(LibraryItem.relativePath), relativePath + "/") ) } @@ -1507,7 +1515,7 @@ extension LibraryService { public func filterContents( at relativePath: String?, query: String?, - scope: SimpleItemType, + scope: SimpleItemType?, limit: Int?, offset: Int? ) -> [SimpleLibraryItem]? { From 2b6df27927d19427a21c5590cd5b7b52285b6ed6 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Thu, 4 Jun 2026 06:53:55 -0500 Subject: [PATCH 05/17] Fix updating last played widget after delete (#1545) * Fix updating last played widget after delete * address feedback * address feedback --- BookPlayer.xcodeproj/project.pbxproj | 10 + BookPlayer/Player/PlayerManager.swift | 18 +- BookPlayerTests/SharedWidgetStoreTests.swift | 186 ++++++++++++++++++ .../LocalPlayback/Player/PlayerManager.swift | 18 +- Shared/Services/Account/AccountAPI.swift | 9 + Shared/Services/Account/AccountService.swift | 11 +- Shared/Services/LibraryService.swift | 15 ++ Shared/Services/SharedWidgetStore.swift | 119 +++++++++++ 8 files changed, 349 insertions(+), 37 deletions(-) create mode 100644 BookPlayerTests/SharedWidgetStoreTests.swift create mode 100644 Shared/Services/SharedWidgetStore.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 76c6e35f2..db5c4b9b9 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -321,6 +321,9 @@ 630826082AF54BB1002ACE0D /* Spacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F22DE36288CBCA400056FCD /* Spacing.swift */; }; 6308260A2AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630826092AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift */; }; 6308260B2AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630826092AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift */; }; + FEEDBABE0000000000000002 /* SharedWidgetStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDBABE0000000000000001 /* SharedWidgetStore.swift */; }; + FEEDBABE0000000000000003 /* SharedWidgetStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDBABE0000000000000001 /* SharedWidgetStore.swift */; }; + FEEDBABE0000000000000005 /* SharedWidgetStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */; }; 6308260D2AF6C312002ACE0D /* WidgetReloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6308260C2AF6C312002ACE0D /* WidgetReloadService.swift */; }; 630826112AF6CA44002ACE0D /* SharedIconWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6308260F2AF6C9B0002ACE0D /* SharedIconWidget.swift */; }; 630826122AF6CA45002ACE0D /* SharedIconWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6308260F2AF6C9B0002ACE0D /* SharedIconWidget.swift */; }; @@ -1291,6 +1294,8 @@ 630825FD2AF293DC002ACE0D /* SharedWidgetContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWidgetContainerView.swift; sourceTree = ""; }; 630826002AF29538002ACE0D /* SharedWidgetEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWidgetEntry.swift; sourceTree = ""; }; 630826092AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+BookPlayer.swift"; sourceTree = ""; }; + FEEDBABE0000000000000001 /* SharedWidgetStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWidgetStore.swift; sourceTree = ""; }; + FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWidgetStoreTests.swift; sourceTree = ""; }; 6308260C2AF6C312002ACE0D /* WidgetReloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetReloadService.swift; sourceTree = ""; }; 6308260F2AF6C9B0002ACE0D /* SharedIconWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedIconWidget.swift; sourceTree = ""; }; 630826132AF6CA81002ACE0D /* SharedIconWidgetEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedIconWidgetEntry.swift; sourceTree = ""; }; @@ -2221,6 +2226,7 @@ 41C3CF7D20AEA5E5007C3EF4 /* DataManagerTests.swift */, 9F8A9A5D27AC3F8C0093AA1C /* PlayableItemTests.swift */, 9FB7C48C2835A2C1003B917E /* PlayerManagerTests.swift */, + FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */, 6279361D272D0CF50097837D /* Coordinators */, 418B6D141D2707F800F974FB /* Info.plist */, ); @@ -2568,6 +2574,7 @@ 9FC1E4612814F68F00522FA8 /* Account */, 9FD8FE4C286566FF00EB2C3D /* Sync */, 64957AF0989CEBEEEC40E4BB /* Preferences */, + FEEDBABE0000000000000001 /* SharedWidgetStore.swift */, ); path = Services; sourceTree = ""; @@ -4344,6 +4351,7 @@ 41C233FE272E1958006BC7B8 /* SimpleLibraryItem.swift in Sources */, 416AAC3523F515B0005AD04F /* String+BookPlayer.swift in Sources */, 6308260B2AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift in Sources */, + FEEDBABE0000000000000002 /* SharedWidgetStore.swift in Sources */, 63398E3D2D68254000A6934E /* SimplePlaybackRecord.swift in Sources */, 99F3C2042F5FC76D00AC91A2 /* MigrationPlan.swift in Sources */, 41A8942B2652A7DF0032E972 /* Bundle+BookPlayer.swift in Sources */, @@ -4765,6 +4773,7 @@ 8AAA4F4C2CF1FF8C009D0777 /* MockCoordinatorPresentationFlow.swift in Sources */, 41A1B13F226FEF9300EA0400 /* DataManagerTests.swift in Sources */, 9FB7C48D2835A2C1003B917E /* PlayerManagerTests.swift in Sources */, + FEEDBABE0000000000000005 /* SharedWidgetStoreTests.swift in Sources */, 8AF18A3B2CF0E92F00238F8D /* KeychainServiceMock.swift in Sources */, 9F7C47532A2C15D300F9A500 /* AutoMockable.generated.swift in Sources */, 62AAE231274ABB5D001EB9FF /* LibraryServiceTests.swift in Sources */, @@ -4841,6 +4850,7 @@ 9FBF33EC29F6293C00501639 /* SimpleBookmark.swift in Sources */, 9F9C7B5329F9672700E257B0 /* SyncableBookmark.swift in Sources */, 6308260A2AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift in Sources */, + FEEDBABE0000000000000003 /* SharedWidgetStore.swift in Sources */, 63398E3E2D68254000A6934E /* SimplePlaybackRecord.swift in Sources */, 4140EA1F22723CFC0009F794 /* CommandParser.swift in Sources */, 6356F9CE2AC8A1CE00B7A027 /* DatabaseInitializer.swift in Sources */, diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index 54634b69e..4c4c0d59b 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -63,8 +63,6 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { private var playTask: Task<(), Error>? private var playerItem: AVPlayerItem? private var loadChapterTask: Task<(), Never>? - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() @Published var currentItem: PlayableItem? @Published var currentSpeed: Float = 1.0 @@ -362,21 +360,7 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { } func storeWidgetItem(_ item: PlayableItem) { - var widgetItems: [PlayableItem] = [item] - - if let itemsData = UserDefaults.sharedDefaults.data(forKey: Constants.UserDefaults.sharedWidgetLastPlayedItems), - let items = try? decoder.decode([PlayableItem].self, from: itemsData) - { - widgetItems.append(contentsOf: items.filter({ $0.relativePath != item.relativePath })) - widgetItems = Array(widgetItems.prefix(10)) - } - - guard let data = try? encoder.encode(widgetItems) else { - return - } - - UserDefaults.sharedDefaults.set(data, forKey: Constants.UserDefaults.sharedWidgetLastPlayedItems) - widgetReloadService.reloadWidget(.lastPlayedWidget) + SharedWidgetStore.store(item) } func loadChapterMetadata(_ chapter: PlayableChapter, autoplay: Bool? = nil, forceRefreshURL: Bool = false) { diff --git a/BookPlayerTests/SharedWidgetStoreTests.swift b/BookPlayerTests/SharedWidgetStoreTests.swift new file mode 100644 index 000000000..dc088fd89 --- /dev/null +++ b/BookPlayerTests/SharedWidgetStoreTests.swift @@ -0,0 +1,186 @@ +// +// SharedWidgetStoreTests.swift +// BookPlayerTests +// +// Created by Gianni Carlo on 2/6/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +@testable import BookPlayerKit +import Foundation +import XCTest + +/// Covers `SharedWidgetStore` snapshot mutation: prune matching rules and `store` +/// dedupe/cap. The debounced widget reload is not asserted here — it calls the +/// non-mockable `WidgetCenter.shared` singleton on a deferred main-queue work item. +class SharedWidgetStoreTests: XCTestCase { + private let key = Constants.UserDefaults.sharedWidgetLastPlayedItems + + override func setUp() { + UserDefaults.sharedDefaults.removeObject(forKey: key) + } + + override func tearDown() { + UserDefaults.sharedDefaults.removeObject(forKey: key) + } + + // MARK: - Helpers + + private func makeItem(relativePath: String) -> PlayableItem { + let chapter = PlayableChapter( + title: relativePath, + author: "author", + start: 0, + duration: 100, + relativePath: relativePath, + remoteURL: nil, + index: 0 + ) + return PlayableItem( + title: relativePath, + author: "author", + chapters: [chapter], + currentTime: 0, + duration: 100, + relativePath: relativePath, + uuid: relativePath, + parentFolder: nil, + percentCompleted: 0, + lastPlayDate: nil, + isFinished: false, + isBoundBook: false + ) + } + + private func seedSnapshot(_ relativePaths: [String]) { + let items = relativePaths.map(makeItem(relativePath:)) + let data = try! JSONEncoder().encode(items) + UserDefaults.sharedDefaults.set(data, forKey: key) + } + + private func snapshotPaths() -> [String] { + guard + let data = UserDefaults.sharedDefaults.data(forKey: key), + let items = try? JSONDecoder().decode([PlayableItem].self, from: data) + else { return [] } + + return items.map(\.relativePath) + } + + /// `store`/`removeItems` mutate asynchronously on a serial queue, so flush before asserting. + private func prune(_ paths: [String]) { + SharedWidgetStore.removeItems(matching: paths) + SharedWidgetStore.waitForPendingMutations() + } + + private func store(_ relativePath: String) { + SharedWidgetStore.store(makeItem(relativePath: relativePath)) + SharedWidgetStore.waitForPendingMutations() + } + + // MARK: - removeItems + + func testRemoveExactBookMatch() { + seedSnapshot(["Book1", "Folder/Book2", "Folder/Book3", "Foobar"]) + + prune(["Book1"]) + + XCTAssertEqual(snapshotPaths(), ["Folder/Book2", "Folder/Book3", "Foobar"]) + } + + func testRemoveFolderPrunesDescendants() { + seedSnapshot(["Book1", "Folder/Book2", "Folder/Book3", "Foobar"]) + + prune(["Folder"]) + + // Folder's children removed; Book1 and the similarly-named Foobar are retained. + XCTAssertEqual(snapshotPaths(), ["Book1", "Foobar"]) + } + + func testRemoveDoesNotMatchSiblingPrefix() { + seedSnapshot(["Foo", "Foobar"]) + + // Deleting "Foo" must not prune "Foobar" (only exact match or "Foo/" descendants). + prune(["Foo"]) + + XCTAssertEqual(snapshotPaths(), ["Foobar"]) + } + + func testRemoveWithNoMatchLeavesSnapshotUnchanged() { + seedSnapshot(["Book1", "Book2"]) + + prune(["DoesNotExist"]) + + XCTAssertEqual(snapshotPaths(), ["Book1", "Book2"]) + } + + func testRemoveWithEmptyInputIsNoOp() { + seedSnapshot(["Book1"]) + + prune([]) + + XCTAssertEqual(snapshotPaths(), ["Book1"]) + } + + func testRemoveOnEmptySnapshotIsNoOp() { + prune(["Book1"]) + + XCTAssertEqual(snapshotPaths(), []) + } + + func testRemoveMultiplePaths() { + seedSnapshot(["Book1", "Book2", "Book3"]) + + prune(["Book1", "Book3"]) + + XCTAssertEqual(snapshotPaths(), ["Book2"]) + } + + // MARK: - store + + func testStorePrependsAndDedupes() { + seedSnapshot(["Book1", "Book2"]) + + store("Book2") + + // Book2 moves to the front; no duplicate entry. + XCTAssertEqual(snapshotPaths(), ["Book2", "Book1"]) + } + + func testStoreCapsAtTenItems() { + seedSnapshot((1...10).map { "Book\($0)" }) + + store("BookNew") + + let paths = snapshotPaths() + XCTAssertEqual(paths.count, 10) + XCTAssertEqual(paths.first, "BookNew") + XCTAssertFalse(paths.contains("Book10")) + } + + // MARK: - Concurrency + + /// Smoke test: hammer `store`/`removeItems` from many threads at once. The serial + /// mutation queue must keep the snapshot well-formed — decodable, within the cap, and + /// free of duplicate paths — with no torn read-modify-write. (Probabilistic, not a proof.) + func testConcurrentMutationsKeepSnapshotWellFormed() { + seedSnapshot((1...5).map { "Book\($0)" }) + + let group = DispatchGroup() + for index in 0..<100 { + DispatchQueue.global().async(group: group) { + if index.isMultiple(of: 2) { + SharedWidgetStore.store(self.makeItem(relativePath: "Extra\(index)")) + } else { + SharedWidgetStore.removeItems(matching: ["Book\((index % 5) + 1)"]) + } + } + } + group.wait() + SharedWidgetStore.waitForPendingMutations() + + let paths = snapshotPaths() + XCTAssertLessThanOrEqual(paths.count, 10, "store must keep the snapshot within the cap") + XCTAssertEqual(Set(paths).count, paths.count, "no duplicate relativePaths") + } +} diff --git a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift index 7e41819c7..b26ba4944 100644 --- a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift +++ b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift @@ -58,8 +58,6 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { private var playTask: Task<(), Error>? private var playerItem: AVPlayerItem? private var loadChapterTask: Task<(), Never>? - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() @Published var currentItem: PlayableItem? @Published var currentSpeed: Float = 1.0 @Published private(set) var currentPlaybackTime: TimeInterval = 0 @@ -339,21 +337,7 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { } func storeWidgetItem(_ item: PlayableItem) { - var widgetItems: [PlayableItem] = [item] - - if let itemsData = UserDefaults.sharedDefaults.data(forKey: Constants.UserDefaults.sharedWidgetLastPlayedItems), - let items = try? decoder.decode([PlayableItem].self, from: itemsData) - { - widgetItems.append(contentsOf: items.filter({ $0.relativePath != item.relativePath })) - widgetItems = Array(widgetItems.prefix(10)) - } - - guard let data = try? encoder.encode(widgetItems) else { - return - } - - UserDefaults.sharedDefaults.set(data, forKey: Constants.UserDefaults.sharedWidgetLastPlayedItems) - widgetReloadService.reloadWidget(.lastPlayedWidget) + SharedWidgetStore.store(item) } func loadChapterMetadata(_ chapter: PlayableChapter, autoplay: Bool? = nil, forceRefreshURL: Bool = false) { diff --git a/Shared/Services/Account/AccountAPI.swift b/Shared/Services/Account/AccountAPI.swift index 6e663f6c7..30beefa7d 100644 --- a/Shared/Services/Account/AccountAPI.swift +++ b/Shared/Services/Account/AccountAPI.swift @@ -62,6 +62,15 @@ extension AccountAPI: Endpoint { struct LoginResponse: Decodable { let email: String let token: String + /// The account's canonical RevenueCat id (its `external_id`), resolved server-side. + /// Optional to stay decodable against older API responses that omit it. + let revenuecatId: String? + + enum CodingKeys: String, CodingKey { + case email + case token + case revenuecatId = "revenuecat_id" + } } struct DeleteResponse: Decodable { diff --git a/Shared/Services/Account/AccountService.swift b/Shared/Services/Account/AccountService.swift index 7fecec39c..f49d7d1f4 100644 --- a/Shared/Services/Account/AccountService.swift +++ b/Shared/Services/Account/AccountService.swift @@ -378,15 +378,20 @@ public final class AccountService: AccountServiceProtocol { try self.keychain.set(response.token, key: .token) - let (customerInfo, _) = try await Purchases.shared.logIn(userId) - UserDefaults.sharedDefaults.set(userId, forKey: "rcUserId") + // Identify to RevenueCat with the server's canonical id (the account's + // external_id) as the single source of truth, so a user signing in with + // Apple lands on the same RevenueCat user as their other credentials. + // Fall back to the Apple credential id only if an older response omits it. + let rcUserId = response.revenuecatId ?? userId + let (customerInfo, _) = try await Purchases.shared.logIn(rcUserId) + UserDefaults.sharedDefaults.set(rcUserId, forKey: "rcUserId") if let existingAccount = self.getAccount() { // Preserve donation made flag from stored account let donationMade = existingAccount.donationMade || !customerInfo.nonSubscriptions.isEmpty self.updateAccount( - id: userId, + id: rcUserId, email: response.email, donationMade: donationMade, hasSubscription: !customerInfo.activeSubscriptions.isEmpty diff --git a/Shared/Services/LibraryService.swift b/Shared/Services/LibraryService.swift index 0499570e7..35642b5dd 100644 --- a/Shared/Services/LibraryService.swift +++ b/Shared/Services/LibraryService.swift @@ -1137,6 +1137,11 @@ extension LibraryService { sortContents(at: relativePath, by: sort) } } + + /// The moved items' stored relativePaths (and any folder descendants, via the + /// path-prefix rule) are now stale, so prune them from the Last Played widget + /// snapshot. They reappear once played again at the new location. + SharedWidgetStore.removeItems(matching: items.map(\.relativePath)) } func rebuildOrderRank(in folderRelativePath: String?) { @@ -1195,6 +1200,12 @@ extension LibraryService { /// Clean up artwork cache ArtworkService.removeCache(for: item.relativePath) } + + /// Prune deleted items (and their descendants) from the Last Played widget snapshot. + /// Covers both the local delete path and the remote/sync delete path, which both + /// funnel through here. Deep folder deletes recurse into this method, so this runs + /// once per level, but the debounced reload coalesces them into a single refresh. + SharedWidgetStore.removeItems(matching: items.map(\.relativePath)) } func deleteItem(_ item: SimpleLibraryItem) throws { @@ -2108,6 +2119,10 @@ extension LibraryService { self.dataManager.saveContext() + /// The old folder path and all its descendants are now stale in the Last Played + /// widget snapshot; prune them via the path-prefix rule using the old path. + SharedWidgetStore.removeItems(matching: [relativePath]) + return newRelativePath } diff --git a/Shared/Services/SharedWidgetStore.swift b/Shared/Services/SharedWidgetStore.swift new file mode 100644 index 000000000..8f1ec29a3 --- /dev/null +++ b/Shared/Services/SharedWidgetStore.swift @@ -0,0 +1,119 @@ +// +// SharedWidgetStore.swift +// BookPlayer +// +// Created by Gianni Carlo on 2/6/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import WidgetKit + +/// Owns the read-modify-write of the "Last Played" widget snapshot stored in the +/// App Group shared `UserDefaults`. Centralizing it here keeps the write path +/// (on playback) and the prune path (on deletion) symmetric: same key, same coder, +/// same cap. Both paths reload the widget timeline after mutating the snapshot. +/// +/// The write path runs on the main thread (playback) while the prune path can run on +/// a background Core Data context (remote/sync delete), so every snapshot mutation is +/// serialized on `mutationQueue` to keep each `load`→modify→`persist` atomic — without +/// it, a concurrent prune and store could race and re-introduce a just-deleted item. +public final class SharedWidgetStore { + private init() {} + + private static let key = Constants.UserDefaults.sharedWidgetLastPlayedItems + /// Cap on retained recent items; matches the previous `storeWidgetItem` behavior. + private static let maxItems = 10 + /// Serializes all snapshot read-modify-write so concurrent `store`/`removeItems` + /// calls from different threads can't clobber each other's writes. + private static let mutationQueue = DispatchQueue(label: "com.bookplayer.sharedWidgetStore") + /// Coalescing window for Last Played widget reloads. Several mutations can land in + /// quick succession (deleting books one-by-one, or a folder delete that prunes + /// multiple entries), so we debounce into a single reload to stay within iOS's + /// widget-reload budget. Mirrors `WidgetReloadService.scheduleWidgetReload`. + private static let reloadDebounce: DispatchTimeInterval = .seconds(5) + private static var pendingReload: DispatchWorkItem? + + /// Prepend `item` to the snapshot, dedupe by `relativePath`, cap at 10, persist, reload. + /// Runs asynchronously on `mutationQueue` so the playback path never blocks. + public static func store(_ item: PlayableItem) { + mutationQueue.async { + var items = [item] + items.append(contentsOf: load().filter { $0.relativePath != item.relativePath }) + persist(Array(items.prefix(maxItems))) + reload() + } + } + + /// Remove any snapshot entry that was deleted: an entry is pruned when its + /// `relativePath` exactly matches one of `relativePaths`, or is a descendant of it + /// (prefix `path + "/"`). The descendant rule covers folder deletions (deep delete's + /// children and shallow delete's moved children both share the folder prefix), as + /// well as moved/renamed items whose old paths are now stale. + /// Runs asynchronously on `mutationQueue`; persists and reloads only when something + /// actually changed, so callers (including the background sync-delete path) never block. + public static func removeItems(matching relativePaths: [String]) { + guard !relativePaths.isEmpty else { return } + + mutationQueue.async { + let current = load() + guard !current.isEmpty else { return } + + /// Precompute once: O(1) exact-match lookups, and the descendant prefixes + /// allocated a single time instead of once per snapshot entry. + let deletedPaths = Set(relativePaths) + let descendantPrefixes = relativePaths.map { $0 + "/" } + + let pruned = current.filter { entry in + if deletedPaths.contains(entry.relativePath) { return false } + return !descendantPrefixes.contains { entry.relativePath.hasPrefix($0) } + } + + guard pruned.count != current.count else { return } + + persist(pruned) + reload() + } + } + + /// Must be called on `mutationQueue`. + private static func load() -> [PlayableItem] { + guard + let data = UserDefaults.sharedDefaults.data(forKey: key), + let items = try? JSONDecoder().decode([PlayableItem].self, from: data) + else { return [] } + + return items + } + + /// Must be called on `mutationQueue`. + private static func persist(_ items: [PlayableItem]) { + guard let data = try? JSONEncoder().encode(items) else { return } + + UserDefaults.sharedDefaults.set(data, forKey: key) + } + + /// Debounced reload of the Last Played widget. `SharedWidgetStore` is the only + /// reloader of this widget kind, so a self-contained debouncer fully coalesces its + /// reloads. All bookkeeping runs on the main queue, so the shared work item is safe + /// to touch from the background sync-delete path. + private static func reload() { + DispatchQueue.main.async { + pendingReload?.cancel() + + let workItem = DispatchWorkItem { + pendingReload = nil + WidgetCenter.shared.reloadTimelines(ofKind: Constants.Widgets.lastPlayedWidget.rawValue) + } + + pendingReload = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + reloadDebounce, execute: workItem) + } + } + + /// Test support: blocks until all queued mutations have completed. `internal`, so it is + /// only reachable via `@testable import`, never from app/widget code. + static func waitForPendingMutations() { + mutationQueue.sync {} + } +} From 1f893c8a5f20f614f5b128456b0bf97bcda6c29f Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Fri, 5 Jun 2026 19:30:57 -0500 Subject: [PATCH 06/17] Add support for total remaining time in chapter context (#1547) * Add support for total remaining time in chapter context * address feedback * address feedback 2 --- BookPlayer/Base.lproj/Localizable.strings | 2 + .../Player/ViewModels/PlayerViewModel.swift | 38 +++++- .../ProgressLabelsSectionView.swift | 110 ++++++++++++++++++ BookPlayer/ar.lproj/Localizable.strings | 2 + BookPlayer/ca.lproj/Localizable.strings | 2 + BookPlayer/cs.lproj/Localizable.strings | 2 + BookPlayer/da.lproj/Localizable.strings | 2 + BookPlayer/de.lproj/Localizable.strings | 2 + BookPlayer/el.lproj/Localizable.strings | 2 + BookPlayer/en.lproj/Localizable.strings | 2 + BookPlayer/es.lproj/Localizable.strings | 2 + BookPlayer/fi.lproj/Localizable.strings | 2 + BookPlayer/fr.lproj/Localizable.strings | 2 + BookPlayer/hu.lproj/Localizable.strings | 2 + BookPlayer/it.lproj/Localizable.strings | 2 + BookPlayer/ja.lproj/Localizable.strings | 2 + BookPlayer/nb.lproj/Localizable.strings | 2 + BookPlayer/nl.lproj/Localizable.strings | 2 + BookPlayer/pl.lproj/Localizable.strings | 2 + BookPlayer/pt-BR.lproj/Localizable.strings | 2 + BookPlayer/pt-PT.lproj/Localizable.strings | 2 + BookPlayer/ro.lproj/Localizable.strings | 2 + BookPlayer/ru.lproj/Localizable.strings | 2 + BookPlayer/sk-SK.lproj/Localizable.strings | 2 + BookPlayer/sv.lproj/Localizable.strings | 2 + BookPlayer/tr.lproj/Localizable.strings | 2 + BookPlayer/uk.lproj/Localizable.strings | 2 + BookPlayer/zh-Hans.lproj/Localizable.strings | 2 + Shared/CommandParser.swift | 28 +++++ Shared/Constants.swift | 1 + 30 files changed, 224 insertions(+), 5 deletions(-) diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index b290adf74..93c322e4b 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pause playback"; "sleep_chapter_option_title" = "End of current chapter"; "player_chapter_description" = "Chapter %d of %d"; +"player_book_remaining_title" = "%@ left"; "chapters_title" = "Chapters"; "chapters_item_description" = "Start: %@ - Duration: %@"; "restore_title" = "Restore"; @@ -218,6 +219,7 @@ "settings_playerinterface_list_title" = "Opens"; "settings_remainingtime_title" = "Use Remaining Time"; "settings_chaptercontext_title" = "Use Chapter Context"; +"settings_bookremaining_title" = "Show Total Remaining Time"; "settings_progresslabels_description" = "Toggle between displaying the remaining time, total duration and progress of either the chapter or the book in the player screen"; "settings_playerinterface_list_description" = "Adjust what the list button in the player screen opens"; "settings_autoplay_section_title" = "AUTOPLAY"; diff --git a/BookPlayer/Player/ViewModels/PlayerViewModel.swift b/BookPlayer/Player/ViewModels/PlayerViewModel.swift index 67db179b3..5408acc89 100644 --- a/BookPlayer/Player/ViewModels/PlayerViewModel.swift +++ b/BookPlayer/Player/ViewModels/PlayerViewModel.swift @@ -49,6 +49,7 @@ final class PlayerViewModel: ObservableObject { private let sharedDefaults: UserDefaults private var prefersChapterContext: Bool private var prefersRemainingTime: Bool + private var prefersBookRemaining: Bool private var disposeBag = Set() private var playingProgressSubscriber: AnyCancellable? private var listeningProgressSubscriber: AnyCancellable? @@ -112,6 +113,7 @@ final class PlayerViewModel: ObservableObject { let sharedDefaults = UserDefaults.sharedDefaults self.prefersChapterContext = sharedDefaults.bool(forKey: Constants.UserDefaults.chapterContextEnabled) self.prefersRemainingTime = sharedDefaults.bool(forKey: Constants.UserDefaults.remainingTimeEnabled) + self.prefersBookRemaining = sharedDefaults.bool(forKey: Constants.UserDefaults.bookRemainingTimeEnabled) self.sharedDefaults = sharedDefaults self.playbackSpeed = playerManager.currentSpeed } @@ -138,6 +140,7 @@ final class PlayerViewModel: ObservableObject { Task { @MainActor in self.prefersChapterContext = UserDefaults.sharedDefaults.bool(forKey: Constants.UserDefaults.chapterContextEnabled) self.prefersRemainingTime = UserDefaults.sharedDefaults.bool(forKey: Constants.UserDefaults.remainingTimeEnabled) + self.prefersBookRemaining = UserDefaults.sharedDefaults.bool(forKey: Constants.UserDefaults.bookRemainingTimeEnabled) self.recalculateProgress() } @@ -289,6 +292,19 @@ final class PlayerViewModel: ObservableObject { ) ?? 0 } + /// Audible-style "Xh Ym left" label for the total time remaining in the book, + /// scaled by the current playback speed. `currentTime` is the absolute book + /// position (live playback position, or the dragged position while scrubbing). + func bookRemainingLabel(currentTime: TimeInterval, duration: TimeInterval) -> String { + let speed = Double(self.playerManager.currentSpeed) + let remaining = (duration - currentTime) / (speed > 0 ? speed : 1) + + return String.localizedStringWithFormat( + "player_book_remaining_title".localized, + TimeParser.formatRemaining(remaining) + ) + } + func processToggleMaxTime() { self.prefersRemainingTime = !self.prefersRemainingTime sharedDefaults.set(self.prefersRemainingTime, forKey: Constants.UserDefaults.remainingTimeEnabled) @@ -318,6 +334,13 @@ final class PlayerViewModel: ObservableObject { progressData.maxTime = (elapsed - chapter.duration) / Double(playerManager.currentSpeed) } + if prefersBookRemaining { + progressData.progress = bookRemainingLabel( + currentTime: absoluteTime, + duration: currentItem.duration + ) + } + hasPreviousChapter = hasChapter(before: chapter) hasNextChapter = hasChapter(after: chapter) } else { @@ -375,11 +398,16 @@ final class PlayerViewModel: ObservableObject { let currentItem = currentItem, let currentChapter = currentItem.currentChapter { - progress = String.localizedStringWithFormat( - "player_chapter_description".localized, - currentChapter.index, - currentItem.chapters.count - ) + progress = self.prefersBookRemaining + ? self.bookRemainingLabel( + currentTime: currentItem.currentTime, + duration: currentItem.duration + ) + : String.localizedStringWithFormat( + "player_chapter_description".localized, + currentChapter.index, + currentItem.chapters.count + ) sliderValue = Float((currentItem.currentTime - currentChapter.start) / currentChapter.duration) } else { progress = "\(Int(round((currentItem?.progressPercentage ?? 0) * 100)))%" diff --git a/BookPlayer/Settings/Sections/PlayerControls/ProgressLabelsSectionView.swift b/BookPlayer/Settings/Sections/PlayerControls/ProgressLabelsSectionView.swift index ab753265e..7d06bba94 100644 --- a/BookPlayer/Settings/Sections/PlayerControls/ProgressLabelsSectionView.swift +++ b/BookPlayer/Settings/Sections/PlayerControls/ProgressLabelsSectionView.swift @@ -12,6 +12,7 @@ import SwiftUI struct ProgressLabelsSectionView: View { @AppStorage(Constants.UserDefaults.remainingTimeEnabled, store: UserDefaults.sharedDefaults) var prefersRemainingTime: Bool = true @AppStorage(Constants.UserDefaults.chapterContextEnabled, store: UserDefaults.sharedDefaults) var prefersChapterContext: Bool = true + @AppStorage(Constants.UserDefaults.bookRemainingTimeEnabled, store: UserDefaults.sharedDefaults) var prefersBookRemaining: Bool = false @EnvironmentObject var theme: ThemeViewModel @@ -31,10 +32,28 @@ struct ProgressLabelsSectionView: View { .onChange(of: prefersChapterContext) { handleValueUpdated() } + Toggle(isOn: $prefersBookRemaining) { + Text("settings_bookremaining_title") + .bpFont(.body) + } + .onChange(of: prefersBookRemaining) { + handleValueUpdated() + } + .disabled(!prefersChapterContext) } header: { Text("settings_progresslabels_title") .bpFont(.subheadline) .foregroundStyle(theme.secondaryColor) + } + .listSectionSpacing(Spacing.S1) + + ThemedSection { + ProgressLabelsPreview( + prefersChapterContext: prefersChapterContext, + prefersRemainingTime: prefersRemainingTime, + prefersBookRemaining: prefersBookRemaining + ) + .listRowBackground(Color.clear) } footer: { Text("settings_progresslabels_description") .bpFont(.caption) @@ -48,6 +67,97 @@ struct ProgressLabelsSectionView: View { } } +/// Non-interactive illustration of the player's progress labels, reflecting the +/// current toggle selections. Uses a fixed sample book so users can see how the +/// labels behave before opening the player. +private struct ProgressLabelsPreview: View { + let prefersChapterContext: Bool + let prefersRemainingTime: Bool + let prefersBookRemaining: Bool + + @EnvironmentObject var theme: ThemeViewModel + + // Sample scenario: 46:00 into chapter 3 of 18 of an 8h book. + private let bookDuration: TimeInterval = 8 * 3600 + private let currentTime: TimeInterval = 2760 + private let chapterStart: TimeInterval = 2400 + private let chapterDuration: TimeInterval = 1080 + private let chapterIndex = 3 + private let chapterCount = 18 + + private var sliderValue: Double { + prefersChapterContext + ? (currentTime - chapterStart) / chapterDuration + : currentTime / bookDuration + } + + private var currentTimeText: String { + TimeParser.formatTime( + prefersChapterContext ? currentTime - chapterStart : currentTime + ) + } + + private var progressText: String { + guard prefersChapterContext else { + return "\(Int(round(currentTime / bookDuration * 100)))%" + } + + if prefersBookRemaining { + return String.localizedStringWithFormat( + "player_book_remaining_title".localized, + TimeParser.formatRemaining(bookDuration - currentTime) + ) + } + + return String.localizedStringWithFormat( + "player_chapter_description".localized, + chapterIndex, + chapterCount + ) + } + + private var maxTimeText: String { + let value: TimeInterval + if prefersChapterContext { + value = prefersRemainingTime ? (currentTime - chapterStart) - chapterDuration : chapterDuration + } else { + value = prefersRemainingTime ? currentTime - bookDuration : bookDuration + } + + let formatted = TimeParser.formatTime(abs(value)) + return value < 0 ? "-".appending(formatted) : formatted + } + + var body: some View { + VStack(spacing: 8) { + ProgressView(value: sliderValue) + .tint(theme.linkColor) + + HStack { + Text(currentTimeText) + .bpFont(.miniPlayerTitle).monospacedDigit() + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Text(progressText) + .lineLimit(1) + .bpFont(.miniPlayerTitle) + .layoutPriority(1) + + Spacer() + + Text(maxTimeText) + .bpFont(.miniPlayerTitle).monospacedDigit() + .frame(maxWidth: .infinity, alignment: .trailing) + } + .foregroundColor(.secondary) + } + .padding(.vertical, 8) + .accessibilityHidden(true) + } +} + #Preview { Form { ProgressLabelsSectionView() diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 8e74a3bbd..9fd4c3119 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "إيقاف التشغيل مؤقتًا"; "sleep_chapter_option_title" = "نهاية الفصل الحالي"; "player_chapter_description" = "الفصل %d من %d"; +"player_book_remaining_title" = "متبقّي %@"; "chapters_title" = "الفصول"; "chapters_item_description" = "البداية: %@ - المدة: %@"; "restore_title" = "استعادة"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "استخدم الوقت المتبقي"; "settings_chaptercontext_title" = "استخدم سياق الفصل"; "settings_progresslabels_description" = "قم بالتبديل بين عرض الوقت المتبقي والمدة الإجمالية والتقدم في الفصل أو الكتاب في شاشة المشغل"; +"settings_bookremaining_title" = "إظهار إجمالي الوقت المتبقّي"; "settings_playerinterface_list_description" = "اضبط ما يفتحه زر القائمة في شاشة المشغل"; "settings_autoplay_section_title" = "التشغيل التلقائي"; "settings_autoplay_restart_title" = "أعد تشغيل الكتب المنتهية"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 85db2072e..a5d2bdea5 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Posa en pausa la reproducció"; "sleep_chapter_option_title" = "Final del capítol actual"; "player_chapter_description" = "Capítol %d de %d"; +"player_book_remaining_title" = "%@ restant"; "chapters_title" = "Capítols"; "chapters_item_description" = "Inici: %@ - Durada: %@"; "restore_title" = "Restaura"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Utilitzeu el temps restant"; "settings_chaptercontext_title" = "Utilitzeu el context del capítol"; "settings_progresslabels_description" = "Canvia entre mostrar el temps restant, la durada total i el progrés del capítol o del llibre a la pantalla del reproductor."; +"settings_bookremaining_title" = "Mostra el temps total restant"; "settings_playerinterface_list_description" = "Ajusteu el que obre el botó de llista a la pantalla del reproductor"; "settings_autoplay_section_title" = "REPRODUCCIÓ AUTOMÀTICA"; "settings_autoplay_restart_title" = "Reinicieu els llibres acabats"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index 6d3f5ef34..cbbf78c58 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pozastavit přehrávání"; "sleep_chapter_option_title" = "Konec aktuální kapitoly"; "player_chapter_description" = "Kapitola %d z %d"; +"player_book_remaining_title" = "Zbývá %@"; "chapters_title" = "Kapitoly"; "chapters_item_description" = "Začátek: %@ - Doba trvání: %@"; "restore_title" = "Obnovit"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Použijte zbývající čas"; "settings_chaptercontext_title" = "Použijte kontext kapitoly"; "settings_progresslabels_description" = "Přepínání mezi zobrazením zbývajícího času, celkové doby trvání a průběhu kapitoly nebo knihy na obrazovce přehrávače"; +"settings_bookremaining_title" = "Zobrazit celkový zbývající čas"; "settings_playerinterface_list_description" = "Upravte, co otevře tlačítko seznamu na obrazovce přehrávače"; "settings_autoplay_section_title" = "AUTOMATICKÉ PŘEHRÁVÁNÍ"; "settings_autoplay_restart_title" = "Restartujte hotové knihy"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index fcb435576..302a4fd74 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Sæt afspilningen på pause"; "sleep_chapter_option_title" = "Slutningen af nuværende kapitel"; "player_chapter_description" = "Kapitel %d af %d"; +"player_book_remaining_title" = "%@ tilbage"; "chapters_title" = "Kapitler"; "chapters_item_description" = "Start: %@ - Varighed: %@"; "restore_title" = "Gendan"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Brug den resterende tid"; "settings_chaptercontext_title" = "Brug kapitelkontekst"; "settings_progresslabels_description" = "Skift mellem at vise den resterende tid, den samlede varighed og fremskridt for enten kapitlet eller bogen på afspillerskærmen"; +"settings_bookremaining_title" = "Vis samlet resterende tid"; "settings_playerinterface_list_description" = "Juster, hvad listeknappen på afspillerskærmen åbner"; "settings_autoplay_section_title" = "AUTOMATISK AFSPILNING"; "settings_autoplay_restart_title" = "Genstart færdige bøger"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index d7586ee20..ac4c71f3a 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Wiedergabe anhalten"; "sleep_chapter_option_title" = "Am Ende des aktuellen Kapitels"; "player_chapter_description" = "Kapitel %d von %d"; +"player_book_remaining_title" = "%@ übrig"; "chapters_title" = "Kapitel"; "chapters_item_description" = "Beginn: %@ – Dauer: %@"; "restore_title" = "Wiederherstellen"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Verbleibende Zeit verwenden"; "settings_chaptercontext_title" = "Kapitelkontext verwenden"; "settings_progresslabels_description" = "Zwischen der Anzeige der verbleibenden Zeit, der Gesamtdauer und des Kapitel- oder Buchfortschritts auf dem Wiedergabebildschirm umschalten."; +"settings_bookremaining_title" = "Gesamte verbleibende Zeit anzeigen"; "settings_playerinterface_list_description" = "Anpassen, was die Taste „Liste“ auf dem Wiedergabebildschirm öffnet"; "settings_autoplay_section_title" = "AUTOMATISCHES ABSPIELEN"; "settings_autoplay_restart_title" = "Beendete Bücher neu starten"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 07569f1a1..4bbd2a956 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Παύση αναπαραγωγής"; "sleep_chapter_option_title" = "Τέλος τρέχοντος κεφαλαίου"; "player_chapter_description" = "Κεφάλαιο %d από %d"; +"player_book_remaining_title" = "Απομένουν %@"; "chapters_title" = "Κεφάλαια"; "chapters_item_description" = "Έναρξη: %@ - Διάρκεια: %@"; "restore_title" = "Επαναφέρω"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Χρησιμοποιήστε τον υπολειπόμενο χρόνο"; "settings_chaptercontext_title" = "Χρησιμοποιήστε το πλαίσιο του κεφαλαίου"; "settings_progresslabels_description" = "Εναλλαγή μεταξύ της εμφάνισης του υπολειπόμενου χρόνου, της συνολικής διάρκειας και της προόδου είτε του κεφαλαίου είτε του βιβλίου στην οθόνη του προγράμματος αναπαραγωγής"; +"settings_bookremaining_title" = "Εμφάνιση συνολικού χρόνου που απομένει"; "settings_playerinterface_list_description" = "Προσαρμόστε τι ανοίγει το κουμπί λίστας στην οθόνη του προγράμματος αναπαραγωγής"; "settings_autoplay_section_title" = "ΑΥΤΟΜΑΤΗ ΑΝΑΠΑΡΑΓΩΓΗ"; "settings_autoplay_restart_title" = "Επανεκκινήστε τα ολοκληρωμένα βιβλία"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index adb65e9c1..1653a4ebc 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pause playback"; "sleep_chapter_option_title" = "End of current chapter"; "player_chapter_description" = "Chapter %d of %d"; +"player_book_remaining_title" = "%@ left"; "chapters_title" = "Chapters"; "chapters_item_description" = "Start: %@ - Duration: %@"; "restore_title" = "Restore"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Use Remaining Time"; "settings_chaptercontext_title" = "Use Chapter Context"; "settings_progresslabels_description" = "Toggle between displaying the remaining time, total duration and progress of either the chapter or the book in the player screen"; +"settings_bookremaining_title" = "Show Total Remaining Time"; "settings_playerinterface_list_description" = "Adjust what the list button in the player screen opens"; "settings_autoplay_section_title" = "AUTOPLAY"; "settings_autoplay_restart_title" = "Restart finished books"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 8b905e90c..ec6a2ba21 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pausar audio"; "sleep_chapter_option_title" = "Fin del capítulo actual"; "player_chapter_description" = "Capítulo %d de %d"; +"player_book_remaining_title" = "%@ restante"; "chapters_title" = "Capítulos"; "chapters_item_description" = "Inicio: %@ - Duración: %@"; "restore_title" = "Restaurar"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Usar el tiempo restante"; "settings_chaptercontext_title" = "Usar el contexto del capítulo"; "settings_progresslabels_description" = "Alterna entre mostrar el tiempo restante, la duración total y el progreso del capítulo o del libro en la pantalla del reproductor"; +"settings_bookremaining_title" = "Mostrar el tiempo total restante"; "settings_playerinterface_list_description" = "Ajusta lo que el botón de lista abre en la pantalla del reproductor"; "settings_autoplay_section_title" = "AUTO-REPRODUCCIÓN"; "settings_autoplay_restart_title" = "Reiniciar libros terminados"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index 942d8813a..e1cb9547f 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Keskeytä toisto"; "sleep_chapter_option_title" = "Nykyisen luvun loppu"; "player_chapter_description" = "Luku %d (Lukuja %d)"; +"player_book_remaining_title" = "%@ jäljellä"; "chapters_title" = "Kappaleet"; "chapters_item_description" = "Aloitus: %@ - Kesto: %@"; "restore_title" = "Palauta"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Käytä jäljellä oleva aika"; "settings_chaptercontext_title" = "Käytä luvun kontekstia"; "settings_progresslabels_description" = "Vaihda jäljellä olevan ajan, kokonaiskeston ja joko luvun tai kirjan edistymisen näyttämisen välillä soittimen näytössä"; +"settings_bookremaining_title" = "Näytä jäljellä oleva kokonaisaika"; "settings_playerinterface_list_description" = "Säädä, mitä luettelopainike avautuu soittimen näytössä"; "settings_autoplay_section_title" = "AUTOMAATTINEN TOISTO"; "settings_autoplay_restart_title" = "Käynnistä valmiit kirjat uudelleen"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 1f072964a..d3f180383 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Mettre la lecture en pause"; "sleep_chapter_option_title" = "Fin du chapitre en cours"; "player_chapter_description" = "Chapitre %d sur %d"; +"player_book_remaining_title" = "%@ restant"; "chapters_title" = "Chapitres"; "chapters_item_description" = "Début : %@ - Durée : %@"; "restore_title" = "Restaurer"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Utiliser le temps restant"; "settings_chaptercontext_title" = "Utiliser le contexte du chapitre"; "settings_progresslabels_description" = "Basculer entre l'affichage du temps restant, de la durée totale et de la progression du chapitre ou du livre sur l'écran du lecteur"; +"settings_bookremaining_title" = "Afficher le temps total restant"; "settings_playerinterface_list_description" = "Ajustez ce que le bouton de liste dans l'écran du lecteur ouvre"; "settings_autoplay_section_title" = "LECTURE AUTOMATIQUE"; "settings_autoplay_restart_title" = "Redémarrez les livres terminés"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 1b0aad49d..70f957b31 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Lejátszás megállítása"; "sleep_chapter_option_title" = "Az aktuális fejezet végén"; "player_chapter_description" = "%d/%d fejezet"; +"player_book_remaining_title" = "%@ van hátra"; "chapters_title" = "Fejezetek"; "chapters_item_description" = "Kezdés: %@ - Hossz: %@"; "restore_title" = "Visszaállítás"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Hátralévő idő használata"; "settings_chaptercontext_title" = "Fejezet kontextus használata"; "settings_progresslabels_description" = "Váltás a hátralévő idő, a teljes időtartam és a fejezet vagy a teljes könyv előrehaladásának megjelenítése között a lejátszó képernyőn"; +"settings_bookremaining_title" = "Teljes hátralévő idő megjelenítése"; "settings_playerinterface_list_description" = "A lejátszó képernyőn mi nyíljon meg a lista gomb megnyomására"; "settings_autoplay_section_title" = "AUTOMATIKUS LEJÁTSZÁS"; "settings_autoplay_restart_title" = "Már befejezettek újrahallgatása"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index e5fbc6ce8..638eaeb02 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pausa la riproduzione"; "sleep_chapter_option_title" = "Fine del capitolo corrente"; "player_chapter_description" = "Capitolo %d di %d"; +"player_book_remaining_title" = "%@ rimanenti"; "chapters_title" = "Capitoli"; "chapters_item_description" = "Inizio: %@ - Durata: %@"; "restore_title" = "Ripristina"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Usa il tempo rimanente"; "settings_chaptercontext_title" = "Usa il contesto del capitolo"; "settings_progresslabels_description" = "Alterna tra la visualizzazione del tempo rimanente, la durata totale e l'avanzamento del capitolo o del libro nella schermata del lettore"; +"settings_bookremaining_title" = "Mostra il tempo totale rimanente"; "settings_playerinterface_list_description" = "Regola ciò che si apre il pulsante elenco nella schermata del lettore"; "settings_autoplay_section_title" = "RIPRODUZIONE AUTOMATICA"; "settings_autoplay_restart_title" = "Riavvia i libri finiti"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index 4f2047fe5..a8cf18cea 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "再生を一時停止"; "sleep_chapter_option_title" = "このチャプタの終わり"; "player_chapter_description" = "チャプタ %d/%d"; +"player_book_remaining_title" = "残り%@"; "chapters_title" = "チャプタ"; "chapters_item_description" = "開始: %@ - 長さ: %@"; "restore_title" = "復元"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "残り時間を使用"; "settings_chaptercontext_title" = "チャプタ情報の使用"; "settings_progresslabels_description" = "プレーヤー画面で、チャプタまたはブックの残り時間、合計再生時間、進行状況を表示するかどうかを切り替えます。"; +"settings_bookremaining_title" = "残り合計時間を表示"; "settings_playerinterface_list_description" = "プレーヤー画面のリストボタンから開く項目を選択します。"; "settings_autoplay_section_title" = "自動再生"; "settings_autoplay_restart_title" = "再生済みのブックを最初から再生"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index 6e3a2a863..d9d796e90 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pause avspilling"; "sleep_chapter_option_title" = "Slutt på gjeldende kapittel"; "player_chapter_description" = "Kapittel %d av %d"; +"player_book_remaining_title" = "%@ igjen"; "chapters_title" = "Kapitler"; "chapters_item_description" = "Start: %@ - Varighet: %@"; "restore_title" = "Gjenopprett"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Bruk gjenværende tid"; "settings_chaptercontext_title" = "Bruk kappittelkontekst"; "settings_progresslabels_description" = "Veksle mellom å vise gjenværende tid, total varighet og fremdrift for enten kapittelet eller hele boken på spillerskjermen"; +"settings_bookremaining_title" = "Vis total gjenværende tid"; "settings_playerinterface_list_description" = "Juster hva \"liste\" knappen på spillerskjermen åpner"; "settings_autoplay_section_title" = "AUTOPLAY"; "settings_autoplay_restart_title" = "Les fullførte bøker på nytt"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index 0fb607ef4..0911877eb 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Afspelen pauzeren"; "sleep_chapter_option_title" = "Einde van het huidige hoofdstuk"; "player_chapter_description" = "Hoofdstuk %d van %d"; +"player_book_remaining_title" = "Nog %@"; "chapters_title" = "Hoofdstukken"; "chapters_item_description" = "Start: %@ - Duur: %@"; "restore_title" = "Herstellen"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Resterende tijd gebruiken"; "settings_chaptercontext_title" = "Hoofdstukcontext gebruiken"; "settings_progresslabels_description" = "Schakel tussen weergave van de resterende tijd, totale duur en voortgang van het hoofdstuk of het boek in het spelerscherm"; +"settings_bookremaining_title" = "Totale resterende tijd tonen"; "settings_playerinterface_list_description" = "Pas aan wat de lijstknop in het spelerscherm opent"; "settings_autoplay_section_title" = "AUTOMATISCH AFSPELEN"; "settings_autoplay_restart_title" = "Herstart voltooide boeken"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index fcb4eb468..73d49b8f6 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Wstrzymaj odtwarzanie"; "sleep_chapter_option_title" = "Koniec bieżącego rozdziału"; "player_chapter_description" = "Rozdział %d z %d"; +"player_book_remaining_title" = "Pozostało %@"; "chapters_title" = "Rozdziały"; "chapters_item_description" = "Start: %@ - Czas trwania: %@"; "restore_title" = "Przywróć"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Użyj Pozostały czas"; "settings_chaptercontext_title" = "Użyj Kontekstu rozdziału"; "settings_progresslabels_description" = "Przełącz między wyświetlaniem pozostałego czasu, całkowitego czasu trwania i postępu rozdziału lub książki na ekranie odtwarzacza"; +"settings_bookremaining_title" = "Pokaż całkowity pozostały czas"; "settings_playerinterface_list_description" = "Dostosuj, co otwiera przycisk listy na ekranie odtwarzacza"; "settings_autoplay_section_title" = "AUTOMATYCZNE ODTWARZANIE"; "settings_autoplay_restart_title" = "Uruchom ponownie ukończone książki"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index 28e938875..475447c13 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pausar reprodução"; "sleep_chapter_option_title" = "Fim do capítulo atual"; "player_chapter_description" = "Capítulo %d de %d"; +"player_book_remaining_title" = "%@ restante"; "chapters_title" = "Capítulos"; "chapters_item_description" = "Início: %@ - Duração: %@"; "restore_title" = "Restaurar"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Usar Tempo Restante"; "settings_chaptercontext_title" = "Usar Contexto do Capítulo"; "settings_progresslabels_description" = "Alterne entre a exibição do tempo restante, duração total e progresso do capítulo ou do livro na tela de reprodução"; +"settings_bookremaining_title" = "Mostrar tempo total restante"; "settings_playerinterface_list_description" = "Ajuste o que o botão de lista na tela de reprodução abre"; "settings_autoplay_section_title" = "REPRODUÇÃO AUTOMÁTICA"; "settings_autoplay_restart_title" = "Reiniciar livros concluídos"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index dcfee280a..eadabc2a4 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pausar reprodução"; "sleep_chapter_option_title" = "Fim do capítulo actual"; "player_chapter_description" = "Capítulo %d de %d"; +"player_book_remaining_title" = "%@ restante"; "chapters_title" = "Capítulos"; "chapters_item_description" = "Início: %@ - Duração: %@"; "restore_title" = "Restaurar"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Utilizar Tempo Restante"; "settings_chaptercontext_title" = "Utilizar Contexto do Capítulo"; "settings_progresslabels_description" = "Alternar entre a exibição do tempo restante, duração total e o progresso do capítulo ou do livro no ecrã de reprodução"; +"settings_bookremaining_title" = "Mostrar o tempo total restante"; "settings_playerinterface_list_description" = "Ajustar o que abre o botão de lista no ecrã de reprodução"; "settings_autoplay_section_title" = "REPRODUÇÃO AUTOMÁTICA"; "settings_autoplay_restart_title" = "Reiniciar livros concluídos"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index 4732aeb21..bedceb0dc 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Întrerupeți redarea"; "sleep_chapter_option_title" = "Sfârșitul capitolului curent"; "player_chapter_description" = "Capitolul %d din %d"; +"player_book_remaining_title" = "%@ rămase"; "chapters_title" = "Capitole"; "chapters_item_description" = "Start: %@ - Durata: %@"; "restore_title" = "Restabilire"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Utilizați timpul rămas"; "settings_chaptercontext_title" = "Utilizați contextul capitolului"; "settings_progresslabels_description" = "Comutați între afișarea timpului rămas, a duratei totale și a progresului fie al capitolului, fie al cărții în ecranul playerului"; +"settings_bookremaining_title" = "Afișează timpul total rămas"; "settings_playerinterface_list_description" = "Ajustați ce se deschide butonul de listă din ecranul playerului"; "settings_autoplay_section_title" = "REDARE AUTOMATA"; "settings_autoplay_restart_title" = "Reporniți cărțile terminate"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index c06fc21bd..6b2039835 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Остановить воспроизведение"; "sleep_chapter_option_title" = "До конца текущей главы"; "player_chapter_description" = "Глава %d из %d"; +"player_book_remaining_title" = "Осталось %@"; "chapters_title" = "Главы"; "chapters_item_description" = "Начало: %@ - Длительность: %@"; "restore_title" = "Восстановить покупки"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Использовать оставшееся время"; "settings_chaptercontext_title" = "Использовать контекст главы"; "settings_progresslabels_description" = "Переключение между отображением оставшегося времени, общей продолжительности и хода выполнения главы или книги на экране проигрывателя."; +"settings_bookremaining_title" = "Показывать общее оставшееся время"; "settings_playerinterface_list_description" = "Отрегулируйте, что открывает кнопка списка на экране плеера"; "settings_autoplay_section_title" = "АВТОВОСПРОИЗВЕДЕНИЕ"; "settings_autoplay_restart_title" = "Перезапустить готовые книги"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index d38cea317..fc8644187 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pozastavenie prehrávania"; "sleep_chapter_option_title" = "Na konci aktuálnej kapitoly"; "player_chapter_description" = "Kapitola %d z %d"; +"player_book_remaining_title" = "Zostáva %@"; "chapters_title" = "Kapitoly"; "chapters_item_description" = "Začiatok: %@ - Trvanie: %@"; "restore_title" = "Obnoviť"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Zostávajúci čas"; "settings_chaptercontext_title" = "Kontext kapitoly"; "settings_progresslabels_description" = "Prepínanie medzi zobrazením zostávajúceho času, celkového trvania a priebehu spracovania kapitoly alebo knihy na obrazovke prehrávača"; +"settings_bookremaining_title" = "Zobraziť celkový zostávajúci čas"; "settings_playerinterface_list_description" = "Úprava správania sa tlačidla zoznamu na obrazovke prehrávača"; "settings_autoplay_section_title" = "AUTOMATICKÉ PREHRÁVANIE"; "settings_autoplay_restart_title" = "Reštartuje hotové knihy"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index 47c56897f..e4a17da4d 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Pausa uppspelningen"; "sleep_chapter_option_title" = "Vid slutet av detta kapitel"; "player_chapter_description" = "Kapitel %d av %d"; +"player_book_remaining_title" = "%@ kvar"; "chapters_title" = "Kapitel"; "chapters_item_description" = "Start: %@ - Längd: %@"; "restore_title" = "Återställ"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Använd Återstående Tid"; "settings_chaptercontext_title" = "Använd Kapitelkontext"; "settings_progresslabels_description" = "Välj mellan att visa återstående tid, total längd och spelläge relaterat till antingen kapitlet eller boken i spelaren"; +"settings_bookremaining_title" = "Visa total återstående tid"; "settings_playerinterface_list_description" = "Välj vad listknappen på spelarkontrollen öppnar"; "settings_autoplay_section_title" = "AUTOSPELA"; "settings_autoplay_restart_title" = "Starta om färdiga böcker"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index da1ac6be7..85b4f5224 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Oynatmayı duraklat"; "sleep_chapter_option_title" = "Geçerli bölüm sonunda"; "player_chapter_description" = "Bölüm %d / %d"; +"player_book_remaining_title" = "%@ kaldı"; "chapters_title" = "Bölümler"; "chapters_item_description" = "Başlangıç: %@ - Süre: %@"; "restore_title" = "Geri Yükle"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Kalan Süreyi Kullan"; "settings_chaptercontext_title" = "Bölüm İçeriğini Kullan"; "settings_progresslabels_description" = "Oynatıcı ekranında bölümün veya kitabın kalan süresini, toplam süresini ve ilerlemesini görüntüleme arasında geçiş yapın"; +"settings_bookremaining_title" = "Toplam kalan süreyi göster"; "settings_playerinterface_list_description" = "Oynatıcı ekranındaki liste düğmesinin ne açacağını ayarlayın"; "settings_autoplay_section_title" = "OTOMATİK OYNATMA"; "settings_autoplay_restart_title" = "Biten kitapları yeniden başlat"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 451748433..23ea7ad77 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "Призупинити відтворення"; "sleep_chapter_option_title" = "Кінець розділу"; "player_chapter_description" = "Розділ %d з %d"; +"player_book_remaining_title" = "Залишилось %@"; "chapters_title" = "Розділи"; "chapters_item_description" = "Початок: %@ - Тривалість: %@"; "restore_title" = "Відновити"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "Використовуйте час, що залишився"; "settings_chaptercontext_title" = "Використовуйте контекст розділу"; "settings_progresslabels_description" = "Перемикання між відображенням часу, що залишився, загальної тривалості та прогресу розділу або книги на екрані програвача"; +"settings_bookremaining_title" = "Показувати загальний залишковий час"; "settings_playerinterface_list_description" = "Оберіть, що відкриватиме кнопка переліку на екрані програвача"; "settings_autoplay_section_title" = "АВТОМАТИЧНЕ ВІДТВОРЕННЯ"; "settings_autoplay_restart_title" = "Перезапускати закінчені книги"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index dbb97db69..5c2a0eabc 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -99,6 +99,7 @@ "player_sleep_title" = "暂停播放"; "sleep_chapter_option_title" = "当前章节的结尾"; "player_chapter_description" = "第%d章,共%d章"; +"player_book_remaining_title" = "剩余 %@"; "chapters_title" = "章节"; "chapters_item_description" = "开始: %@ - 时长: %@"; "restore_title" = "恢复"; @@ -217,6 +218,7 @@ "settings_remainingtime_title" = "使用剩余时间"; "settings_chaptercontext_title" = "使用章节上下文"; "settings_progresslabels_description" = "在播放器屏幕中显示章节或书籍的剩余时间、总持续时间和进度之间切换"; +"settings_bookremaining_title" = "显示剩余总时长"; "settings_playerinterface_list_description" = "调整播放器屏幕中的列表按钮打开的内容"; "settings_autoplay_section_title" = "自动播放"; "settings_autoplay_restart_title" = "重新启动完成的书籍"; diff --git a/Shared/CommandParser.swift b/Shared/CommandParser.swift index c9f422ad7..8148a32e7 100644 --- a/Shared/CommandParser.swift +++ b/Shared/CommandParser.swift @@ -281,6 +281,34 @@ public class TimeParser { return durationFormatter.string(from: duration)! } + /// Formats a remaining time interval into an abbreviated, tiered string. + /// - `>= 1 hour` -> "Xh Ym" + /// - `>= 1 minute` -> "Xm Ys" + /// - `< 1 minute` -> "Xs" + public class func formatRemaining(_ time: TimeInterval) -> String { + // Guard against non-finite input (e.g. a malformed asset duration), which + // would otherwise make DateComponentsFormatter return nil and render an + // empty "%@ left" string. + let remaining = time.isFinite ? max(0, time) : 0 + let allowedUnits: NSCalendar.Unit + + if remaining >= 3600 { + allowedUnits = [.hour, .minute] + } else if remaining >= 60 { + allowedUnits = [.minute, .second] + } else { + allowedUnits = [.second] + } + + let durationFormatter = DateComponentsFormatter() + durationFormatter.unitsStyle = .abbreviated + durationFormatter.allowsFractionalUnits = false + durationFormatter.collapsesLargestUnit = false + durationFormatter.allowedUnits = allowedUnits + + return durationFormatter.string(from: remaining) ?? "" + } + public class func formatTotalDuration( _ duration: TimeInterval, allowedUnits: NSCalendar.Unit = [.hour, .minute, .second] diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 611a8f17d..661be37e2 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -23,6 +23,7 @@ public enum Constants { public static let systemThemeVariantEnabled = "userSettingsSystemThemeVariant" public static let chapterContextEnabled = "userSettingsChapterContext" public static let remainingTimeEnabled = "userSettingsRemainingTime" + public static let bookRemainingTimeEnabled = "userSettingsBookRemainingTime" public static let smartRewindEnabled = "userSettingsSmartRewind" public static let smartRewindMaxInterval = "userSettingsSmartRewindMaxInterval" public static let boostVolumeEnabled = "userSettingsBoostVolume" From 714054b680d6e6507c405ad45e3bd5a74a4c2798 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sat, 6 Jun 2026 07:05:12 -0500 Subject: [PATCH 07/17] Add fallback to extract chapters when not natively supported --- Shared/Services/AudioMetadataService.swift | 394 ++++++++++++++++++++- 1 file changed, 393 insertions(+), 1 deletion(-) diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index 4a386e6fa..dc8d2ffd6 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -156,7 +156,17 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return await extractStandardChapters(from: asset, locales: availableChapterLocales) } - // Second try: Check what metadata identifiers exist + // Second try: Malformed QuickTime/MP4 chapter tracks that AVFoundation refuses + // to expose. Older tools (e.g. "MarkAble") create a valid `chap`-referenced text + // chapter track but tag it with an external `alis` data reference and/or an invalid + // media language. AVFoundation then reports no `availableChapterLocales`, even though + // the chapter samples are physically present in the file. Parse them directly. + if let url = (asset as? AVURLAsset)?.url, + let textChapters = extractQuickTimeTextChapters(from: url, duration: duration) { + return textChapters + } + + // Third try: Check what metadata identifiers exist let identifiers = metadata.compactMap { $0.identifier?.rawValue } // FLAC/Vorbis chapters (CHAPTER tags) @@ -385,4 +395,386 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return finalChapters.isEmpty ? nil : finalChapters } + + // MARK: - QuickTime / MP4 text chapter track fallback + + /// Parse chapters from a QuickTime-style text chapter track by reading the MP4 box + /// structure directly. Used when AVFoundation declines to expose chapters that are + /// nonetheless present (malformed data reference or invalid track language). + /// - Returns: The parsed chapters, or `nil` if the file has no readable text chapter track. + private func extractQuickTimeTextChapters(from url: URL, duration: TimeInterval) -> [ChapterMetadata]? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + guard + let fileSize = try? handle.seekToEnd(), + let moov = readTopLevelBox(named: "moov", handle: handle, fileSize: fileSize) + else { return nil } + + // Gather every track, then resolve the one referenced as the chapter track. + let traks = childBoxes(moov, 0, moov.count).filter { $0.type == "trak" } + guard !traks.isEmpty else { return nil } + + var trackByID: [UInt32: (start: Int, end: Int)] = [:] + var chapterTrackID: UInt32? + + for trak in traks { + guard let trackID = trackID(of: moov, trak.start, trak.end) else { continue } + trackByID[trackID] = (trak.start, trak.end) + + // A track's `tref` of type `chap` lists the IDs of its chapter tracks. + if chapterTrackID == nil, + let tref = firstChild(moov, trak.start, trak.end, "tref"), + let chap = firstChild(moov, tref.start, tref.end, "chap"), + tref.end >= chap.start + 4 { + chapterTrackID = beUInt32(moov, chap.start) + } + } + + guard + let chapterID = chapterTrackID, + let chapterTrak = trackByID[chapterID] + else { return nil } + + return parseTextChapters( + moov: moov, + trakStart: chapterTrak.start, + trakEnd: chapterTrak.end, + handle: handle, + totalDuration: duration + ) + } + + /// Parse the sample table of a text chapter track and read each chapter's title and timing. + private func parseTextChapters( + moov: [UInt8], + trakStart: Int, + trakEnd: Int, + handle: FileHandle, + totalDuration: TimeInterval + ) -> [ChapterMetadata]? { + guard + let mdhd = descend(moov, trakStart, trakEnd, ["mdia", "mdhd"]), + let stbl = descend(moov, trakStart, trakEnd, ["mdia", "minf", "stbl"]) + else { return nil } + + // mdhd timescale: version (0/1) changes the field offset. + let mdhdVersion = moov[mdhd.start] + let timescaleOffset = mdhd.start + (mdhdVersion == 1 ? 20 : 12) + guard timescaleOffset + 4 <= mdhd.end else { return nil } + let timescale = beUInt32(moov, timescaleOffset) + guard timescale > 0 else { return nil } + + guard + let sampleDeltas = parseSTTS(moov, stbl), + let sampleSizes = parseSTSZ(moov, stbl), + let chunkOffsets = parseChunkOffsets(moov, stbl), + let sampleToChunk = parseSTSC(moov, stbl) + else { return nil } + + let locations = sampleLocations( + sizes: sampleSizes, + chunkOffsets: chunkOffsets, + sampleToChunk: sampleToChunk + ) + guard !locations.isEmpty else { return nil } + + // Sample start times are the cumulative sum of per-sample durations (stts), in + // the track's timescale. Chapter durations are derived from consecutive starts so + // that empty/degenerate samples don't produce negative or zero-length spans. + var starts: [TimeInterval] = [] + var cumulative: UInt64 = 0 + for index in locations.indices { + starts.append(TimeInterval(cumulative) / TimeInterval(timescale)) + if index < sampleDeltas.count { + cumulative += UInt64(sampleDeltas[index]) + } + } + + var chapters: [ChapterMetadata] = [] + for (index, location) in locations.enumerated() { + guard + location.size >= 2, + let sampleData = readBytes(handle: handle, offset: location.offset, length: location.size), + sampleData.count >= 2 + else { continue } + + let titleLength = Int(beUInt16([UInt8](sampleData), 0)) + let titleBytes = sampleData.dropFirst(2).prefix(titleLength) + let title = decodeText(Array(titleBytes)) + + let start = starts[index] + let nextStart = index < starts.count - 1 ? starts[index + 1] : totalDuration + let chapterDuration = max(0, nextStart - start) + + chapters.append( + ChapterMetadata( + title: title, + start: start, + duration: chapterDuration, + index: index + 1 + ) + ) + } + + return chapters.isEmpty ? nil : chapters + } + + // MARK: - Sample table parsing + + /// Per-sample durations expanded from the run-length encoded `stts` box. + private func parseSTTS(_ data: [UInt8], _ stbl: (start: Int, end: Int)) -> [UInt32]? { + guard let box = firstChild(data, stbl.start, stbl.end, "stts"), box.start + 8 <= box.end else { return nil } + let entryCount = Int(beUInt32(data, box.start + 4)) + var deltas: [UInt32] = [] + var cursor = box.start + 8 + for _ in 0..= 0, sampleCount < 1_000_000 else { return nil } + deltas.append(contentsOf: repeatElement(sampleDelta, count: sampleCount)) + cursor += 8 + } + return deltas + } + + /// Per-sample byte sizes from the `stsz` box (handles the shared-size form). + private func parseSTSZ(_ data: [UInt8], _ stbl: (start: Int, end: Int)) -> [Int]? { + guard let box = firstChild(data, stbl.start, stbl.end, "stsz"), box.start + 12 <= box.end else { return nil } + let uniformSize = beUInt32(data, box.start + 4) + let sampleCount = Int(beUInt32(data, box.start + 8)) + guard sampleCount >= 0, sampleCount < 1_000_000 else { return nil } + + if uniformSize != 0 { + return Array(repeating: Int(uniformSize), count: sampleCount) + } + + var sizes: [Int] = [] + var cursor = box.start + 12 + for _ in 0.. [UInt64]? { + if let box = firstChild(data, stbl.start, stbl.end, "stco"), box.start + 8 <= box.end { + let count = Int(beUInt32(data, box.start + 4)) + var offsets: [UInt64] = [] + var cursor = box.start + 8 + for _ in 0.. [(firstChunk: Int, samplesPerChunk: Int)]? { + guard let box = firstChild(data, stbl.start, stbl.end, "stsc"), box.start + 8 <= box.end else { return nil } + let entryCount = Int(beUInt32(data, box.start + 4)) + var entries: [(Int, Int)] = [] + var cursor = box.start + 8 + for _ in 0.. [(offset: UInt64, size: Int)] { + var locations: [(UInt64, Int)] = [] + var sampleIndex = 0 + + for chunkIndex in 0.. [UInt8]? { + var offset: UInt64 = 0 + while offset + 8 <= fileSize { + try? handle.seek(toOffset: offset) + guard + let headerData = try? handle.read(upToCount: 16), + headerData.count >= 8 + else { return nil } + + let header = [UInt8](headerData) + let size32 = beUInt32(header, 0) + var boxSize = UInt64(size32) + var headerSize: UInt64 = 8 + + if size32 == 1 { + guard header.count >= 16 else { return nil } + boxSize = beUInt64(header, 8) + headerSize = 16 + } else if size32 == 0 { + boxSize = fileSize - offset + } + + guard boxSize >= headerSize else { return nil } + + if typeString(header, 4) == name { + try? handle.seek(toOffset: offset + headerSize) + let payloadLength = Int(boxSize - headerSize) + guard + let payload = try? handle.read(upToCount: payloadLength), + payload.count == payloadLength + else { return nil } + return [UInt8](payload) + } + + offset += boxSize + } + return nil + } + + /// Immediate child boxes within the byte range `[start, end)`. + private func childBoxes(_ data: [UInt8], _ start: Int, _ end: Int) -> [(type: String, start: Int, end: Int)] { + var children: [(String, Int, Int)] = [] + var cursor = start + while cursor + 8 <= end { + let size32 = beUInt32(data, cursor) + var size = Int(size32) + var headerSize = 8 + if size32 == 1 { + guard cursor + 16 <= end else { break } + size = Int(beUInt64(data, cursor + 8)) + headerSize = 16 + } else if size32 == 0 { + size = end - cursor + } + guard size >= headerSize, cursor + size <= end else { break } + children.append((typeString(data, cursor + 4), cursor + headerSize, cursor + size)) + cursor += size + } + return children + } + + /// The first immediate child box of the given type within `[start, end)`. + private func firstChild(_ data: [UInt8], _ start: Int, _ end: Int, _ type: String) -> (start: Int, end: Int)? { + for child in childBoxes(data, start, end) where child.type == type { + return (child.start, child.end) + } + return nil + } + + /// Follow a chain of nested child box types, returning the innermost range. + private func descend(_ data: [UInt8], _ start: Int, _ end: Int, _ path: [String]) -> (start: Int, end: Int)? { + var current = (start: start, end: end) + for type in path { + guard let next = firstChild(data, current.start, current.end, type) else { return nil } + current = next + } + return current + } + + /// Read a track's ID from its `tkhd` box (version 0 and 1 layouts differ). + private func trackID(of data: [UInt8], _ trakStart: Int, _ trakEnd: Int) -> UInt32? { + guard let tkhd = firstChild(data, trakStart, trakEnd, "tkhd") else { return nil } + let version = data[tkhd.start] + let trackIDOffset = tkhd.start + (version == 1 ? 20 : 12) + guard trackIDOffset + 4 <= tkhd.end else { return nil } + return beUInt32(data, trackIDOffset) + } + + // MARK: - Primitive readers + + /// Decode QuickTime text sample bytes, trying UTF-16 (when a BOM is present), then + /// UTF-8, then Mac Roman (the legacy default for text tracks). + private func decodeText(_ bytes: [UInt8]) -> String { + guard !bytes.isEmpty else { return "" } + + if bytes.count >= 2, (bytes[0] == 0xFE && bytes[1] == 0xFF) || (bytes[0] == 0xFF && bytes[1] == 0xFE) { + if let utf16 = String(bytes: bytes, encoding: .utf16) { return utf16 } + } + if let utf8 = String(bytes: bytes, encoding: .utf8) { return utf8 } + if let macRoman = String(bytes: bytes, encoding: .macOSRoman) { return macRoman } + return String(bytes: bytes, encoding: .isoLatin1) ?? "" + } + + private func readBytes(handle: FileHandle, offset: UInt64, length: Int) -> Data? { + guard length > 0, (try? handle.seek(toOffset: offset)) != nil else { return nil } + return try? handle.read(upToCount: length) + } + + private func beUInt16(_ data: [UInt8], _ offset: Int) -> UInt16 { + guard offset + 2 <= data.count else { return 0 } + return (UInt16(data[offset]) << 8) | UInt16(data[offset + 1]) + } + + private func beUInt32(_ data: [UInt8], _ offset: Int) -> UInt32 { + guard offset + 4 <= data.count else { return 0 } + return (UInt32(data[offset]) << 24) + | (UInt32(data[offset + 1]) << 16) + | (UInt32(data[offset + 2]) << 8) + | UInt32(data[offset + 3]) + } + + private func beUInt64(_ data: [UInt8], _ offset: Int) -> UInt64 { + guard offset + 8 <= data.count else { return 0 } + var value: UInt64 = 0 + for index in 0..<8 { + value = (value << 8) | UInt64(data[offset + index]) + } + return value + } + + private func typeString(_ data: [UInt8], _ offset: Int) -> String { + guard offset + 4 <= data.count else { return "" } + return String(bytes: data[offset.. Date: Sat, 6 Jun 2026 07:23:13 -0500 Subject: [PATCH 08/17] Fix mp3 chapters without CTOC tag --- Shared/Services/AudioMetadataService.swift | 126 +++++++++++++++++---- 1 file changed, 107 insertions(+), 19 deletions(-) diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index dc8d2ffd6..7e7fd0df9 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -179,9 +179,10 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return await extractOverdriveChapters(from: metadata, duration: duration) } - // MP3 standard chapters (ID3v2.3+ CHAP frames) - // Note: Currently AVFoundation doesn't fully expose CHAP frame data, - // but this may be supported in future iOS releases. + // MP3 standard chapters (ID3v2.3+ CHAP frames). + // AVFoundation only assembles ID3 chapters into `availableChapterLocales` when a + // `CTOC` (table of contents) frame is present. Without it, the `CHAP` frames are + // surfaced only as opaque data blobs, so we parse them ourselves. if identifiers.contains(where: { $0.hasPrefix("id3/CHAP") }) { return await extractID3Chapters(from: metadata, duration: duration) } @@ -293,21 +294,24 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } private func extractID3Chapters(from metadata: [AVMetadataItem], duration: TimeInterval) async -> [ChapterMetadata]? { - var chapterData: [(start: Double, title: String)] = [] + // AVFoundation surfaces each CHAP frame as an opaque data blob (its `numberValue` + // and `stringValue` are nil), so we parse the raw frame body ourselves. The CHAP + // frame layout (ID3v2.3/2.4) is: + // element ID : null-terminated string + // start time : UInt32 BE, milliseconds + // end time : UInt32 BE, milliseconds + // start offset: UInt32 BE, byte offset (0xFFFFFFFF when unused) + // end offset : UInt32 BE, byte offset (0xFFFFFFFF when unused) + // sub-frames : embedded ID3 frames (e.g. TIT2 for the chapter title) + var chapterData: [(start: Double, end: Double?, title: String)] = [] for item in metadata { guard let identifier = item.identifier?.rawValue, - identifier.hasPrefix("id3/CHAP") else { continue } - - // Extract chapter start time and title from CHAP frame. - // CHAP timestamps are relative offsets from the start of the audio (not absolute dates). - // Note: AVFoundation may not fully expose CHAP frame timing data currently, - // but we attempt to load what's available for future compatibility. - if let numberValue = try? await item.load(.numberValue), - numberValue.doubleValue >= 0 { - let startTime = numberValue.doubleValue - let title = (try? await item.load(.stringValue)) ?? "" - chapterData.append((start: startTime, title: title)) + identifier.hasPrefix("id3/CHAP"), + let data = try? await item.load(.dataValue) else { continue } + + if let parsed = parseID3ChapterFrame([UInt8](data)) { + chapterData.append(parsed) } } @@ -316,10 +320,12 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { var chapters: [ChapterMetadata] = [] for (index, data) in chapterData.enumerated() { + // Prefer the frame's own end time; otherwise derive from the next chapter or the + // total duration. Guard against malformed end times that precede the start. let chapterDuration: TimeInterval - - // Calculate duration - if index < chapterData.count - 1 { + if let end = data.end, end > data.start { + chapterDuration = end - data.start + } else if index < chapterData.count - 1 { chapterDuration = chapterData[index + 1].start - data.start } else { chapterDuration = duration - data.start @@ -328,7 +334,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { let chapter = ChapterMetadata( title: data.title, start: data.start, - duration: chapterDuration, + duration: max(0, chapterDuration), index: index + 1 ) @@ -338,6 +344,79 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return chapters.isEmpty ? nil : chapters } + /// Parse a single ID3 `CHAP` frame body into its start/end times (seconds) and title. + private func parseID3ChapterFrame(_ body: [UInt8]) -> (start: Double, end: Double?, title: String)? { + // Element ID is a null-terminated string. + guard let terminator = body.firstIndex(of: 0) else { return nil } + var cursor = terminator + 1 + + // Need 16 bytes for the four UInt32 timing/offset fields. + guard cursor + 16 <= body.count else { return nil } + let startMS = beUInt32(body, cursor) + let endMS = beUInt32(body, cursor + 4) + cursor += 16 + + let start = Double(startMS) / 1000.0 + // 0xFFFFFFFF / a non-increasing end signals "unset"; let the caller derive duration. + let end: Double? = (endMS == 0xFFFFFFFF || endMS <= startMS) ? nil : Double(endMS) / 1000.0 + + let title = parseID3ChapterTitle(body, from: cursor) + return (start: start, end: end, title: title) + } + + /// Walk the sub-frames of a CHAP frame and return the `TIT2` (title) text, if present. + private func parseID3ChapterTitle(_ body: [UInt8], from start: Int) -> String { + var cursor = start + + // Each sub-frame: 4-byte frame ID, 4-byte size, 2-byte flags, then the payload. + while cursor + 10 <= body.count { + let frameID = typeString(body, cursor) + let declaredSize = Int(beUInt32(body, cursor + 4)) + let payloadStart = cursor + 10 + + // ID3v2.4 uses synchsafe sizes; v2.3 uses plain UInt32. When the plain size + // overruns the buffer, fall back to the synchsafe interpretation. + var size = declaredSize + if payloadStart + size > body.count { + size = Int(synchsafeUInt32(body, cursor + 4)) + } + guard size > 0, payloadStart + size <= body.count else { break } + + if frameID == "TIT2" { + return decodeID3Text(Array(body[payloadStart.. String { + guard let encoding = payload.first else { return "" } + var bytes = Array(payload.dropFirst()) + // Strip a trailing null terminator (one byte for 8-bit encodings, two for UTF-16). + let trimmed: String + + switch encoding { + case 0: // ISO-8859-1 (Latin-1) + if bytes.last == 0 { bytes.removeLast() } + trimmed = String(bytes: bytes, encoding: .isoLatin1) ?? "" + case 1: // UTF-16 with BOM + if bytes.count >= 2, bytes[bytes.count - 1] == 0, bytes[bytes.count - 2] == 0 { bytes.removeLast(2) } + trimmed = String(bytes: bytes, encoding: .utf16) ?? "" + case 2: // UTF-16BE without BOM + if bytes.count >= 2, bytes[bytes.count - 1] == 0, bytes[bytes.count - 2] == 0 { bytes.removeLast(2) } + trimmed = String(bytes: bytes, encoding: .utf16BigEndian) ?? "" + default: // 3 = UTF-8 (and any unknown value, treated leniently) + if bytes.last == 0 { bytes.removeLast() } + trimmed = String(bytes: bytes, encoding: .utf8) ?? "" + } + + return trimmed + } + private func extractOverdriveChapters(from metadata: [AVMetadataItem], duration: TimeInterval) async -> [ChapterMetadata]? { guard let txxxItem = metadata.first(where: { $0.identifier?.rawValue == "id3/TXXX" }), let overdriveMetadata = try? await txxxItem.load(.stringValue) @@ -764,6 +843,15 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { | UInt32(data[offset + 3]) } + /// Read a 28-bit ID3v2 synchsafe integer (7 bits per byte, MSB always zero). + private func synchsafeUInt32(_ data: [UInt8], _ offset: Int) -> UInt32 { + guard offset + 4 <= data.count else { return 0 } + return (UInt32(data[offset] & 0x7F) << 21) + | (UInt32(data[offset + 1] & 0x7F) << 14) + | (UInt32(data[offset + 2] & 0x7F) << 7) + | UInt32(data[offset + 3] & 0x7F) + } + private func beUInt64(_ data: [UInt8], _ offset: Int) -> UInt64 { guard offset + 8 <= data.count else { return 0 } var value: UInt64 = 0 From 9811a484733a1a716b4aef741ecc6126f3dacaa5 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sat, 6 Jun 2026 14:58:56 -0500 Subject: [PATCH 09/17] address feedback --- Shared/Services/AudioMetadataService.swift | 182 +++++++++++++++------ 1 file changed, 131 insertions(+), 51 deletions(-) diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index 7e7fd0df9..b634a00e1 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -63,7 +63,27 @@ public protocol AudioMetadataServiceProtocol { } public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { - + + /// File extensions whose containers can hold a QuickTime/MP4 text chapter track. This is + /// an explicit list rather than a `UTType` conformance check on purpose: no system UTType + /// models "ISO-BMFF container" — `m4b` resolves to `com.apple.protected-mpeg-4-audio-b`, + /// which conforms to none of `.mpeg4Audio`/`.mpeg4Movie`/`.quickTimeMovie`, and the only + /// broad-enough type, `.audiovisualContent`, also matches `mp3`/`flac`/`wav` (defeating the + /// gate) while still missing the unregistered `aaxc` UTI. + private static let quickTimeFileExtensions: Set = ["m4b", "m4a", "mp4", "m4v", "mov", "aax", "aaxc"] + + /// Upper bound on a single chapter text sample we'll read from disk. Chapter titles are + /// tiny; this caps memory use if a malformed `stsz` table declares an enormous sample. + private static let maxChapterSampleSize = 64 * 1024 + + /// Upper bound on the total number of samples we'll expand from a chapter track's tables. + /// A 100-hour book at one chapter per minute is ~6,000 — this is generous headroom. + private static let maxChapterSampleCount = 100_000 + + /// Upper bound on the `moov` box we'll read into memory. Even chapter-rich audiobooks + /// keep `moov` well under a megabyte; this caps memory if a file declares a huge one. + private static let maxMoovSize = 256 * 1024 * 1024 + public init() {} public func extractMetadata(from fileURL: URL) async -> AudioMetadata? { @@ -151,9 +171,11 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { do { let availableChapterLocales = try await asset.load(.availableChapterLocales) - // First try: Native chapter support (works for M4B, some M4A, properly tagged files) - if !availableChapterLocales.isEmpty { - return await extractStandardChapters(from: asset, locales: availableChapterLocales) + // First try: Native chapter support (works for M4B, some M4A, properly tagged files). + // If locales exist but yield no usable chapters, fall through to the manual fallbacks. + if !availableChapterLocales.isEmpty, + let standardChapters = await extractStandardChapters(from: asset, locales: availableChapterLocales) { + return standardChapters } // Second try: Malformed QuickTime/MP4 chapter tracks that AVFoundation refuses @@ -161,9 +183,20 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { // chapter track but tag it with an external `alis` data reference and/or an invalid // media language. AVFoundation then reports no `availableChapterLocales`, even though // the chapter samples are physically present in the file. Parse them directly. + // Gated to MP4/QuickTime containers so non-MP4 files (e.g. MP3) skip the box scan. + // The parser does blocking FileHandle I/O, so run it off the cooperative thread pool. + // This runs during import while the user waits for the book to appear, so use + // `.userInitiated` — `.utility` would let the OS throttle the disk reads. if let url = (asset as? AVURLAsset)?.url, - let textChapters = extractQuickTimeTextChapters(from: url, duration: duration) { - return textChapters + Self.quickTimeFileExtensions.contains(url.pathExtension.lowercased()) { + let textChapters = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: self.extractQuickTimeTextChapters(from: url, duration: duration)) + } + } + if let textChapters { + return textChapters + } } // Third try: Check what metadata identifiers exist @@ -371,14 +404,26 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { // Each sub-frame: 4-byte frame ID, 4-byte size, 2-byte flags, then the payload. while cursor + 10 <= body.count { let frameID = typeString(body, cursor) - let declaredSize = Int(beUInt32(body, cursor + 4)) let payloadStart = cursor + 10 - // ID3v2.4 uses synchsafe sizes; v2.3 uses plain UInt32. When the plain size - // overruns the buffer, fall back to the synchsafe interpretation. - var size = declaredSize + // ID3v2.4 encodes frame sizes as synchsafe integers (every byte has bit 7 clear); + // v2.3 uses a plain UInt32. The CHAP blob doesn't carry the tag version, so we infer + // it: if all four size bytes have bit 7 clear the field is a valid synchsafe integer, + // which agrees with the plain value below 128 and is the correct reading for v2.4. + // Otherwise it can only be a plain v2.3 size. We fall back to the other interpretation + // if the chosen one overruns the buffer. + let plainSize = Int(beUInt32(body, cursor + 4)) + let synchsafeSize = Int(synchsafeUInt32(body, cursor + 4)) + let sizeBytesAreSynchsafe = (4...7).allSatisfy { (body[cursor + $0] & 0x80) == 0 } + var size = sizeBytesAreSynchsafe ? synchsafeSize : plainSize if payloadStart + size > body.count { - size = Int(synchsafeUInt32(body, cursor + 4)) + // The chosen interpretation overruns; the other one must be correct. + size = sizeBytesAreSynchsafe ? plainSize : synchsafeSize + } else if sizeBytesAreSynchsafe, plainSize != synchsafeSize, payloadStart + plainSize == body.count { + // Ambiguous: the size bytes are valid synchsafe but also a valid plain UInt32. When + // the plain size lands exactly on the frame boundary it's a v2.3 frame whose size + // bytes happen to all have bit 7 clear (e.g. a 256-byte title) — prefer it. + size = plainSize } guard size > 0, payloadStart + size <= body.count else { break } @@ -404,10 +449,10 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { if bytes.last == 0 { bytes.removeLast() } trimmed = String(bytes: bytes, encoding: .isoLatin1) ?? "" case 1: // UTF-16 with BOM - if bytes.count >= 2, bytes[bytes.count - 1] == 0, bytes[bytes.count - 2] == 0 { bytes.removeLast(2) } - trimmed = String(bytes: bytes, encoding: .utf16) ?? "" + if bytes.count >= 2, bytes.count % 2 == 0, bytes[bytes.count - 1] == 0, bytes[bytes.count - 2] == 0 { bytes.removeLast(2) } + trimmed = decodeUTF16WithBOM(bytes) case 2: // UTF-16BE without BOM - if bytes.count >= 2, bytes[bytes.count - 1] == 0, bytes[bytes.count - 2] == 0 { bytes.removeLast(2) } + if bytes.count >= 2, bytes.count % 2 == 0, bytes[bytes.count - 1] == 0, bytes[bytes.count - 2] == 0 { bytes.removeLast(2) } trimmed = String(bytes: bytes, encoding: .utf16BigEndian) ?? "" default: // 3 = UTF-8 (and any unknown value, treated leniently) if bytes.last == 0 { bytes.removeLast() } @@ -417,6 +462,23 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return trimmed } + /// Decode UTF-16 bytes, honoring a leading BOM. The ID3 spec requires a BOM for this + /// encoding, but some taggers omit it; we then default to little-endian (the common case + /// for Windows-authored tags) before falling back to big-endian. + private func decodeUTF16WithBOM(_ bytes: [UInt8]) -> String { + if bytes.count >= 2 { + if bytes[0] == 0xFF, bytes[1] == 0xFE { + return String(bytes: bytes.dropFirst(2), encoding: .utf16LittleEndian) ?? "" + } + if bytes[0] == 0xFE, bytes[1] == 0xFF { + return String(bytes: bytes.dropFirst(2), encoding: .utf16BigEndian) ?? "" + } + } + return String(bytes: bytes, encoding: .utf16LittleEndian) + ?? String(bytes: bytes, encoding: .utf16BigEndian) + ?? "" + } + private func extractOverdriveChapters(from metadata: [AVMetadataItem], duration: TimeInterval) async -> [ChapterMetadata]? { guard let txxxItem = metadata.first(where: { $0.identifier?.rawValue == "id3/TXXX" }), let overdriveMetadata = try? await txxxItem.load(.stringValue) @@ -505,7 +567,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { if chapterTrackID == nil, let tref = firstChild(moov, trak.start, trak.end, "tref"), let chap = firstChild(moov, tref.start, tref.end, "chap"), - tref.end >= chap.start + 4 { + chap.end >= chap.start + 4 { chapterTrackID = beUInt32(moov, chap.start) } } @@ -537,7 +599,9 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { let stbl = descend(moov, trakStart, trakEnd, ["mdia", "minf", "stbl"]) else { return nil } - // mdhd timescale: version (0/1) changes the field offset. + // mdhd timescale: version (0/1) changes the field offset. Guard the version-byte read + // against a zero-payload mdhd box (mdhd.start could equal moov.count). + guard mdhd.start < mdhd.end else { return nil } let mdhdVersion = moov[mdhd.start] let timescaleOffset = mdhd.start + (mdhdVersion == 1 ? 20 : 12) guard timescaleOffset + 4 <= mdhd.end else { return nil } @@ -556,24 +620,28 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { chunkOffsets: chunkOffsets, sampleToChunk: sampleToChunk ) - guard !locations.isEmpty else { return nil } + // A well-formed chapter track has one stts duration per sample. If the tables + // disagree (malformed), only trust as many samples as both tables describe so we + // don't emit trailing chapters that all share the same timestamp. + let sampleCount = min(locations.count, sampleDeltas.count) + guard sampleCount > 0 else { return nil } // Sample start times are the cumulative sum of per-sample durations (stts), in // the track's timescale. Chapter durations are derived from consecutive starts so // that empty/degenerate samples don't produce negative or zero-length spans. var starts: [TimeInterval] = [] var cumulative: UInt64 = 0 - for index in locations.indices { + for index in 0..= 2, + location.size <= Self.maxChapterSampleSize, let sampleData = readBytes(handle: handle, offset: location.offset, length: location.size), sampleData.count >= 2 else { continue } @@ -611,8 +679,9 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { guard cursor + 8 <= box.end else { break } let sampleCount = Int(beUInt32(data, cursor)) let sampleDelta = beUInt32(data, cursor + 4) - // Guard against pathological counts that would exhaust memory. - guard sampleCount >= 0, sampleCount < 1_000_000 else { return nil } + // Guard against pathological counts that would exhaust memory — bounded cumulatively, + // since many entries could otherwise expand past the overall limit. + guard deltas.count + sampleCount <= Self.maxChapterSampleCount else { return nil } deltas.append(contentsOf: repeatElement(sampleDelta, count: sampleCount)) cursor += 8 } @@ -624,7 +693,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { guard let box = firstChild(data, stbl.start, stbl.end, "stsz"), box.start + 12 <= box.end else { return nil } let uniformSize = beUInt32(data, box.start + 4) let sampleCount = Int(beUInt32(data, box.start + 8)) - guard sampleCount >= 0, sampleCount < 1_000_000 else { return nil } + guard sampleCount <= Self.maxChapterSampleCount else { return nil } if uniformSize != 0 { return Array(repeating: Int(uniformSize), count: sampleCount) @@ -642,37 +711,40 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { /// Chunk file offsets from `stco` (32-bit) or `co64` (64-bit). private func parseChunkOffsets(_ data: [UInt8], _ stbl: (start: Int, end: Int)) -> [UInt64]? { - if let box = firstChild(data, stbl.start, stbl.end, "stco"), box.start + 8 <= box.end { - let count = Int(beUInt32(data, box.start + 4)) - var offsets: [UInt64] = [] - var cursor = box.start + 8 - for _ in 0.. UInt64 + ) -> [UInt64]? { + guard box.start + 8 <= box.end else { return nil } + let count = Int(beUInt32(data, box.start + 4)) + guard count <= Self.maxChapterSampleCount else { return nil } + var offsets: [UInt64] = [] + var cursor = box.start + 8 + for _ in 0.. [(firstChunk: Int, samplesPerChunk: Int)]? { guard let box = firstChild(data, stbl.start, stbl.end, "stsc"), box.start + 8 <= box.end else { return nil } let entryCount = Int(beUInt32(data, box.start + 4)) + guard entryCount <= Self.maxChapterSampleCount else { return nil } var entries: [(Int, Int)] = [] var cursor = box.start + 8 for _ in 0..= headerSize else { return nil } + // A box must be at least its header and must fit within the remaining file. This + // also prevents both a UInt64->Int overflow trap below and an `offset` wraparound + // (an infinite loop) when a malformed file declares an absurd box size. + guard boxSize >= headerSize, boxSize <= fileSize - offset else { return nil } if typeString(header, 4) == name { - try? handle.seek(toOffset: offset + headerSize) let payloadLength = Int(boxSize - headerSize) + // Don't read an absurdly large box into memory. + guard payloadLength <= Self.maxMoovSize else { return nil } + try? handle.seek(toOffset: offset + headerSize) guard let payload = try? handle.read(upToCount: payloadLength), payload.count == payloadLength @@ -771,7 +848,10 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { var headerSize = 8 if size32 == 1 { guard cursor + 16 <= end else { break } - size = Int(beUInt64(data, cursor + 8)) + // Clamp to the enclosing range so an oversized 64-bit size can't trap on the + // UInt64->Int conversion; the bounds guard below then rejects it. + let extendedSize = beUInt64(data, cursor + 8) + size = extendedSize > UInt64(end - cursor) ? (end - cursor + 1) : Int(extendedSize) headerSize = 16 } else if size32 == 0 { size = end - cursor @@ -803,7 +883,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { /// Read a track's ID from its `tkhd` box (version 0 and 1 layouts differ). private func trackID(of data: [UInt8], _ trakStart: Int, _ trakEnd: Int) -> UInt32? { - guard let tkhd = firstChild(data, trakStart, trakEnd, "tkhd") else { return nil } + guard let tkhd = firstChild(data, trakStart, trakEnd, "tkhd"), tkhd.start < tkhd.end else { return nil } let version = data[tkhd.start] let trackIDOffset = tkhd.start + (version == 1 ? 20 : 12) guard trackIDOffset + 4 <= tkhd.end else { return nil } From 64c6c0747f9d5137eb131616633812a68ca4f743 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sat, 6 Jun 2026 15:30:21 -0500 Subject: [PATCH 10/17] Address feedback --- Shared/Services/AudioMetadataService.swift | 77 +++++++++++++--------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index b634a00e1..2c87bfeb4 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -80,9 +80,11 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { /// A 100-hour book at one chapter per minute is ~6,000 — this is generous headroom. private static let maxChapterSampleCount = 100_000 - /// Upper bound on the `moov` box we'll read into memory. Even chapter-rich audiobooks - /// keep `moov` well under a megabyte; this caps memory if a file declares a huge one. - private static let maxMoovSize = 256 * 1024 * 1024 + /// Upper bound on the `moov` box we'll read into memory. `moov` is dominated by the main + /// audio track's sample tables (stsz/stco), so even a very long audiobook stays in the tens + /// of MB; 64 MB is generous headroom while avoiding a transient allocation large enough to + /// risk memory pressure / jetsam on constrained devices. + private static let maxMoovSize = 64 * 1024 * 1024 public init() {} @@ -197,6 +199,9 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { if let textChapters { return textChapters } + // Unlike the other parsers, this path returns nil silently at many guards; log once + // so a "chapters missing" report on an MP4 file leaves a diagnostic trail. + Self.logger.debug("QuickTime text chapter parser found no chapters in \(url.lastPathComponent)") } // Third try: Check what metadata identifiers exist @@ -343,7 +348,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { identifier.hasPrefix("id3/CHAP"), let data = try? await item.load(.dataValue) else { continue } - if let parsed = parseID3ChapterFrame([UInt8](data)) { + if let parsed = parseID3ChapterFrame(data) { chapterData.append(parsed) } } @@ -378,7 +383,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Parse a single ID3 `CHAP` frame body into its start/end times (seconds) and title. - private func parseID3ChapterFrame(_ body: [UInt8]) -> (start: Double, end: Double?, title: String)? { + private func parseID3ChapterFrame(_ body: Data) -> (start: Double, end: Double?, title: String)? { // Element ID is a null-terminated string. guard let terminator = body.firstIndex(of: 0) else { return nil } var cursor = terminator + 1 @@ -398,7 +403,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Walk the sub-frames of a CHAP frame and return the `TIT2` (title) text, if present. - private func parseID3ChapterTitle(_ body: [UInt8], from start: Int) -> String { + private func parseID3ChapterTitle(_ body: Data, from start: Int) -> String { var cursor = start // Each sub-frame: 4-byte frame ID, 4-byte size, 2-byte flags, then the payload. @@ -588,7 +593,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { /// Parse the sample table of a text chapter track and read each chapter's title and timing. private func parseTextChapters( - moov: [UInt8], + moov: Data, trakStart: Int, trakEnd: Int, handle: FileHandle, @@ -646,7 +651,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { sampleData.count >= 2 else { continue } - let titleLength = Int(beUInt16([UInt8](sampleData), 0)) + let titleLength = Int(beUInt16(sampleData, 0)) let titleBytes = sampleData.dropFirst(2).prefix(titleLength) let title = decodeText(Array(titleBytes)) @@ -670,7 +675,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { // MARK: - Sample table parsing /// Per-sample durations expanded from the run-length encoded `stts` box. - private func parseSTTS(_ data: [UInt8], _ stbl: (start: Int, end: Int)) -> [UInt32]? { + private func parseSTTS(_ data: Data, _ stbl: (start: Int, end: Int)) -> [UInt32]? { guard let box = firstChild(data, stbl.start, stbl.end, "stts"), box.start + 8 <= box.end else { return nil } let entryCount = Int(beUInt32(data, box.start + 4)) var deltas: [UInt32] = [] @@ -689,7 +694,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Per-sample byte sizes from the `stsz` box (handles the shared-size form). - private func parseSTSZ(_ data: [UInt8], _ stbl: (start: Int, end: Int)) -> [Int]? { + private func parseSTSZ(_ data: Data, _ stbl: (start: Int, end: Int)) -> [Int]? { guard let box = firstChild(data, stbl.start, stbl.end, "stsz"), box.start + 12 <= box.end else { return nil } let uniformSize = beUInt32(data, box.start + 4) let sampleCount = Int(beUInt32(data, box.start + 8)) @@ -710,7 +715,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Chunk file offsets from `stco` (32-bit) or `co64` (64-bit). - private func parseChunkOffsets(_ data: [UInt8], _ stbl: (start: Int, end: Int)) -> [UInt64]? { + private func parseChunkOffsets(_ data: Data, _ stbl: (start: Int, end: Int)) -> [UInt64]? { if let box = firstChild(data, stbl.start, stbl.end, "stco") { return chunkOffsets(data, box, entrySize: 4) { UInt64(beUInt32(data, $0)) } } @@ -722,7 +727,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { /// Shared `stco`/`co64` reader: walk `entrySize`-byte chunk offsets, decoding each with `read`. private func chunkOffsets( - _ data: [UInt8], + _ data: Data, _ box: (start: Int, end: Int), entrySize: Int, read: (Int) -> UInt64 @@ -741,7 +746,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// `stsc` run table mapping chunk indices to samples-per-chunk. - private func parseSTSC(_ data: [UInt8], _ stbl: (start: Int, end: Int)) -> [(firstChunk: Int, samplesPerChunk: Int)]? { + private func parseSTSC(_ data: Data, _ stbl: (start: Int, end: Int)) -> [(firstChunk: Int, samplesPerChunk: Int)]? { guard let box = firstChild(data, stbl.start, stbl.end, "stsc"), box.start + 8 <= box.end else { return nil } let entryCount = Int(beUInt32(data, box.start + 4)) guard entryCount <= Self.maxChapterSampleCount else { return nil } @@ -794,16 +799,15 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { // MARK: - Box navigation helpers /// Scan top-level boxes and return the payload bytes of the first box with the given type. - private func readTopLevelBox(named name: String, handle: FileHandle, fileSize: UInt64) -> [UInt8]? { + private func readTopLevelBox(named name: String, handle: FileHandle, fileSize: UInt64) -> Data? { var offset: UInt64 = 0 while offset + 8 <= fileSize { try? handle.seek(toOffset: offset) guard - let headerData = try? handle.read(upToCount: 16), - headerData.count >= 8 + let header = try? handle.read(upToCount: 16), + header.count >= 8 else { return nil } - let header = [UInt8](headerData) let size32 = beUInt32(header, 0) var boxSize = UInt64(size32) var headerSize: UInt64 = 8 @@ -830,7 +834,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { let payload = try? handle.read(upToCount: payloadLength), payload.count == payloadLength else { return nil } - return [UInt8](payload) + return payload } offset += boxSize @@ -839,7 +843,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Immediate child boxes within the byte range `[start, end)`. - private func childBoxes(_ data: [UInt8], _ start: Int, _ end: Int) -> [(type: String, start: Int, end: Int)] { + private func childBoxes(_ data: Data, _ start: Int, _ end: Int) -> [(type: String, start: Int, end: Int)] { var children: [(String, Int, Int)] = [] var cursor = start while cursor + 8 <= end { @@ -864,7 +868,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// The first immediate child box of the given type within `[start, end)`. - private func firstChild(_ data: [UInt8], _ start: Int, _ end: Int, _ type: String) -> (start: Int, end: Int)? { + private func firstChild(_ data: Data, _ start: Int, _ end: Int, _ type: String) -> (start: Int, end: Int)? { for child in childBoxes(data, start, end) where child.type == type { return (child.start, child.end) } @@ -872,7 +876,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Follow a chain of nested child box types, returning the innermost range. - private func descend(_ data: [UInt8], _ start: Int, _ end: Int, _ path: [String]) -> (start: Int, end: Int)? { + private func descend(_ data: Data, _ start: Int, _ end: Int, _ path: [String]) -> (start: Int, end: Int)? { var current = (start: start, end: end) for type in path { guard let next = firstChild(data, current.start, current.end, type) else { return nil } @@ -882,7 +886,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Read a track's ID from its `tkhd` box (version 0 and 1 layouts differ). - private func trackID(of data: [UInt8], _ trakStart: Int, _ trakEnd: Int) -> UInt32? { + private func trackID(of data: Data, _ trakStart: Int, _ trakEnd: Int) -> UInt32? { guard let tkhd = firstChild(data, trakStart, trakEnd, "tkhd"), tkhd.start < tkhd.end else { return nil } let version = data[tkhd.start] let trackIDOffset = tkhd.start + (version == 1 ? 20 : 12) @@ -907,15 +911,25 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { private func readBytes(handle: FileHandle, offset: UInt64, length: Int) -> Data? { guard length > 0, (try? handle.seek(toOffset: offset)) != nil else { return nil } - return try? handle.read(upToCount: length) - } - - private func beUInt16(_ data: [UInt8], _ offset: Int) -> UInt16 { + // `read(upToCount:)` can return fewer bytes than requested (e.g. a truncated file). Treat + // a short read as a failure so callers never parse a partial sample as if it were complete. + guard let data = try? handle.read(upToCount: length), data.count == length else { return nil } + return data + } + + // Primitive readers operate on `Data` directly so the (potentially large) `moov` and the + // ID3 frame blobs are parsed in place — no intermediate `[UInt8]` copy. They assume the + // passed `Data` is zero-based (a fresh buffer from `read`/`load(.dataValue)`, never a slice), + // since they index by absolute offset. A `Data` slice keeps its parent's indices, so the + // debug assert catches a future caller that passes one (it would read the wrong bytes). + private func beUInt16(_ data: Data, _ offset: Int) -> UInt16 { + assert(data.startIndex == 0, "byte readers require a zero-based Data") guard offset + 2 <= data.count else { return 0 } return (UInt16(data[offset]) << 8) | UInt16(data[offset + 1]) } - private func beUInt32(_ data: [UInt8], _ offset: Int) -> UInt32 { + private func beUInt32(_ data: Data, _ offset: Int) -> UInt32 { + assert(data.startIndex == 0, "byte readers require a zero-based Data") guard offset + 4 <= data.count else { return 0 } return (UInt32(data[offset]) << 24) | (UInt32(data[offset + 1]) << 16) @@ -924,7 +938,8 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } /// Read a 28-bit ID3v2 synchsafe integer (7 bits per byte, MSB always zero). - private func synchsafeUInt32(_ data: [UInt8], _ offset: Int) -> UInt32 { + private func synchsafeUInt32(_ data: Data, _ offset: Int) -> UInt32 { + assert(data.startIndex == 0, "byte readers require a zero-based Data") guard offset + 4 <= data.count else { return 0 } return (UInt32(data[offset] & 0x7F) << 21) | (UInt32(data[offset + 1] & 0x7F) << 14) @@ -932,7 +947,8 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { | UInt32(data[offset + 3] & 0x7F) } - private func beUInt64(_ data: [UInt8], _ offset: Int) -> UInt64 { + private func beUInt64(_ data: Data, _ offset: Int) -> UInt64 { + assert(data.startIndex == 0, "byte readers require a zero-based Data") guard offset + 8 <= data.count else { return 0 } var value: UInt64 = 0 for index in 0..<8 { @@ -941,7 +957,8 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return value } - private func typeString(_ data: [UInt8], _ offset: Int) -> String { + private func typeString(_ data: Data, _ offset: Int) -> String { + assert(data.startIndex == 0, "byte readers require a zero-based Data") guard offset + 4 <= data.count else { return "" } return String(bytes: data[offset.. Date: Sat, 6 Jun 2026 16:22:54 -0500 Subject: [PATCH 11/17] remove artwork property not being used --- Shared/Services/AudioMetadataService.swift | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index 2c87bfeb4..4bc679bac 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -32,20 +32,17 @@ public struct AudioMetadata { public let title: String public let artist: String public let duration: TimeInterval - public let artwork: Data? public let chapters: [ChapterMetadata]? - + public init( title: String, artist: String = "", duration: TimeInterval = 0, - artwork: Data? = nil, chapters: [ChapterMetadata]? = nil ) { self.title = title self.artist = artist self.duration = duration - self.artwork = artwork self.chapters = chapters } } @@ -101,14 +98,12 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { let title = await extractTitle(from: metadata) let artist = await extractArtist(from: metadata) - let artwork = await extractArtwork(from: metadata) let chapters = await extractChapters(from: asset, metadata: metadata, duration: durationSeconds) return AudioMetadata( title: title, artist: artist, duration: durationSeconds, - artwork: artwork, chapters: chapters ) @@ -158,15 +153,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return "" } - - private func extractArtwork(from metadata: [AVMetadataItem]) async -> Data? { - guard let artworkItem = metadata.first(where: { $0.commonKey == .commonKeyArtwork }) else { - return nil - } - - return try? await artworkItem.load(.dataValue) - } - + // MARK: - Chapter Extraction private func extractChapters(from asset: AVAsset, metadata: [AVMetadataItem], duration: TimeInterval) async -> [ChapterMetadata]? { From 5d604f453b9e95a7cec036a78309df78ac245e3a Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sun, 7 Jun 2026 06:56:43 -0500 Subject: [PATCH 12/17] Add playing specific book with app intents --- BookPlayer.xcodeproj/project.pbxproj | 8 +++ BookPlayer/AppIntents/BPAppShortcuts.swift | 11 +++ BookPlayer/AppIntents/BookEntity.swift | 79 ++++++++++++++++++++++ BookPlayer/AppIntents/PlayBookIntent.swift | 41 +++++++++++ BookPlayer/en.lproj/Localizable.strings | 3 + 5 files changed, 142 insertions(+) create mode 100644 BookPlayer/AppIntents/BookEntity.swift create mode 100644 BookPlayer/AppIntents/PlayBookIntent.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index db5c4b9b9..c005d8fec 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -444,6 +444,8 @@ 6356F9CD2AC8A1CE00B7A027 /* DatabaseInitializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6356F9C92AC8A1B700B7A027 /* DatabaseInitializer.swift */; }; 6356F9CE2AC8A1CE00B7A027 /* DatabaseInitializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6356F9C92AC8A1B700B7A027 /* DatabaseInitializer.swift */; }; 6356F9D02ACB01C700B7A027 /* PausePlaybackIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6356F9CF2ACB01C700B7A027 /* PausePlaybackIntent.swift */; }; + D2B95B92F8F84F2FA4B41BF6 /* BookEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33D03D266EF46B5B02FBF40 /* BookEntity.swift */; }; + C744B2254A1146AC9F78A68F /* PlayBookIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */; }; 635771D62F378EE800BB5F59 /* ThemedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 635771D52F378EE800BB5F59 /* ThemedSection.swift */; }; 6357F1192A8BA084007947FC /* BPURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6357F1182A8BA084007947FC /* BPURLSession.swift */; }; 6357F11A2A8BA084007947FC /* BPURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6357F1182A8BA084007947FC /* BPURLSession.swift */; }; @@ -1399,6 +1401,8 @@ 6356F9C42AC86D9200B7A027 /* BPAppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPAppShortcuts.swift; sourceTree = ""; }; 6356F9C92AC8A1B700B7A027 /* DatabaseInitializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseInitializer.swift; sourceTree = ""; }; 6356F9CF2ACB01C700B7A027 /* PausePlaybackIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PausePlaybackIntent.swift; sourceTree = ""; }; + B33D03D266EF46B5B02FBF40 /* BookEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookEntity.swift; sourceTree = ""; }; + 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayBookIntent.swift; sourceTree = ""; }; 635771D52F378EE800BB5F59 /* ThemedSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedSection.swift; sourceTree = ""; }; 6357F1182A8BA084007947FC /* BPURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPURLSession.swift; sourceTree = ""; }; 636086002C5B3EB400341D78 /* CustomRewindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRewindIntent.swift; sourceTree = ""; }; @@ -2713,6 +2717,8 @@ 6356D48B2C584EFD00994B71 /* CustomSkipForwardIntent.swift */, 636086002C5B3EB400341D78 /* CustomRewindIntent.swift */, 63388BE12DB6D7D00042C103 /* CreateBookmarkIntent.swift */, + B33D03D266EF46B5B02FBF40 /* BookEntity.swift */, + 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */, 6356F9C42AC86D9200B7A027 /* BPAppShortcuts.swift */, ); path = AppIntents; @@ -4537,6 +4543,8 @@ 4124122826D19A8700B099DB /* StorageViewModel.swift in Sources */, 4158387926EB8D8800F4A12B /* LoadingViewController.swift in Sources */, 6356F9D02ACB01C700B7A027 /* PausePlaybackIntent.swift in Sources */, + D2B95B92F8F84F2FA4B41BF6 /* BookEntity.swift in Sources */, + C744B2254A1146AC9F78A68F /* PlayBookIntent.swift in Sources */, 634BA5952C1611230015314D /* StoryProgress.swift in Sources */, 4151A6E026E4A17900E49DBE /* Coordinator.swift in Sources */, 418EA78B268D6EF100F6BAEB /* ImportTableViewCell.swift in Sources */, diff --git a/BookPlayer/AppIntents/BPAppShortcuts.swift b/BookPlayer/AppIntents/BPAppShortcuts.swift index eeaf99215..b32d7107f 100644 --- a/BookPlayer/AppIntents/BPAppShortcuts.swift +++ b/BookPlayer/AppIntents/BPAppShortcuts.swift @@ -23,6 +23,17 @@ struct BPAppShortcuts: AppShortcutsProvider { shortTitle: "intent_lastbook_play_title", systemImageName: "play.fill" ), + AppShortcut( + intent: PlayBookIntent(), + phrases: [ + "Play \(\.$book) in \(.applicationName)", + "Play the book \(\.$book) in \(.applicationName)", + "Listen to \(\.$book) in \(.applicationName)", + "Start \(\.$book) in \(.applicationName)" + ], + shortTitle: "intent_play_book_title", + systemImageName: "play.circle.fill" + ), AppShortcut( intent: PausePlaybackIntent(), phrases: [ diff --git a/BookPlayer/AppIntents/BookEntity.swift b/BookPlayer/AppIntents/BookEntity.swift new file mode 100644 index 000000000..42043fb53 --- /dev/null +++ b/BookPlayer/AppIntents/BookEntity.swift @@ -0,0 +1,79 @@ +// +// BookEntity.swift +// BookPlayer +// +// Created by Gianni Carlo on 6/6/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import AppIntents +import BookPlayerKit +import Foundation + +/// Represents a book in the library that can be selected as a parameter in +/// the Shortcuts app and resolved by Siri when playing a specific book. +@available(macOS 14.0, watchOS 10.0, *) +struct BookEntity: AppEntity, Identifiable { + /// The book's `relativePath`, used everywhere as its unique identifier. + let id: String + let title: String + let details: String + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "book_title" + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: details.isEmpty ? nil : "\(details)" + ) + } + + static var defaultQuery = BookEntityQuery() + + init(id: String, title: String, details: String) { + self.id = id + self.title = title + self.details = details + } + + init(item: SimpleLibraryItem) { + self.id = item.relativePath + self.title = item.title + self.details = item.details + } +} + +/// Backs `BookEntity` selection: resolves identifiers, provides suggestions for +/// the picker, and matches spoken/typed titles for Siri. +@available(macOS 14.0, watchOS 10.0, *) +struct BookEntityQuery: EntityStringQuery { + /// Resolve specific books by their `relativePath` identifiers. + func entities(for identifiers: [String]) async throws -> [BookEntity] { + let coreServices = try await AppServices.shared.awaitCoreServices() + let libraryService = coreServices.libraryService + + return identifiers.compactMap { identifier in + libraryService.getSimpleItem(with: identifier).map(BookEntity.init(item:)) + } + } + + /// Match books by a typed/spoken query (title or author), powering Siri resolution. + func entities(matching string: String) async throws -> [BookEntity] { + let coreServices = try await AppServices.shared.awaitCoreServices() + let items = coreServices.libraryService.searchAllBooks( + query: string, + limit: 50, + offset: nil + ) ?? [] + + return items.map(BookEntity.init(item:)) + } + + /// Suggestions shown in the Shortcuts picker: most recently played books first. + func suggestedEntities() async throws -> [BookEntity] { + let coreServices = try await AppServices.shared.awaitCoreServices() + let items = coreServices.libraryService.getLastPlayedItems(limit: 20) ?? [] + + return items.map(BookEntity.init(item:)) + } +} diff --git a/BookPlayer/AppIntents/PlayBookIntent.swift b/BookPlayer/AppIntents/PlayBookIntent.swift new file mode 100644 index 000000000..ccb1d684d --- /dev/null +++ b/BookPlayer/AppIntents/PlayBookIntent.swift @@ -0,0 +1,41 @@ +// +// PlayBookIntent.swift +// BookPlayer +// +// Created by Gianni Carlo on 6/6/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import AppIntents +import BookPlayerKit +import Foundation + +/// Plays a specific book chosen by the user, selectable in the Shortcuts app +/// and resolvable by Siri (e.g. "Play ⟨book⟩ in BookPlayer"). +@available(macOS 14.0, watchOS 10.0, *) +struct PlayBookIntent: AudioPlaybackIntent { + static var title: LocalizedStringResource = "intent_play_book_title" + + @Parameter( + title: "book_title", + requestValueDialog: IntentDialog("intent_play_book_request_title") + ) + var book: BookEntity + + init() {} + + init(book: BookEntity) { + self.book = book + } + + func perform() async throws -> some IntentResult { + let coreServices = try await AppServices.shared.awaitCoreServices() + + try await AppServices.shared.loadAndKeepAlive( + relativePath: book.id, + playerLoaderService: coreServices.playerLoaderService + ) + + return .result() + } +} diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index 1653a4ebc..f5ee30f3a 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Sort"; "search_title" = "Search"; "books_title" = "Books"; +"book_title" = "Book"; "folders_title" = "Folders"; "section_item_title" = "Title"; "section_item_author" = "Author"; @@ -318,6 +319,8 @@ We're working hard on providing a seamless experience, if possible, please conta "intent_lastbook_play_title" = "Resume last played book"; "intent_lastbook_empty_error" = "There's no last played book"; "intent_playback_pause_title" = "Pause playback"; +"intent_play_book_title" = "Play a book"; +"intent_play_book_request_title" = "Which book?"; "storage_artwork_cache_title" = "Artwork cache size"; "settings_share_debug_information" = "Share debug information"; "settings_autlock_section_title" = "Autolock"; From 92aaba2e6fa4a1286a4eac57ba4e446456ab3874 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sun, 7 Jun 2026 07:23:11 -0500 Subject: [PATCH 13/17] Add translations --- BookPlayer/Base.lproj/Localizable.strings | 3 +++ BookPlayer/ar.lproj/Localizable.strings | 3 +++ BookPlayer/ca.lproj/Localizable.strings | 3 +++ BookPlayer/cs.lproj/Localizable.strings | 3 +++ BookPlayer/da.lproj/Localizable.strings | 3 +++ BookPlayer/de.lproj/Localizable.strings | 3 +++ BookPlayer/el.lproj/Localizable.strings | 3 +++ BookPlayer/es.lproj/Localizable.strings | 3 +++ BookPlayer/fi.lproj/Localizable.strings | 3 +++ BookPlayer/fr.lproj/Localizable.strings | 3 +++ BookPlayer/hu.lproj/Localizable.strings | 3 +++ BookPlayer/it.lproj/Localizable.strings | 3 +++ BookPlayer/ja.lproj/Localizable.strings | 3 +++ BookPlayer/nb.lproj/Localizable.strings | 3 +++ BookPlayer/nl.lproj/Localizable.strings | 3 +++ BookPlayer/pl.lproj/Localizable.strings | 3 +++ BookPlayer/pt-BR.lproj/Localizable.strings | 3 +++ BookPlayer/pt-PT.lproj/Localizable.strings | 3 +++ BookPlayer/ro.lproj/Localizable.strings | 3 +++ BookPlayer/ru.lproj/Localizable.strings | 3 +++ BookPlayer/sk-SK.lproj/Localizable.strings | 3 +++ BookPlayer/sv.lproj/Localizable.strings | 3 +++ BookPlayer/tr.lproj/Localizable.strings | 3 +++ BookPlayer/uk.lproj/Localizable.strings | 3 +++ BookPlayer/zh-Hans.lproj/Localizable.strings | 3 +++ 25 files changed, 75 insertions(+) diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index 93c322e4b..b72a17a88 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -246,6 +246,7 @@ "sort_button_title" = "Sort"; "search_title" = "Search"; "books_title" = "Books"; +"book_title" = "Book"; "folders_title" = "Folders"; "section_item_title" = "Title"; "section_item_author" = "Author"; @@ -317,6 +318,8 @@ We're working hard on providing a seamless experience, if possible, please conta "intent_lastbook_play_title" = "Resume last played book"; "intent_lastbook_empty_error" = "There's no last played book"; "intent_playback_pause_title" = "Pause playback"; +"intent_play_book_title" = "Play a book"; +"intent_play_book_request_title" = "Which book?"; "storage_artwork_cache_title" = "Artwork cache size"; "settings_share_debug_information" = "Share debug information"; "settings_autlock_section_title" = "Autolock"; diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 9fd4c3119..9ab60be25 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "نوع"; "search_title" = "يبحث"; "books_title" = "كتب"; +"book_title" = "كتاب"; "folders_title" = "المجلدات"; "section_item_title" = "عنوان"; "section_item_author" = "مؤلف"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "استئناف آخر كتاب تم تشغيله"; "intent_lastbook_empty_error" = "لا يوجد كتاب تم تشغيله مؤخراً"; "intent_playback_pause_title" = "إيقاف التشغيل مؤقتًا"; +"intent_play_book_title" = "تشغيل كتاب"; +"intent_play_book_request_title" = "أي كتاب؟"; "storage_artwork_cache_title" = "حجم ذاكرة التخزين المؤقت للعمل الفني"; "settings_share_debug_information" = "مشاركة معلومات التصحيح"; "settings_autlock_section_title" = "القفل التلقائي"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index a5d2bdea5..9ddc6915d 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Ordena"; "search_title" = "Cerca"; "books_title" = "Llibres"; +"book_title" = "Llibre"; "folders_title" = "Carpetes"; "section_item_title" = "Títol"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "intent_lastbook_play_title" = "Reprèn l'últim llibre reproduït"; "intent_lastbook_empty_error" = "No hi ha l'últim llibre reproduït"; "intent_playback_pause_title" = "Posa en pausa la reproducció"; +"intent_play_book_title" = "Reprodueix un llibre"; +"intent_play_book_request_title" = "Quin llibre?"; "storage_artwork_cache_title" = "Mida de la memòria cau de les imatges"; "settings_share_debug_information" = "Comparteix informació de depuració"; "settings_autlock_section_title" = "Bloqueig automàtic"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index cbbf78c58..bb6dc6339 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Seřadit"; "search_title" = "Vyhledávání"; "books_title" = "knihy"; +"book_title" = "Kniha"; "folders_title" = "Složky"; "section_item_title" = "Titul"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Obnovit naposledy přehrávanou knihu"; "intent_lastbook_empty_error" = "Neexistuje žádná naposledy hraná kniha"; "intent_playback_pause_title" = "Pozastavit přehrávání"; +"intent_play_book_title" = "Přehrát knihu"; +"intent_play_book_request_title" = "Kterou knihu?"; "storage_artwork_cache_title" = "Velikost mezipaměti uměleckých děl"; "settings_share_debug_information" = "Sdílejte informace o ladění"; "settings_autlock_section_title" = "Automatický zámek"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 302a4fd74..04c1c2c89 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Sortere"; "search_title" = "Søg"; "books_title" = "Bøger"; +"book_title" = "Bog"; "folders_title" = "Mapper"; "section_item_title" = "Titel"; "section_item_author" = "Forfatter"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Genoptag sidste spillede bog"; "intent_lastbook_empty_error" = "Der er ingen sidst spillet bog"; "intent_playback_pause_title" = "Sæt afspilning på pause"; +"intent_play_book_title" = "Afspil en bog"; +"intent_play_book_request_title" = "Hvilken bog?"; "storage_artwork_cache_title" = "Artwork cache størrelse"; "settings_share_debug_information" = "Del fejlretningsoplysninger"; "settings_autlock_section_title" = "Autolås"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index ac4c71f3a..29d3a6b5a 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Sortieren"; "search_title" = "Suchen"; "books_title" = "Bücher"; +"book_title" = "Buch"; "folders_title" = "Ordner"; "section_item_title" = "Titel"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Setze das zuletzt gespielte Buch fort"; "intent_lastbook_empty_error" = "Es gibt kein zuletzt gespieltes Buch"; "intent_playback_pause_title" = "Wiedergabe anhalten"; +"intent_play_book_title" = "Ein Buch abspielen"; +"intent_play_book_request_title" = "Welches Buch?"; "storage_artwork_cache_title" = "Größe des Grafik-Cache"; "settings_share_debug_information" = "Debug-Informationen teilen"; "settings_autlock_section_title" = "Automatische Sperre"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 4bbd2a956..9e64993cd 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Ταξινόμηση"; "search_title" = "Αναζήτηση"; "books_title" = "Βιβλία"; +"book_title" = "Βιβλίο"; "folders_title" = "Φάκελοι"; "section_item_title" = "Τίτλος"; "section_item_author" = "Συγγραφέας"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Συνέχιση βιβλίου που παίχτηκε τελευταία"; "intent_lastbook_empty_error" = "Δεν υπάρχει βιβλίο που παίχτηκε τελευταία"; "intent_playback_pause_title" = "Παύση αναπαραγωγής"; +"intent_play_book_title" = "Αναπαραγωγή βιβλίου"; +"intent_play_book_request_title" = "Ποιο βιβλίο;"; "storage_artwork_cache_title" = "Μέγεθος κρυφής μνήμης"; "settings_share_debug_information" = "Κοινή χρήση πληροφοριών εντοπισμού σφαλμάτων"; "settings_autlock_section_title" = "Αυτόματο κλείδωμα"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index ec6a2ba21..8232a0f7e 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Ordenar"; "search_title" = "Buscar"; "books_title" = "Libros"; +"book_title" = "Libro"; "folders_title" = "Carpetas"; "section_item_title" = "Título"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Reanudar el último libro reproducido"; "intent_lastbook_empty_error" = "No hay ningún último libro reproducido"; "intent_playback_pause_title" = "Pausar la reproducción"; +"intent_play_book_title" = "Reproducir un libro"; +"intent_play_book_request_title" = "¿Qué libro?"; "storage_artwork_cache_title" = "Tamaño de caché de ilustraciones"; "settings_share_debug_information" = "Compartir información de depuración"; "settings_autlock_section_title" = "Bloqueo automático"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index e1cb9547f..a68bb4f57 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Järjestellä"; "search_title" = "Hae"; "books_title" = "Kirjat"; +"book_title" = "Kirja"; "folders_title" = "Kansiot"; "section_item_title" = "Otsikko"; "section_item_author" = "Tekijä"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Jatka viimeksi toistettua kirjaa"; "intent_lastbook_empty_error" = "Ei ole viimeksi pelattua kirjaa"; "intent_playback_pause_title" = "Keskeytä toisto"; +"intent_play_book_title" = "Toista kirja"; +"intent_play_book_request_title" = "Mikä kirja?"; "storage_artwork_cache_title" = "Taideteoksen välimuistin koko"; "settings_share_debug_information" = "Jaa virheenkorjaustiedot"; "settings_autlock_section_title" = "Automaattinen lukitus"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index d3f180383..dda5e0da9 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Trier"; "search_title" = "Recherche"; "books_title" = "Livres"; +"book_title" = "Livre"; "folders_title" = "Dossiers"; "section_item_title" = "Titre"; "section_item_author" = "Auteur"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Reprendre le dernier livre lu"; "intent_lastbook_empty_error" = "Il n'y a pas de dernier livre joué"; "intent_playback_pause_title" = "Suspendre la lecture"; +"intent_play_book_title" = "Lire un livre"; +"intent_play_book_request_title" = "Quel livre ?"; "storage_artwork_cache_title" = "Taille du cache des illustrations"; "settings_share_debug_information" = "Partager les informations de débogage"; "settings_autlock_section_title" = "Verrouillage automatique"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 70f957b31..4cc441baa 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Rendezés"; "search_title" = "Keresés"; "books_title" = "Könyvek"; +"book_title" = "Könyv"; "folders_title" = "Mappák"; "section_item_title" = "Cím"; "section_item_author" = "Szerző"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Utoljára lejátszott könyv folytatása"; "intent_lastbook_empty_error" = "Nincs utoljára játszott könyv"; "intent_playback_pause_title" = "Lejátszás szüneteltetése"; +"intent_play_book_title" = "Könyv lejátszása"; +"intent_play_book_request_title" = "Melyik könyv?"; "storage_artwork_cache_title" = "Artwork gyorsítótár mérete"; "settings_share_debug_information" = "Hibakeresési információk megosztása"; "settings_autlock_section_title" = "Automatikus zárolás"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index 638eaeb02..c18701ee7 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Ordina"; "search_title" = "Ricerca"; "books_title" = "Libri"; +"book_title" = "Libro"; "folders_title" = "Cartelle"; "section_item_title" = "Titolo"; "section_item_author" = "Autore"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Riprendi l'ultimo libro riprodotto"; "intent_lastbook_empty_error" = "Non c'è l'ultimo libro giocato"; "intent_playback_pause_title" = "Mettere in pausa la riproduzione"; +"intent_play_book_title" = "Riproduci un libro"; +"intent_play_book_request_title" = "Quale libro?"; "storage_artwork_cache_title" = "Dimensioni della cache della grafica"; "settings_share_debug_information" = "Condividi le informazioni di debug"; "settings_autlock_section_title" = "Chiusura automatica"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index a8cf18cea..86d470319 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "並べ替え"; "search_title" = "検索"; "books_title" = "ブック"; +"book_title" = "ブック"; "folders_title" = "フォルダ"; "section_item_title" = "タイトル"; "section_item_author" = "作成者"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "前回再生していたブックを続きから再生"; "intent_lastbook_empty_error" = "前回再生していたブックがありません"; "intent_playback_pause_title" = "再生を一時停止"; +"intent_play_book_title" = "ブックを再生"; +"intent_play_book_request_title" = "どのブック?"; "storage_artwork_cache_title" = "アートワークのキャッシュサイズ"; "settings_share_debug_information" = "デバッグ情報を共有"; "settings_autlock_section_title" = "自動ロック"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index d9d796e90..a6d0d4fba 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Sorter"; "search_title" = "Søk"; "books_title" = "Bøker"; +"book_title" = "Bok"; "folders_title" = "Mapper"; "section_item_title" = "Tittle"; "section_item_author" = "Forfatter"; @@ -315,6 +316,8 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "intent_lastbook_play_title" = "Fortsett sist spilte bok"; "intent_lastbook_empty_error" = "Det er ingen sist spilte bok"; "intent_playback_pause_title" = "Sett avspilling på pause"; +"intent_play_book_title" = "Spill av en bok"; +"intent_play_book_request_title" = "Hvilken bok?"; "storage_artwork_cache_title" = "Artwork cache-størrelse"; "settings_share_debug_information" = "Del feilsøkingsinformasjon"; "settings_autlock_section_title" = "Automatisk lås"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index 0911877eb..a54b740dd 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Soort"; "search_title" = "Zoekopdracht"; "books_title" = "Boeken"; +"book_title" = "Boek"; "folders_title" = "mappen"; "section_item_title" = "Titel"; "section_item_author" = "Auteur"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Hervat het laatst gespeelde boek"; "intent_lastbook_empty_error" = "Er is geen laatst gespeeld boek"; "intent_playback_pause_title" = "Pauzeer het afspelen"; +"intent_play_book_title" = "Een boek afspelen"; +"intent_play_book_request_title" = "Welk boek?"; "storage_artwork_cache_title" = "Grootte van de artworkcache"; "settings_share_debug_information" = "Deel foutopsporingsinformatie"; "settings_autlock_section_title" = "Automatische vergrendeling"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 73d49b8f6..6d027493d 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Sortuj"; "search_title" = "Szukaj"; "books_title" = "Książki"; +"book_title" = "Książka"; "folders_title" = "Foldery"; "section_item_title" = "Tytuł"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Wznów ostatnio odtwarzaną książkę"; "intent_lastbook_empty_error" = "Nie ma ostatnio odtwarzanej książki"; "intent_playback_pause_title" = "Wstrzymaj odtwarzanie"; +"intent_play_book_title" = "Odtwórz książkę"; +"intent_play_book_request_title" = "Która książka?"; "storage_artwork_cache_title" = "Rozmiar pamięci podręcznej grafiki"; "settings_share_debug_information" = "Udostępnij informacje debugowania"; "settings_autlock_section_title" = "Automatyczna blokada"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index 475447c13..a670f5ebe 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Ordenar"; "search_title" = "Procurar"; "books_title" = "livros"; +"book_title" = "Livro"; "folders_title" = "Pastas"; "section_item_title" = "Título"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Retomar o último livro jogado"; "intent_lastbook_empty_error" = "Não há último livro jogado"; "intent_playback_pause_title" = "Pausar a reprodução"; +"intent_play_book_title" = "Reproduzir um livro"; +"intent_play_book_request_title" = "Qual livro?"; "storage_artwork_cache_title" = "Tamanho do cache de arte"; "settings_share_debug_information" = "Compartilhe informações de depuração"; "settings_autlock_section_title" = "Bloqueio automático"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index eadabc2a4..efc0e2979 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Ordenar"; "search_title" = "Procurar"; "books_title" = "Livros"; +"book_title" = "Livro"; "folders_title" = "Pastas"; "section_item_title" = "Título"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Continuar o último audiolivro reproduzido"; "intent_lastbook_empty_error" = "Último audiolivro reproduzido não encontrado"; "intent_playback_pause_title" = "Pausar reprodução"; +"intent_play_book_title" = "Reproduzir um livro"; +"intent_play_book_request_title" = "Que livro?"; "storage_artwork_cache_title" = "Tamanho do cache de imagens"; "settings_share_debug_information" = "Partilhar informações de depuração"; "settings_autlock_section_title" = "Bloqueio automático"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index bedceb0dc..9c1263cbb 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Fel"; "search_title" = "Căutare"; "books_title" = "Cărți"; +"book_title" = "Carte"; "folders_title" = "Foldere"; "section_item_title" = "Titlu"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Reluați ultima carte jucată"; "intent_lastbook_empty_error" = "Nu există ultima carte jucată"; "intent_playback_pause_title" = "Întrerupeți redarea"; +"intent_play_book_title" = "Redă o carte"; +"intent_play_book_request_title" = "Care carte?"; "storage_artwork_cache_title" = "Dimensiunea memoriei cache a lucrărilor de artă"; "settings_share_debug_information" = "Partajați informațiile de depanare"; "settings_autlock_section_title" = "Închidere automată"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index 6b2039835..d4efb0a42 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Сортировать"; "search_title" = "Поиск"; "books_title" = "Книги"; +"book_title" = "Книга"; "folders_title" = "Папки"; "section_item_title" = "Заголовок"; "section_item_author" = "Автор"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Продолжить последнюю книгу"; "intent_lastbook_empty_error" = "Нет последней прослушанной книги"; "intent_playback_pause_title" = "Приостановить воспроизведение"; +"intent_play_book_title" = "Воспроизвести книгу"; +"intent_play_book_request_title" = "Какую книгу?"; "storage_artwork_cache_title" = "Размер кэша обложек"; "settings_share_debug_information" = "Поделиться отладочной информацией"; "settings_autlock_section_title" = "Автоматическая блокировка"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index fc8644187..91cac9226 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Zoradiť podľa"; "search_title" = "Vyhľadávanie"; "books_title" = "Knihy"; +"book_title" = "Kniha"; "folders_title" = "Priečinky"; "section_item_title" = "Názov"; "section_item_author" = "Autor"; @@ -315,6 +316,8 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "intent_lastbook_play_title" = "Obnovenie poslednej prehrávanej knihy"; "intent_lastbook_empty_error" = "Žiadna naposledy prehrávaná kniha"; "intent_playback_pause_title" = "Pozastavenie prehrávania"; +"intent_play_book_title" = "Prehrať knihu"; +"intent_play_book_request_title" = "Ktorú knihu?"; "storage_artwork_cache_title" = "Veľkosť vyrovnávacej pamäte obalov kníh"; "settings_share_debug_information" = "Zdieľať informácie o ladení"; "settings_autlock_section_title" = "Automatické uzamknutie"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index e4a17da4d..8c91a1e74 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Sort"; "search_title" = "Sök"; "books_title" = "Böcker"; +"book_title" = "Bok"; "folders_title" = "Mappar"; "section_item_title" = "Titel"; "section_item_author" = "Författare"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Återuppta senast spelade bok"; "intent_lastbook_empty_error" = "Det finns ingen senast spelad bok"; "intent_playback_pause_title" = "Pausa uppspelningen"; +"intent_play_book_title" = "Spela upp en bok"; +"intent_play_book_request_title" = "Vilken bok?"; "storage_artwork_cache_title" = "Artwork cachestorlek"; "settings_share_debug_information" = "Dela felsökningsinformation"; "settings_autlock_section_title" = "Auto lås"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 85b4f5224..68c4d71da 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Tür"; "search_title" = "Aramak"; "books_title" = "Kitabın"; +"book_title" = "Kitap"; "folders_title" = "Klasörler"; "section_item_title" = "Başlık"; "section_item_author" = "Yazar"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Son oynatılan kitabı devam ettir"; "intent_lastbook_empty_error" = "Son çalınan kitap yok"; "intent_playback_pause_title" = "Oynatmayı duraklat"; +"intent_play_book_title" = "Bir kitap oynat"; +"intent_play_book_request_title" = "Hangi kitap?"; "storage_artwork_cache_title" = "Resim önbellek boyutu"; "settings_share_debug_information" = "Hata ayıklama bilgilerini paylaşın"; "settings_autlock_section_title" = "Otomatik kilit"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 23ea7ad77..93d3bb6dc 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "Сортувати"; "search_title" = "Пошук"; "books_title" = "Книги"; +"book_title" = "Книга"; "folders_title" = "Папки"; "section_item_title" = "Назва"; "section_item_author" = "Автор"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "Відновити останню відтворену книгу"; "intent_lastbook_empty_error" = "Немає останньої програної книги"; "intent_playback_pause_title" = "Призупинити відтворення"; +"intent_play_book_title" = "Відтворити книгу"; +"intent_play_book_request_title" = "Яку книгу?"; "storage_artwork_cache_title" = "Розмір кеша ілюстрації"; "settings_share_debug_information" = "Поділіться інформацією про налагодження"; "settings_autlock_section_title" = "Автоблокування"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index 5c2a0eabc..a91e076a7 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -244,6 +244,7 @@ "sort_button_title" = "种类"; "search_title" = "搜索"; "books_title" = "图书"; +"book_title" = "图书"; "folders_title" = "文件夹"; "section_item_title" = "标题"; "section_item_author" = "作者"; @@ -315,6 +316,8 @@ "intent_lastbook_play_title" = "继续上次播放的书"; "intent_lastbook_empty_error" = "没有最后玩过的书"; "intent_playback_pause_title" = "暂停播放"; +"intent_play_book_title" = "播放图书"; +"intent_play_book_request_title" = "哪本图书?"; "storage_artwork_cache_title" = "艺术品缓存大小"; "settings_share_debug_information" = "共享调试信息"; "settings_autlock_section_title" = "自动锁"; From 1d85b19bb669047e5aeb0f329f8f847782e5d458 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sun, 7 Jun 2026 08:30:49 -0500 Subject: [PATCH 14/17] Add chapter threshold logic for prev chapter button --- BookPlayer/Player/PlayerManager.swift | 18 ++++++++++++++---- .../Lightweight-Models/PlayableItem.swift | 16 +++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index 4c4c0d59b..d0fc7722d 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -791,14 +791,24 @@ extension PlayerManager { } func skipToPreviousChapter() { - if let currentChapter = currentItem?.currentChapter, - let previousChapter = currentItem?.previousChapter(before: currentChapter) - { + defer { NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } + + guard let currentItem, + let currentChapter = currentItem.currentChapter + else { + playPreviousItem() + return + } + + if !currentItem.isNearChapterStart { + /// First press while into the chapter goes back to its start + jumpToChapter(currentChapter) + } else if let previousChapter = currentItem.previousChapter(before: currentChapter) { + /// Already near the chapter start, so step back to the previous chapter jumpToChapter(previousChapter) } else { playPreviousItem() } - NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } func skip(_ interval: TimeInterval) { diff --git a/Shared/CoreData/Lightweight-Models/PlayableItem.swift b/Shared/CoreData/Lightweight-Models/PlayableItem.swift index 4b0397177..5abe20a2b 100644 --- a/Shared/CoreData/Lightweight-Models/PlayableItem.swift +++ b/Shared/CoreData/Lightweight-Models/PlayableItem.swift @@ -128,6 +128,18 @@ public final class PlayableItem: NSObject, Identifiable { : self.duration } + /// Seconds from a chapter's start within which the playhead is still considered to be "at the start". + /// Shared by rewind clamping and the previous-chapter button so they stay in lockstep. + public static let chapterStartThreshold: TimeInterval = 3 + + /// Whether the playhead is close enough to the current chapter's start that a backward action + /// should step into the previous chapter rather than restart the current one. + public var isNearChapterStart: Bool { + guard let chapter = self.currentChapter else { return false } + + return self.currentTime < chapter.start + Self.chapterStartThreshold + } + public func getInterval(from proposedInterval: TimeInterval) -> TimeInterval { let interval = proposedInterval > 0 @@ -144,9 +156,7 @@ public final class PlayableItem: NSObject, Identifiable { return proposedInterval } - let chapterThreshold: TimeInterval = 3 - - if chapter.start + chapterThreshold > currentTime { + if self.isNearChapterStart { return proposedInterval } From cbb4c4cc496bc90d242674b4ae053c728796f110 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sun, 7 Jun 2026 09:04:19 -0500 Subject: [PATCH 15/17] Address feedback --- BookPlayerTests/PlayableItemTests.swift | 31 +++++++ BookPlayerTests/PlayerManagerTests.swift | 90 +++++++++++++++++++ .../LocalPlayback/Player/PlayerManager.swift | 18 +++- .../Lightweight-Models/PlayableItem.swift | 2 +- 4 files changed, 136 insertions(+), 5 deletions(-) diff --git a/BookPlayerTests/PlayableItemTests.swift b/BookPlayerTests/PlayableItemTests.swift index ed7090c0a..419b113f1 100644 --- a/BookPlayerTests/PlayableItemTests.swift +++ b/BookPlayerTests/PlayableItemTests.swift @@ -84,4 +84,35 @@ class PlayableItemTests: XCTestCase { XCTAssert(remainingTimeInBook == -50) } + + // MARK: - isNearChapterStart + + func testIsNearChapterStartWithinThreshold() { + sut.currentChapter = sut.chapters[0] // starts at 0 + sut.currentTime = 2 + + XCTAssertTrue(sut.isNearChapterStart) + } + + func testIsNearChapterStartAtChapterStart() { + sut.currentChapter = sut.chapters[1] // starts at 51 + sut.currentTime = 51 + + XCTAssertTrue(sut.isNearChapterStart) + } + + func testIsNotNearChapterStartAtThresholdBoundary() { + /// Exactly `chapterStartThreshold` (3s) past the start is NOT near the start + sut.currentChapter = sut.chapters[1] // starts at 51 + sut.currentTime = 54 + + XCTAssertFalse(sut.isNearChapterStart) + } + + func testIsNotNearChapterStartMidChapter() { + sut.currentChapter = sut.chapters[1] // starts at 51 + sut.currentTime = 100 + + XCTAssertFalse(sut.isNearChapterStart) + } } diff --git a/BookPlayerTests/PlayerManagerTests.swift b/BookPlayerTests/PlayerManagerTests.swift index d2bd5ff21..70dc9a7e5 100644 --- a/BookPlayerTests/PlayerManagerTests.swift +++ b/BookPlayerTests/PlayerManagerTests.swift @@ -69,6 +69,43 @@ class PlayerManagerTests: XCTestCase { ) } + /// Two-chapter item using production's 1-based chapter indexing, so + /// `nextChapter`/`previousChapter` navigation resolves correctly. + private func generateChapteredItem() -> PlayableItem { + let chapter1 = PlayableChapter( + title: "chapter 1", + author: "author", + start: 0, + duration: 50, + relativePath: "", + remoteURL: nil, + index: 1 + ) + let chapter2 = PlayableChapter( + title: "chapter 2", + author: "author", + start: 51, + duration: 100, + relativePath: "", + remoteURL: nil, + index: 2 + ) + return PlayableItem( + title: "test book", + author: "test author", + chapters: [chapter1, chapter2], + currentTime: 0, + duration: 151, + relativePath: "", + uuid: "LEGACY_UUID", + parentFolder: nil, + percentCompleted: 10, + lastPlayDate: nil, + isFinished: false, + isBoundBook: false + ) + } + func testUpdatingEmptyNowPlayingBookTime() { self.sut.setNowPlayingBookTime() @@ -198,4 +235,57 @@ class PlayerManagerTests: XCTestCase { XCTAssertNil(nextItem) XCTAssertTrue(playbackServiceMock.getPlayableItemAfterParentFolderAutoplayedRestartFinishedCallsCount == 2) } + + // MARK: - skipToPreviousChapter + + func testSkipToPreviousChapterMidChapterRestartsCurrentChapter() { + let item = generateChapteredItem() + item.currentChapter = item.chapters[1] // chapter 2, starts at 51 + item.currentTime = 100 // well past the start threshold + sut.currentItem = item + + sut.skipToPreviousChapter() + + // Restarts the current chapter rather than stepping back + XCTAssertEqual(sut.currentItem?.currentChapter?.index, 2) + XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 0) + } + + func testSkipToPreviousChapterNearStartStepsToPreviousChapter() { + let item = generateChapteredItem() + item.currentChapter = item.chapters[1] // chapter 2, starts at 51 + item.currentTime = 52 // within the start threshold + sut.currentItem = item + + sut.skipToPreviousChapter() + + // Steps back to the previous chapter + XCTAssertEqual(sut.currentItem?.currentChapter?.index, 1) + XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 0) + } + + func testSkipToPreviousChapterFirstChapterMidChapterRestartsInstead() { + let item = generateChapteredItem() + item.currentChapter = item.chapters[0] // chapter 1, starts at 0 + item.currentTime = 30 // mid-chapter, past the threshold + sut.currentItem = item + + sut.skipToPreviousChapter() + + // Restarts chapter 1 instead of jumping to the previous item + XCTAssertEqual(sut.currentItem?.currentChapter?.index, 1) + XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 0) + } + + func testSkipToPreviousChapterFirstChapterNearStartPlaysPreviousItem() { + let item = generateChapteredItem() + item.currentChapter = item.chapters[0] // chapter 1, starts at 0 + item.currentTime = 1 // within the start threshold, no previous chapter + sut.currentItem = item + + sut.skipToPreviousChapter() + + // Falls back to the previous item + XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 1) + } } diff --git a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift index b26ba4944..3d522d618 100644 --- a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift +++ b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift @@ -745,14 +745,24 @@ extension PlayerManager { } func skipToPreviousChapter() { - if let currentChapter = currentItem?.currentChapter, - let previousChapter = currentItem?.previousChapter(before: currentChapter) - { + defer { NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } + + guard let currentItem, + let currentChapter = currentItem.currentChapter + else { + playPreviousItem() + return + } + + if !currentItem.isNearChapterStart { + /// First press while into the chapter goes back to its start + jumpToChapter(currentChapter) + } else if let previousChapter = currentItem.previousChapter(before: currentChapter) { + /// Already near the chapter start, so step back to the previous chapter jumpToChapter(previousChapter) } else { playPreviousItem() } - NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } func skip(_ interval: TimeInterval) { diff --git a/Shared/CoreData/Lightweight-Models/PlayableItem.swift b/Shared/CoreData/Lightweight-Models/PlayableItem.swift index 5abe20a2b..717cb641e 100644 --- a/Shared/CoreData/Lightweight-Models/PlayableItem.swift +++ b/Shared/CoreData/Lightweight-Models/PlayableItem.swift @@ -130,7 +130,7 @@ public final class PlayableItem: NSObject, Identifiable { /// Seconds from a chapter's start within which the playhead is still considered to be "at the start". /// Shared by rewind clamping and the previous-chapter button so they stay in lockstep. - public static let chapterStartThreshold: TimeInterval = 3 + private static let chapterStartThreshold: TimeInterval = 3 /// Whether the playhead is close enough to the current chapter's start that a backward action /// should step into the previous chapter rather than restart the current one. From ee6b386e895222e4c3fd3c4abcffff0f0dc0b51a Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sun, 7 Jun 2026 09:12:47 -0500 Subject: [PATCH 16/17] fix tests --- BookPlayerTests/PlayerManagerTests.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/BookPlayerTests/PlayerManagerTests.swift b/BookPlayerTests/PlayerManagerTests.swift index 70dc9a7e5..e9191b628 100644 --- a/BookPlayerTests/PlayerManagerTests.swift +++ b/BookPlayerTests/PlayerManagerTests.swift @@ -24,6 +24,11 @@ class PlayerManagerTests: XCTestCase { UserDefaults.sharedDefaults.removeObject(forKey: Constants.UserDefaults.remainingTimeEnabled) self.playbackServiceMock = PlaybackServiceProtocolMock() + /// Mirror production `PlaybackService.updatePlaybackTime(item:time:)`, which advances the + /// playhead, so seek targets are observable in tests instead of staying frozen. + self.playbackServiceMock.updatePlaybackTimeItemTimeClosure = { item, time in + item.currentTime = time + } self.sut = PlayerManager( libraryService: LibraryServiceProtocolMock(), playbackService: playbackServiceMock, @@ -246,8 +251,9 @@ class PlayerManagerTests: XCTestCase { sut.skipToPreviousChapter() - // Restarts the current chapter rather than stepping back + // Restarts the current chapter (seeks to its start) rather than stepping back XCTAssertEqual(sut.currentItem?.currentChapter?.index, 2) + XCTAssertEqual(sut.currentItem?.currentTime ?? 0, 51.1, accuracy: 0.0001) XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 0) } @@ -259,8 +265,9 @@ class PlayerManagerTests: XCTestCase { sut.skipToPreviousChapter() - // Steps back to the previous chapter + // Steps back to the previous chapter's start XCTAssertEqual(sut.currentItem?.currentChapter?.index, 1) + XCTAssertEqual(sut.currentItem?.currentTime ?? -1, 0.1, accuracy: 0.0001) XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 0) } @@ -272,8 +279,9 @@ class PlayerManagerTests: XCTestCase { sut.skipToPreviousChapter() - // Restarts chapter 1 instead of jumping to the previous item + // Restarts chapter 1 (seeks to its start) instead of jumping to the previous item XCTAssertEqual(sut.currentItem?.currentChapter?.index, 1) + XCTAssertEqual(sut.currentItem?.currentTime ?? -1, 0.1, accuracy: 0.0001) XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 0) } @@ -285,7 +293,8 @@ class PlayerManagerTests: XCTestCase { sut.skipToPreviousChapter() - // Falls back to the previous item + // Falls back to the previous item without seeking within the current one XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 1) + XCTAssertEqual(sut.currentItem?.currentTime ?? -1, 1, accuracy: 0.0001) } } From be3651475f51953a1d75f3b7fd9c273b7f460fd7 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Sun, 7 Jun 2026 09:21:16 -0500 Subject: [PATCH 17/17] set app version --- BookPlayer.xcodeproj/project.pbxproj | 36 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index c005d8fec..2c91a7dbf 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -5178,7 +5178,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; @@ -5212,7 +5212,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5244,7 +5244,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5280,7 +5280,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; @@ -5321,7 +5321,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5359,7 +5359,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5528,7 +5528,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; @@ -5566,7 +5566,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5602,7 +5602,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5759,7 +5759,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; @@ -5797,7 +5797,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; @@ -6021,7 +6021,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; @@ -6059,7 +6059,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6095,7 +6095,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6134,7 +6134,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; @@ -6174,7 +6174,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6212,7 +6212,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6306,7 +6306,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.20.1; + MARKETING_VERSION = 5.21.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)";