diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 92b98ca90..2c91a7dbf 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 */; }; @@ -441,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 */; }; @@ -632,6 +637,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 +1267,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 = ""; }; @@ -1289,6 +1296,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 = ""; }; @@ -1392,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 = ""; }; @@ -1748,7 +1759,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 = ""; }; @@ -2219,6 +2230,7 @@ 41C3CF7D20AEA5E5007C3EF4 /* DataManagerTests.swift */, 9F8A9A5D27AC3F8C0093AA1C /* PlayableItemTests.swift */, 9FB7C48C2835A2C1003B917E /* PlayerManagerTests.swift */, + FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */, 6279361D272D0CF50097837D /* Coordinators */, 418B6D141D2707F800F974FB /* Info.plist */, ); @@ -2566,6 +2578,7 @@ 9FC1E4612814F68F00522FA8 /* Account */, 9FD8FE4C286566FF00EB2C3D /* Sync */, 64957AF0989CEBEEEC40E4BB /* Preferences */, + FEEDBABE0000000000000001 /* SharedWidgetStore.swift */, ); path = Services; sourceTree = ""; @@ -2704,6 +2717,8 @@ 6356D48B2C584EFD00994B71 /* CustomSkipForwardIntent.swift */, 636086002C5B3EB400341D78 /* CustomRewindIntent.swift */, 63388BE12DB6D7D00042C103 /* CreateBookmarkIntent.swift */, + B33D03D266EF46B5B02FBF40 /* BookEntity.swift */, + 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */, 6356F9C42AC86D9200B7A027 /* BPAppShortcuts.swift */, ); path = AppIntents; @@ -3169,6 +3184,7 @@ 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */, 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */, B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */, + D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */, 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */, 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */, A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */, @@ -4341,6 +4357,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 */, @@ -4526,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 */, @@ -4736,6 +4755,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 */, @@ -4761,6 +4781,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 */, @@ -4837,6 +4858,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 */, @@ -5156,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"; @@ -5190,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)"; @@ -5222,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)"; @@ -5258,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"; @@ -5299,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)"; @@ -5337,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)"; @@ -5506,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"; @@ -5544,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)"; @@ -5580,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)"; @@ -5737,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)"; @@ -5775,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)"; @@ -5999,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"; @@ -6037,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)"; @@ -6073,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)"; @@ -6112,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"; @@ -6152,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)"; @@ -6190,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)"; @@ -6284,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)"; 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/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..b72a17a88 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"; @@ -244,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"; @@ -315,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"; @@ -342,6 +347,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 +357,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 +371,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/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/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..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) @@ -379,25 +390,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/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/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/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index fb6d0e1d7..d0fc7722d 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 { @@ -59,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 @@ -358,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) { @@ -803,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) { @@ -872,6 +870,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 +930,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/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/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/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/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..9ab60be25 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" = "أعد تشغيل الكتب المنتهية"; @@ -242,6 +244,7 @@ "sort_button_title" = "نوع"; "search_title" = "يبحث"; "books_title" = "كتب"; +"book_title" = "كتاب"; "folders_title" = "المجلدات"; "section_item_title" = "عنوان"; "section_item_author" = "مؤلف"; @@ -313,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" = "القفل التلقائي"; @@ -341,6 +346,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 +370,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..9ddc6915d 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"; @@ -242,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"; @@ -313,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"; @@ -341,6 +346,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 +370,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..bb6dc6339 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..04c1c2c89 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..29d3a6b5a 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..9e64993cd 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" = "Επανεκκινήστε τα ολοκληρωμένα βιβλία"; @@ -242,6 +244,7 @@ "sort_button_title" = "Ταξινόμηση"; "search_title" = "Αναζήτηση"; "books_title" = "Βιβλία"; +"book_title" = "Βιβλίο"; "folders_title" = "Φάκελοι"; "section_item_title" = "Τίτλος"; "section_item_author" = "Συγγραφέας"; @@ -313,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" = "Αυτόματο κλείδωμα"; @@ -341,6 +346,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 +370,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..f5ee30f3a 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"; @@ -242,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"; @@ -316,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"; @@ -343,6 +348,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 +358,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 +372,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..8232a0f7e 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..a68bb4f57 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"; @@ -242,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ä"; @@ -313,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"; @@ -340,6 +345,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 +369,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..dda5e0da9 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..4cc441baa 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"; @@ -242,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ő"; @@ -313,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"; @@ -341,6 +346,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 +370,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..c18701ee7 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..86d470319 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" = "再生済みのブックを最初から再生"; @@ -242,6 +244,7 @@ "sort_button_title" = "並べ替え"; "search_title" = "検索"; "books_title" = "ブック"; +"book_title" = "ブック"; "folders_title" = "フォルダ"; "section_item_title" = "タイトル"; "section_item_author" = "作成者"; @@ -313,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" = "自動ロック"; @@ -341,6 +346,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 +370,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..a6d0d4fba 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..a54b740dd 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..6d027493d 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..a670f5ebe 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..efc0e2979 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..9c1263cbb 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"; @@ -242,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"; @@ -313,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ă"; @@ -340,6 +345,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 +369,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..d4efb0a42 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" = "Перезапустить готовые книги"; @@ -242,6 +244,7 @@ "sort_button_title" = "Сортировать"; "search_title" = "Поиск"; "books_title" = "Книги"; +"book_title" = "Книга"; "folders_title" = "Папки"; "section_item_title" = "Заголовок"; "section_item_author" = "Автор"; @@ -313,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" = "Автоматическая блокировка"; @@ -340,6 +345,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 +369,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..91cac9226 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"; @@ -242,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"; @@ -313,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"; @@ -341,6 +346,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 +370,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..8c91a1e74 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..68c4d71da 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"; @@ -242,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"; @@ -313,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"; @@ -340,6 +345,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 +369,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..93d3bb6dc 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" = "Перезапускати закінчені книги"; @@ -242,6 +244,7 @@ "sort_button_title" = "Сортувати"; "search_title" = "Пошук"; "books_title" = "Книги"; +"book_title" = "Книга"; "folders_title" = "Папки"; "section_item_title" = "Назва"; "section_item_author" = "Автор"; @@ -313,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" = "Автоблокування"; @@ -340,6 +345,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 +369,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..a91e076a7 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" = "重新启动完成的书籍"; @@ -242,6 +244,7 @@ "sort_button_title" = "种类"; "search_title" = "搜索"; "books_title" = "图书"; +"book_title" = "图书"; "folders_title" = "文件夹"; "section_item_title" = "标题"; "section_item_author" = "作者"; @@ -313,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" = "自动锁"; @@ -340,6 +345,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 +369,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/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..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, @@ -69,6 +74,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 +240,61 @@ 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 (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) + } + + 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's start + XCTAssertEqual(sut.currentItem?.currentChapter?.index, 1) + XCTAssertEqual(sut.currentItem?.currentTime ?? -1, 0.1, accuracy: 0.0001) + 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 (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) + } + + 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 without seeking within the current one + XCTAssertEqual(playbackServiceMock.getPlayableItemBeforeParentFolderCallsCount, 1) + XCTAssertEqual(sut.currentItem?.currentTime ?? -1, 1, accuracy: 0.0001) + } } 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/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..3d522d618 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) { @@ -761,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/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 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 8d4c94207..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" @@ -111,6 +112,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 { diff --git a/Shared/CoreData/Lightweight-Models/PlayableItem.swift b/Shared/CoreData/Lightweight-Models/PlayableItem.swift index 4b0397177..717cb641e 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. + 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. + 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 } 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/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/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index 4a386e6fa..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 } } @@ -63,7 +60,29 @@ 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. `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() {} public func extractMetadata(from fileURL: URL) async -> AudioMetadata? { @@ -79,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 ) @@ -136,27 +153,45 @@ 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]? { 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: 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. + // 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, + 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 + } + // 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 let identifiers = metadata.compactMap { $0.identifier?.rawValue } // FLAC/Vorbis chapters (CHAPTER tags) @@ -169,9 +204,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) } @@ -283,21 +319,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(data) { + chapterData.append(parsed) } } @@ -306,10 +345,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 @@ -318,7 +359,7 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { let chapter = ChapterMetadata( title: data.title, start: data.start, - duration: chapterDuration, + duration: max(0, chapterDuration), index: index + 1 ) @@ -328,6 +369,108 @@ 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: 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 + + // 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: Data, 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 payloadStart = cursor + 10 + + // 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 { + // 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 } + + 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.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.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() } + trimmed = String(bytes: bytes, encoding: .utf8) ?? "" + } + + 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) @@ -385,4 +528,425 @@ 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"), + chap.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: Data, + 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. 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 } + 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 + ) + // 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 0..= 2, + location.size <= Self.maxChapterSampleSize, + let sampleData = readBytes(handle: handle, offset: location.offset, length: location.size), + sampleData.count >= 2 + else { continue } + + let titleLength = Int(beUInt16(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: 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] = [] + var cursor = box.start + 8 + for _ in 0.. [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 <= Self.maxChapterSampleCount 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") { + return chunkOffsets(data, box, entrySize: 4) { UInt64(beUInt32(data, $0)) } + } + if let box = firstChild(data, stbl.start, stbl.end, "co64") { + return chunkOffsets(data, box, entrySize: 8) { beUInt64(data, $0) } + } + return nil + } + + /// Shared `stco`/`co64` reader: walk `entrySize`-byte chunk offsets, decoding each with `read`. + private func chunkOffsets( + _ data: Data, + _ box: (start: Int, end: Int), + entrySize: Int, + read: (Int) -> 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.. [(offset: UInt64, size: Int)] { + var locations: [(UInt64, Int)] = [] + var sampleIndex = 0 + + for chunkIndex in 0.. Data? { + var offset: UInt64 = 0 + while offset + 8 <= fileSize { + try? handle.seek(toOffset: offset) + guard + let header = try? handle.read(upToCount: 16), + header.count >= 8 + else { return nil } + + 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 + } + + // 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 { + 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 + else { return nil } + return payload + } + + offset += boxSize + } + return nil + } + + /// Immediate child boxes within the byte range `[start, end)`. + 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 { + let size32 = beUInt32(data, cursor) + var size = Int(size32) + var headerSize = 8 + if size32 == 1 { + guard cursor + 16 <= end else { break } + // 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 + } + 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: 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) + } + return nil + } + + /// Follow a chain of nested child box types, returning the innermost range. + 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 } + current = next + } + return current + } + + /// Read a track's ID from its `tkhd` box (version 0 and 1 layouts differ). + 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) + 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 } + // `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: 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) + | (UInt32(data[offset + 2]) << 8) + | UInt32(data[offset + 3]) + } + + /// Read a 28-bit ID3v2 synchsafe integer (7 bits per byte, MSB always zero). + 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) + | (UInt32(data[offset + 2] & 0x7F) << 7) + | UInt32(data[offset + 3] & 0x7F) + } + + 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 { + value = (value << 8) | UInt64(data[offset + index]) + } + return value + } + + 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.. { 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() {} diff --git a/Shared/Services/LibraryService.swift b/Shared/Services/LibraryService.swift index d9b54a9b2..35642b5dd 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 + "/") ) } @@ -1129,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?) { @@ -1187,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 { @@ -1507,7 +1526,7 @@ extension LibraryService { public func filterContents( at relativePath: String?, query: String?, - scope: SimpleItemType, + scope: SimpleItemType?, limit: Int?, offset: Int? ) -> [SimpleLibraryItem]? { @@ -2100,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 {} + } +}