diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 2c91a7dbf..5ae8e06df 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 11A972EC3C4426DD4D02867E /* TagsFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75AADE0F4BFEC0640FC687FA /* TagsFlowLayout.swift */; }; 17239057BBE31E405AFFBBCD /* IntegrationServerFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3511F737DCDEE7A12B713AE /* IntegrationServerFoundView.swift */; }; 18D0AD99D1AAA10976F75C11 /* BPNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55F466D61AE62A69DE4D371 /* BPNavigation.swift */; }; + 1A26F7D72FFD6D625A313FC5 /* AudioMetadataServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC40E520F644AFA46DAD76B0 /* AudioMetadataServiceTests.swift */; }; + 2B283D695990DA665D1661CC /* mp3_NO_chapters.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 12EDB8401F38342155CBBC91 /* mp3_NO_chapters.mp3 */; }; 2D7D5973F598D517CBCCE0ED /* PreferencesSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */; }; 3F66408A2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; 3F66408B2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; @@ -109,6 +111,7 @@ 4140EA81227289D80009F794 /* Folder+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1B11C226F88C500EA0400 /* Folder+CoreDataProperties.swift */; }; 4140EA84227289E60009F794 /* BookPlayer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 4165EDF920A743D500616EDF /* BookPlayer.xcdatamodeld */; }; 4140EA8522728A160009F794 /* BookPlayer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 4165EDF920A743D500616EDF /* BookPlayer.xcdatamodeld */; }; + 4147E8513D57E852C965FC0B /* mp3_NO_toc_v24.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 4BFC5736E667F4BF8CBB9096 /* mp3_NO_toc_v24.mp3 */; }; 4149539A21F0D456003C5D27 /* Themes.json in Resources */ = {isa = PBXBuildFile; fileRef = 4149539921F0D456003C5D27 /* Themes.json */; }; 4151A6A626E3A40600E49DBE /* SpeedService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4151A6A526E3A40600E49DBE /* SpeedService.swift */; }; 4151A6A826E48C3400E49DBE /* Storyboarded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4151A6A726E48C3400E49DBE /* Storyboarded.swift */; }; @@ -274,6 +277,7 @@ 4645F9FD2D1E46AC00A04257 /* SwipeInlineTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */; }; 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87512D3195D600A4AA47 /* BookmarksView.swift */; }; 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */; }; + 466E4B7066F10717C8CE7B3E /* LibraryServiceReloadChaptersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C418B028982CF431C589F /* LibraryServiceReloadChaptersTests.swift */; }; 4689C06D2D270A7100D6C169 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 419B375423B8D5A500128A8F /* Localizable.strings */; }; 46EEDDC92D23154C0063811F /* VoiceOverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D322133844D000C425E /* VoiceOverService.swift */; }; 5126F121258E9F18009965DC /* URL+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126F120258E9F18009965DC /* URL+BookPlayer.swift */; }; @@ -281,6 +285,7 @@ 5126F123258E9F18009965DC /* URL+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126F120258E9F18009965DC /* URL+BookPlayer.swift */; }; 5163037679ED3EE0D1866A8F /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC8B923A5506F865253B97C /* AppServices.swift */; }; 52AA441379FD94339FEECB55 /* SortPreferencesResolving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BC9E7E76B9BAB7A0D88D2 /* SortPreferencesResolving.swift */; }; + 59384C1A0B33B9EB42AE4C75 /* ChaptersViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F496A2BF4CBFFC745C453D42 /* ChaptersViewModelTests.swift */; }; 62793611272CBE910097837D /* ImportFileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4163E3102148606600072AA2 /* ImportFileItem.swift */; }; 62AAE22D274AA6DE001EB9FF /* LibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AAE22B274AA3EB001EB9FF /* LibraryService.swift */; }; 62AAE22E274AA6DE001EB9FF /* LibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AAE22B274AA3EB001EB9FF /* LibraryService.swift */; }; @@ -321,9 +326,6 @@ 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 */; }; @@ -351,6 +353,7 @@ 631908AA2E369BDB009249C1 /* ProgressSeekingSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908A92E369BDA009249C1 /* ProgressSeekingSectionView.swift */; }; 631908AC2E369E31009249C1 /* GlobalSpeedSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908AB2E369E31009249C1 /* GlobalSpeedSectionView.swift */; }; 631908AE2E369EDC009249C1 /* BoostVolumeSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908AD2E369EDC009249C1 /* BoostVolumeSectionView.swift */; }; + 631908F02E369EDC009249C1 /* StartupPlayerSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908F12E369EDC009249C1 /* StartupPlayerSectionView.swift */; }; 631908B02E369FFA009249C1 /* AutoSleepTimerSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908AF2E369FFA009249C1 /* AutoSleepTimerSectionView.swift */; }; 631908B22E36A1AB009249C1 /* SmartRewindSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908B12E36A1AB009249C1 /* SmartRewindSectionView.swift */; }; 631908B42E36A26C009249C1 /* SkipIntervalsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908B32E36A26C009249C1 /* SkipIntervalsSectionView.swift */; }; @@ -444,8 +447,6 @@ 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,16 +633,16 @@ 63FCBBBE2DF7404000C50035 /* JellyfinLibraryListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FCBBBD2DF7404000C50035 /* JellyfinLibraryListItemView.swift */; }; 66DF1F3E6AFECB623A558F04 /* IntegrationDisconnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */; }; 6906A55021720FDF00A9E0B2 /* BookSortServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6906A54F21720FDF00A9E0B2 /* BookSortServiceTest.swift */; }; - FED52673ACD975949EF9E219 /* PreferencesSyncServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA09FE5A35CEA0B87F83EF3 /* PreferencesSyncServiceTests.swift */; }; 6906A553217211C600A9E0B2 /* StubFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6906A552217211C600A9E0B2 /* StubFactory.swift */; }; 69343D332133844D000C425E /* VoiceOverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D322133844D000C425E /* VoiceOverService.swift */; }; 69343D36213A07B4000C425E /* VoiceOverServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D35213A07B4000C425E /* VoiceOverServiceTest.swift */; }; + 6C25A0E1ADBF2325CA78E8A4 /* mp3_NO_toc_v23.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 1BA32C89144C68DD64FD0B62 /* mp3_NO_toc_v23.mp3 */; }; + 6EC082E4BEF0CC2FAA42C0A7 /* mp3_WITH_toc.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 8D3EFD93E6D1E0366A3FD1E9 /* mp3_WITH_toc.mp3 */; }; 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 */; }; + 797E853F1A6F9CE2A713FD8B /* m4b_WELLFORMED.m4b in Resources */ = {isa = PBXBuildFile; fileRef = D6F6A0DE6FBE7828D4C7EC9C /* m4b_WELLFORMED.m4b */; }; 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */; }; 8322A3C6479B099448C63C47 /* IntegrationLibraryViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */; }; - B7F1E4A9D2C5B8F0A6E3C12A /* IntegrationImageSizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */; }; 8A2A22392CEFEB8E00E73A2D /* AdaptiveVGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A2A22382CEFEB8800E73A2D /* AdaptiveVGrid.swift */; }; 8A495CC62CC85D27001B4244 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 8A495CC52CC85D27001B4244 /* JellyfinAPI */; }; 8A9D0D242CCED53C007A924D /* JellyfinLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9D0D232CCED53C007A924D /* JellyfinLibraryViewModel.swift */; }; @@ -843,6 +844,7 @@ A7FA45CB2BD6567C9B5EA372 /* IntegrationTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643E169A5F153B754F726AE1 /* IntegrationTagsView.swift */; }; B14881000000000000000001 /* IntegrationCustomHeadersSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */; }; B26B34D894351E8452FBB64B /* IntegrationServerInformationSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */; }; + B7F1E4A9D2C5B8F0A6E3C12A /* IntegrationImageSizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */; }; C318DDBC20A48D4700C3A17B /* BPMarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C318DDBB20A48D4700C3A17B /* BPMarqueeLabel.swift */; }; C37A6873209F0F830063AEAC /* Credits.html in Resources */ = {isa = PBXBuildFile; fileRef = C37A6872209F0F830063AEAC /* Credits.html */; }; C39401E920DEE83200F3DC71 /* UIView+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39401E820DEE83100F3DC71 /* UIView+BookPlayer.swift */; }; @@ -856,9 +858,13 @@ C3FE3F8220A090880055B9C6 /* limitPanAngle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FE3F8120A090880055B9C6 /* limitPanAngle.swift */; }; C451596D6866C856F3E5F7D1 /* IntegrationConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D61F68F0A174C7CA44FE8A /* IntegrationConnectionView.swift */; }; C53864BEFAE4D1CEC668A6B3 /* IntegrationLibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F0D505E060FDAEB6DB68E3 /* IntegrationLibraryListView.swift */; }; + C63BB893BC572482723FF7CE /* m4b_MALFORMED.m4b in Resources */ = {isa = PBXBuildFile; fileRef = A566F222C6660938B3A71638 /* m4b_MALFORMED.m4b */; }; + C744B2254A1146AC9F78A68F /* PlayBookIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */; }; CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */; }; CCC7E1CC127610C3687B8A50 /* SortPreferencesResolving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BC9E7E76B9BAB7A0D88D2 /* SortPreferencesResolving.swift */; }; D080B0A77D9844C3A0737170 /* IntegrationConnectionFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */; }; + D2B95B92F8F84F2FA4B41BF6 /* BookEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33D03D266EF46B5B02FBF40 /* BookEntity.swift */; }; + D5E6F7081A2B3C4D5E6F7081 /* MediaServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */; }; D6BA8F162A4CA94800C2BD9A /* StorageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */; }; D6BA8F182A4D66CD00C2BD9A /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */; }; DCFC79BEBAEA628FB13F33A1 /* BPFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACFA02FA31A0CB0438C5228 /* BPFont.swift */; }; @@ -866,6 +872,10 @@ F7CB281CF765C468BFA9B0D8 /* IntegrationDetailsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */; }; F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */; }; F9FA95F9A4AE359006A85B88 /* PreferencesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E6918C51C370C2E89B24F9 /* PreferencesAPI.swift */; }; + FED52673ACD975949EF9E219 /* PreferencesSyncServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA09FE5A35CEA0B87F83EF3 /* PreferencesSyncServiceTests.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 */; }; FF95F8C692908D744A32D761 /* PreferencesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E6918C51C370C2E89B24F9 /* PreferencesAPI.swift */; }; /* End PBXBuildFile section */ @@ -1017,10 +1027,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0EA09FE5A35CEA0B87F83EF3 /* PreferencesSyncServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSyncServiceTests.swift; sourceTree = ""; }; 0FC8B923A5506F865253B97C /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = ""; }; + 12EDB8401F38342155CBBC91 /* mp3_NO_chapters.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_NO_chapters.mp3; sourceTree = ""; }; 13C407B5BD19D3CF9CC64EC7 /* IntegrationLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryView.swift; sourceTree = ""; }; + 1BA32C89144C68DD64FD0B62 /* mp3_NO_toc_v23.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_NO_toc_v23.mp3; sourceTree = ""; }; 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryViewModelProtocol.swift; sourceTree = ""; }; - A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationImageSizing.swift; sourceTree = ""; }; 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyEmailInputView.swift; sourceTree = ""; }; 35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectedView.swift; sourceTree = ""; }; 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionFormViewModelProtocol.swift; sourceTree = ""; }; @@ -1267,16 +1279,17 @@ 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 = ""; }; 4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHelper.swift; sourceTree = ""; }; + 4BFC5736E667F4BF8CBB9096 /* mp3_NO_toc_v24.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_NO_toc_v24.mp3; sourceTree = ""; }; 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionViewModelProtocol.swift; sourceTree = ""; }; 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationSettingsView.swift; sourceTree = ""; }; 5126F120258E9F18009965DC /* URL+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+BookPlayer.swift"; sourceTree = ""; }; 5C6BC9E7E76B9BAB7A0D88D2 /* SortPreferencesResolving.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SortPreferencesResolving.swift; sourceTree = ""; }; 5CBB29522163A17F00E3A9FF /* ZIPFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZIPFoundation.framework; path = Carthage/Build/iOS/ZIPFoundation.framework; sourceTree = ""; }; + 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayBookIntent.swift; sourceTree = ""; }; 620C73C7275DA00300D495AA /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; 620C73C8275DA00400D495AA /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; 624AB20A2724808600F6A486 /* AVAudioAssetImageDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVAudioAssetImageDataProvider.swift; sourceTree = ""; }; @@ -1296,8 +1309,6 @@ 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 = ""; }; @@ -1319,6 +1330,7 @@ 631908A92E369BDA009249C1 /* ProgressSeekingSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSeekingSectionView.swift; sourceTree = ""; }; 631908AB2E369E31009249C1 /* GlobalSpeedSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSpeedSectionView.swift; sourceTree = ""; }; 631908AD2E369EDC009249C1 /* BoostVolumeSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoostVolumeSectionView.swift; sourceTree = ""; }; + 631908F12E369EDC009249C1 /* StartupPlayerSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupPlayerSectionView.swift; sourceTree = ""; }; 631908AF2E369FFA009249C1 /* AutoSleepTimerSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSleepTimerSectionView.swift; sourceTree = ""; }; 631908B12E36A1AB009249C1 /* SmartRewindSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartRewindSectionView.swift; sourceTree = ""; }; 631908B32E36A26C009249C1 /* SkipIntervalsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkipIntervalsSectionView.swift; sourceTree = ""; }; @@ -1401,8 +1413,6 @@ 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 = ""; }; @@ -1586,7 +1596,6 @@ 63FCBBBD2DF7404000C50035 /* JellyfinLibraryListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinLibraryListItemView.swift; sourceTree = ""; }; 643E169A5F153B754F726AE1 /* IntegrationTagsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationTagsView.swift; sourceTree = ""; }; 6906A54F21720FDF00A9E0B2 /* BookSortServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSortServiceTest.swift; sourceTree = ""; }; - 0EA09FE5A35CEA0B87F83EF3 /* PreferencesSyncServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSyncServiceTests.swift; sourceTree = ""; }; 6906A552217211C600A9E0B2 /* StubFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubFactory.swift; sourceTree = ""; }; 69343D322133844D000C425E /* VoiceOverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceOverService.swift; sourceTree = ""; }; 69343D35213A07B4000C425E /* VoiceOverServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceOverServiceTest.swift; sourceTree = ""; }; @@ -1614,6 +1623,7 @@ 8AE4AAC42CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinConnectionViewModel.swift; sourceTree = ""; }; 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinLibraryItem.swift; sourceTree = ""; }; 8AF18A3A2CF0E92800238F8D /* KeychainServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainServiceMock.swift; sourceTree = ""; }; + 8D3EFD93E6D1E0366A3FD1E9 /* mp3_WITH_toc.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_WITH_toc.mp3; sourceTree = ""; }; 8FBF775DDE66D0AA08818D45 /* LibraryOptionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibraryOptionsView.swift; sourceTree = ""; }; 99329DB62F3AA8F6003F8E73 /* PlayControlsRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayControlsRowView.swift; sourceTree = ""; }; 99329DBA2F3AAA61003F8E73 /* ListeningProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningProgressView.swift; sourceTree = ""; }; @@ -1756,11 +1766,15 @@ 9FF710B82A213084006490E0 /* QueuedSyncTaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTaskRowView.swift; sourceTree = ""; }; 9FF710BC2A215686006490E0 /* QueuedSyncTaskType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTaskType.swift; sourceTree = ""; }; 9FFCC08E289418CA00F4952E /* SimpleChapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleChapter.swift; sourceTree = ""; }; + A11C418B028982CF431C589F /* LibraryServiceReloadChaptersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibraryServiceReloadChaptersTests.swift; sourceTree = ""; }; A3511F737DCDEE7A12B713AE /* IntegrationServerFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerFoundView.swift; sourceTree = ""; }; + A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationImageSizing.swift; sourceTree = ""; }; + A566F222C6660938B3A71638 /* m4b_MALFORMED.m4b */ = {isa = PBXFileReference; includeInIndex = 1; path = m4b_MALFORMED.m4b; 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 = ""; }; - D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaServersView.swift; sourceTree = ""; }; + B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationCustomHeadersSectionView.swift; sourceTree = ""; }; B25063B02760AB32CB8F38E0 /* IntegrationLibraryGridView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryGridView.swift; sourceTree = ""; }; + B33D03D266EF46B5B02FBF40 /* BookEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookEntity.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 = ""; }; C30CD2A0209791FA00258B09 /* UIColor+Sweetercolor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Sweetercolor.swift"; sourceTree = ""; }; @@ -1780,13 +1794,19 @@ C3FA301D20E0024900393DDA /* BPArtworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPArtworkView.swift; sourceTree = ""; }; C3FE3F8120A090880055B9C6 /* limitPanAngle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = limitPanAngle.swift; sourceTree = ""; }; D367F7671FA2A6F000FEDB37 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaServersView.swift; sourceTree = ""; }; D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageRowView.swift; sourceTree = ""; }; D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; + D6F6A0DE6FBE7828D4C7EC9C /* m4b_WELLFORMED.m4b */ = {isa = PBXFileReference; includeInIndex = 1; path = m4b_WELLFORMED.m4b; sourceTree = ""; }; E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationDisconnectedView.swift; sourceTree = ""; }; E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesSyncService.swift; sourceTree = ""; }; E55F466D61AE62A69DE4D371 /* BPNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPNavigation.swift; sourceTree = ""; }; + EC40E520F644AFA46DAD76B0 /* AudioMetadataServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioMetadataServiceTests.swift; sourceTree = ""; }; F1E6918C51C370C2E89B24F9 /* PreferencesAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesAPI.swift; sourceTree = ""; }; + F496A2BF4CBFFC745C453D42 /* ChaptersViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChaptersViewModelTests.swift; sourceTree = ""; }; F6F0D505E060FDAEB6DB68E3 /* IntegrationLibraryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryListView.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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1901,6 +1921,29 @@ path = "Connection Screen"; sourceTree = ""; }; + 2D01F5B723886251F26FB742 /* ChapterFixtures */ = { + isa = PBXGroup; + children = ( + A566F222C6660938B3A71638 /* m4b_MALFORMED.m4b */, + D6F6A0DE6FBE7828D4C7EC9C /* m4b_WELLFORMED.m4b */, + 12EDB8401F38342155CBBC91 /* mp3_NO_chapters.mp3 */, + 1BA32C89144C68DD64FD0B62 /* mp3_NO_toc_v23.mp3 */, + 4BFC5736E667F4BF8CBB9096 /* mp3_NO_toc_v24.mp3 */, + 8D3EFD93E6D1E0366A3FD1E9 /* mp3_WITH_toc.mp3 */, + ); + name = ChapterFixtures; + path = ChapterFixtures; + sourceTree = ""; + }; + 349F568001B5C23CF1DD5AF6 /* Resources */ = { + isa = PBXGroup; + children = ( + 2D01F5B723886251F26FB742 /* ChapterFixtures */, + ); + name = Resources; + path = Resources; + sourceTree = ""; + }; 3F7B64332E0F713200299D97 /* Hardcover */ = { isa = PBXGroup; children = ( @@ -2233,6 +2276,8 @@ FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */, 6279361D272D0CF50097837D /* Coordinators */, 418B6D141D2707F800F974FB /* Info.plist */, + 730EC9788E81A550CC46D98C /* ViewModels */, + 349F568001B5C23CF1DD5AF6 /* Resources */, ); path = BookPlayerTests; sourceTree = ""; @@ -2640,6 +2685,7 @@ 631908B12E36A1AB009249C1 /* SmartRewindSectionView.swift */, 631908AF2E369FFA009249C1 /* AutoSleepTimerSectionView.swift */, 631908AD2E369EDC009249C1 /* BoostVolumeSectionView.swift */, + 631908F12E369EDC009249C1 /* StartupPlayerSectionView.swift */, 631908AB2E369E31009249C1 /* GlobalSpeedSectionView.swift */, 631908A92E369BDA009249C1 /* ProgressSeekingSectionView.swift */, 631908A72E3697DC009249C1 /* ListOptionsSectionView.swift */, @@ -3088,10 +3134,21 @@ 9FC1E4762815F97E00522FA8 /* KeychainServiceTests.swift */, 700DFD61111AC10253D6EBAA /* SyncTasksStorageTests.swift */, 0EA09FE5A35CEA0B87F83EF3 /* PreferencesSyncServiceTests.swift */, + EC40E520F644AFA46DAD76B0 /* AudioMetadataServiceTests.swift */, + A11C418B028982CF431C589F /* LibraryServiceReloadChaptersTests.swift */, ); path = Services; sourceTree = ""; }; + 730EC9788E81A550CC46D98C /* ViewModels */ = { + isa = PBXGroup; + children = ( + F496A2BF4CBFFC745C453D42 /* ChaptersViewModelTests.swift */, + ); + name = ViewModels; + path = ViewModels; + sourceTree = ""; + }; 8A9D0D1A2CCCF36D007A924D /* Library Screen */ = { isa = PBXGroup; children = ( @@ -4116,6 +4173,12 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C63BB893BC572482723FF7CE /* m4b_MALFORMED.m4b in Resources */, + 797E853F1A6F9CE2A713FD8B /* m4b_WELLFORMED.m4b in Resources */, + 2B283D695990DA665D1661CC /* mp3_NO_chapters.mp3 in Resources */, + 6C25A0E1ADBF2325CA78E8A4 /* mp3_NO_toc_v23.mp3 in Resources */, + 4147E8513D57E852C965FC0B /* mp3_NO_toc_v24.mp3 in Resources */, + 6EC082E4BEF0CC2FAA42C0A7 /* mp3_WITH_toc.mp3 in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4539,6 +4602,7 @@ 4151A6DD26E4A13A00E49DBE /* MainCoordinator.swift in Sources */, 634BA54C2C0C21AF0015314D /* SecondOnboardingCoordinator.swift in Sources */, 631908AE2E369EDC009249C1 /* BoostVolumeSectionView.swift in Sources */, + 631908F02E369EDC009249C1 /* StartupPlayerSectionView.swift in Sources */, 63C6C2E62B5029BC00FFE0D8 /* SettingsAutolockView.swift in Sources */, 4124122826D19A8700B099DB /* StorageViewModel.swift in Sources */, 4158387926EB8D8800F4A12B /* LoadingViewController.swift in Sources */, @@ -4799,6 +4863,9 @@ 9F8A9A5E27AC3F8C0093AA1C /* PlayableItemTests.swift in Sources */, 4163E313214AC43000072AA2 /* ImportOperationTests.swift in Sources */, 0CEC6B48350FC9C0A0B7D1C0 /* SyncTasksStorageTests.swift in Sources */, + 1A26F7D72FFD6D625A313FC5 /* AudioMetadataServiceTests.swift in Sources */, + 466E4B7066F10717C8CE7B3E /* LibraryServiceReloadChaptersTests.swift in Sources */, + 59384C1A0B33B9EB42AE4C75 /* ChaptersViewModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5178,7 +5245,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; @@ -5212,7 +5279,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5244,7 +5311,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5280,7 +5347,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; @@ -5321,7 +5388,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5359,7 +5426,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5528,7 +5595,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; @@ -5566,7 +5633,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5602,7 +5669,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5759,7 +5826,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; @@ -5797,7 +5864,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; @@ -6021,7 +6088,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; @@ -6059,7 +6126,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6095,7 +6162,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6134,7 +6201,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; @@ -6174,7 +6241,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6212,7 +6279,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6306,7 +6373,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.21.0; + MARKETING_VERSION = 5.21.1; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index b72a17a88..e9debbb80 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Doubles the volume.\nUse with caution and care for your hearing."; "settings_globalspeed_title" = "Global Speed Control"; "settings_globalspeed_description" = "Set speed across all books."; +"settings_openplayer_launch_title" = "Open Player on Launch"; +"settings_carplay_showplayer_title" = "Show Player on CarPlay Connect"; +"settings_startupplayer_description" = "Open the player for the last played book when launching the app or connecting to CarPlay. On CarPlay, this interrupts audio playing in other apps."; "settings_autolock_title" = "Disable Autolock"; "settings_autolock_description" = "Prevent the device from locking when on the Player screen."; "settings_siri_lastplayed_title" = "Last played book"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ left"; "chapters_title" = "Chapters"; "chapters_item_description" = "Start: %@ - Duration: %@"; +"reload_button" = "Reload"; +"reload_chapters_title" = "Reload Chapters"; +"reparse_chapters_found_title" = "Chapters Reloaded"; +"reparse_chapters_found_description" = "Found %d chapters."; +"reparse_chapters_none_description" = "No additional chapters were found in this file."; +"reparse_chapters_download_description" = "Download this book before reloading its chapters."; "restore_title" = "Restore"; "themes_caps_title" = "THEMES"; "plus_app_icons_title" = "App Icons"; @@ -238,6 +247,7 @@ "gesture_swipe_vertically_title" = "Swipe vertically to create bookmark"; "details_title" = "Details"; "download_title" = "Download"; +"download_incomplete_error" = "The download was incomplete. Please try again."; "cancel_download_title" = "Cancel download"; "remove_downloaded_file_title" = "Remove from device"; "download_from_url_title" = "Download from URL"; diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index fd3b9e1b6..e58dd2243 100644 --- a/BookPlayer/Generated/AutoMockable.generated.swift +++ b/BookPlayer/Generated/AutoMockable.generated.swift @@ -513,6 +513,26 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol { loadChaptersIfNeededRelativePathAssetReceivedInvocations.append((relativePath: relativePath, asset: asset)) await loadChaptersIfNeededRelativePathAssetClosure?(relativePath, asset) } + //MARK: - reloadChapters + + var reloadChaptersRelativePathCallsCount = 0 + var reloadChaptersRelativePathCalled: Bool { + return reloadChaptersRelativePathCallsCount > 0 + } + var reloadChaptersRelativePathReceivedRelativePath: String? + var reloadChaptersRelativePathReceivedInvocations: [String] = [] + var reloadChaptersRelativePathReturnValue: Int? + var reloadChaptersRelativePathClosure: ((String) async -> Int?)? + func reloadChapters(relativePath: String) async -> Int? { + reloadChaptersRelativePathCallsCount += 1 + reloadChaptersRelativePathReceivedRelativePath = relativePath + reloadChaptersRelativePathReceivedInvocations.append(relativePath) + if let reloadChaptersRelativePathClosure = reloadChaptersRelativePathClosure { + return await reloadChaptersRelativePathClosure(relativePath) + } else { + return reloadChaptersRelativePathReturnValue + } + } //MARK: - createFolder var createFolderWithInsideThrowableError: Error? @@ -1398,6 +1418,18 @@ class PlayerManagerProtocolMock: PlayerManagerProtocol { jumpToChapterReceivedInvocations.append(chapter) jumpToChapterClosure?(chapter) } + //MARK: - reloadCurrentItem + + var reloadCurrentItemCallsCount = 0 + var reloadCurrentItemCalled: Bool { + return reloadCurrentItemCallsCount > 0 + } + var reloadCurrentItemClosure: (() -> Void)? + @MainActor + func reloadCurrentItem() { + reloadCurrentItemCallsCount += 1 + reloadCurrentItemClosure?() + } //MARK: - markAsCompleted var markAsCompletedCallsCount = 0 @@ -1687,6 +1719,32 @@ class SyncServiceProtocolMock: SyncServiceProtocol { set(value) { underlyingDownloadErrorPublisher = value } } var underlyingDownloadErrorPublisher: PassthroughSubject<(String, Error), Never>! + //MARK: - updateSyncEnabled + + var updateSyncEnabledCallsCount = 0 + var updateSyncEnabledCalled: Bool { + return updateSyncEnabledCallsCount > 0 + } + var updateSyncEnabledReceivedEnabled: Bool? + var updateSyncEnabledReceivedInvocations: [Bool] = [] + var updateSyncEnabledClosure: ((Bool) -> Void)? + func updateSyncEnabled(_ enabled: Bool) { + updateSyncEnabledCallsCount += 1 + updateSyncEnabledReceivedEnabled = enabled + updateSyncEnabledReceivedInvocations.append(enabled) + updateSyncEnabledClosure?(enabled) + } + //MARK: - logout + + var logoutCallsCount = 0 + var logoutCalled: Bool { + return logoutCallsCount > 0 + } + var logoutClosure: (() async -> Void)? + func logout() async { + logoutCallsCount += 1 + await logoutClosure?() + } //MARK: - queuedJobsCount var queuedJobsCountCallsCount = 0 diff --git a/BookPlayer/Library/ItemList/ItemListView.swift b/BookPlayer/Library/ItemList/ItemListView.swift index a8915abba..143b4c92f 100644 --- a/BookPlayer/Library/ItemList/ItemListView.swift +++ b/BookPlayer/Library/ItemList/ItemListView.swift @@ -83,7 +83,7 @@ struct ItemListView: View { preferencesService.register(folderUuid: uuid) } } - .onReceive(preferencesService.preferencesChanged) { key in + .onReceive(preferencesService.preferencesChanged.receive(on: DispatchQueue.main)) { key in // Server-driven sort change for the location currently on screen: // PreferencesSyncService has already rewritten orderRank in CoreData // via dispatchResort → sortContents. The cached `[SimpleLibraryItem]` diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 9f7179b20..06f808da9 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -134,6 +134,14 @@ struct LibraryRootView: View { func handleLibraryLoaded() async { await loadLastBookIfNeeded() + /// Open the player on launch when enabled and a book is loaded. Checked here, after the load above, + /// so it covers both a plain cold launch (where `loadLastBookIfNeeded` just loaded the last book) + /// and the case where the book was already loaded by another scene (e.g. CarPlay) — which makes the + /// `currentItem == nil` guard inside `loadLastBookIfNeeded` return early. + if UserDefaults.standard.bool(forKey: Constants.UserDefaults.openPlayerOnAppLaunch), + playerManager.currentItem != nil { + playerState.showPlayer = true + } importManager.notifyPendingFiles() showSecondOnboarding() diff --git a/BookPlayer/Library/ItemList/Views/BookView.swift b/BookPlayer/Library/ItemList/Views/BookView.swift index 9eb1004f4..e20ee2203 100644 --- a/BookPlayer/Library/ItemList/Views/BookView.swift +++ b/BookPlayer/Library/ItemList/Views/BookView.swift @@ -86,9 +86,12 @@ struct BookView: View { let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) + let accountService = AccountService() + accountService.setup(dataManager: dataManager) syncService.setup( isActive: true, libraryService: libraryService, + accountService: accountService, dataManager: dataManager ) diff --git a/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift b/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift index 7e8808093..08aadfa8f 100644 --- a/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift +++ b/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift @@ -105,6 +105,18 @@ struct ItemArtworkView: View { ) { _ in downloadState = .notDownloaded } + /// A download/verification failure (e.g. a discarded truncated file) must reset + /// the cell — otherwise, now that completion is gated on verification, it would + /// stay stuck on the progress spinner until the view recomputes from disk. The + /// error payload only carries `relativePath`, so this matches the item directly + /// (a bound-book child error doesn't reset the parent cell — same limitation as + /// the Watch). + .onReceive( + syncService.downloadErrorPublisher + .filter { $0.0 == item.relativePath } + ) { _ in + downloadState = .notDownloaded + } } @ViewBuilder diff --git a/BookPlayer/Library/ItemList/Views/PercentageProgressView.swift b/BookPlayer/Library/ItemList/Views/PercentageProgressView.swift index e069b26c1..92751f0a6 100644 --- a/BookPlayer/Library/ItemList/Views/PercentageProgressView.swift +++ b/BookPlayer/Library/ItemList/Views/PercentageProgressView.swift @@ -20,7 +20,11 @@ struct PercentageProgressView: View { } var body: some View { - Group { + // `progress` can arrive non-finite (e.g. an item whose duration is still + // 0 makes `percentCompleted` 0/0 = NaN). `Int(NaN)` traps at runtime, so + // collapse any non-finite value to 0 and clamp into the expected 0...1. + let progress = self.progress.isFinite ? min(max(self.progress, 0), 1) : 0 + return Group { if progress == 0 { EmptyView() } else if progress == 1 { diff --git a/BookPlayer/MainView.swift b/BookPlayer/MainView.swift index 7f3a87fc0..2e491c2c0 100644 --- a/BookPlayer/MainView.swift +++ b/BookPlayer/MainView.swift @@ -19,7 +19,6 @@ struct MainView: View { @Environment(\.libraryService) private var libraryService @Environment(\.playerState) private var playerState @Environment(\.syncService) private var syncService - @Environment(\.accountService) private var accountService @Environment(\.jellyfinService) private var jellyfinService @Environment(\.audiobookshelfService) private var audiobookshelfService @Environment(\.playbackService) private var playbackService @@ -124,22 +123,14 @@ struct MainView: View { playerState.showPlayer = false } } - .onReceive( - NotificationCenter.default.publisher(for: .accountUpdate, object: nil) - ) { _ in - guard accountService.hasAccount() else { return } - - if accountService.hasSyncEnabled() { - if !syncService.isActive { - syncService.isActive = true - Task { - try? await listSyncRefreshService.syncList(at: nil) - listState.reloadAll() - } - } - } else if syncService.isActive { - syncService.isActive = false - syncService.cancelAllJobs() + // SyncService owns the active/inactive decision (it observes account/subscription + // changes itself). The view only reacts to sync becoming active to refresh the + // library list — it no longer writes `isActive`. + .onChange(of: syncService.isActive) { _, isActive in + guard isActive else { return } + Task { + try? await listSyncRefreshService.syncList(at: nil) + listState.reloadAll() } } } diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index d0fc7722d..e2fd5daa8 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -32,6 +32,8 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { private var timeControlSubscription: AnyCancellable? private var playableChapterSubscription: AnyCancellable? private var isPlayingSubscription: AnyCancellable? + /// Tracks the brief muted play used to claim Now Playing on CarPlay connect, so we can pause once it starts + private var nowPlayingClaimSubscription: AnyCancellable? private var periodicTimeObserver: Any? private var disposeBag = Set() /// Flag determining if it should resume playback after finishing up loading an item @@ -714,6 +716,21 @@ extension PlayerManager { jumpTo(chapter.start + 0.1, recordBookmark: false) } + @MainActor + func reloadCurrentItem() { + // Rebuild the in-memory item from storage so externally-changed data (e.g. re-parsed + // chapters) takes effect. Playback position is preserved — it's persisted in Core Data and + // re-seeded by time in `PlayableItem.init` — and the chapter-change subscription must be + // re-bound to the new instance, otherwise the end-of-chapter sleep timer silently breaks. + guard let relativePath = currentItem?.relativePath, + let libraryItem = libraryService.getSimpleItem(with: relativePath), + let updatedItem = try? playbackService.getPlayableItem(from: libraryItem) else { + return + } + currentItem = updatedItem + bindPlayableChapterSubscription(to: updatedItem, dropInitialReplay: true) + } + func initializeChapterTime(_ time: Double) { guard let currentItem = self.currentItem else { return } @@ -870,6 +887,64 @@ extension PlayerManager { play(autoPlayed: false) } + /// Take over the system Now Playing slot for CarPlay. iOS only designates the Now Playing app from + /// one that *actually plays* (a third-party app can't fake `playbackState` — that's an Apple-private + /// entitlement), so we briefly play **muted**, then pause the instant playback starts — landing on + /// our book paused, now owning Now Playing, with no audible blip. Skips if nothing is loaded or we're + /// already playing (we'd own it). A timeout fallback guarantees we unmute even if playback never starts. + /// + /// Note: this *will* interrupt audio another app is actively playing. There's no reliable way to detect + /// that beforehand here — `isOtherAudioPlaying` / `secondaryAudioShouldBeSilencedHint` only update once + /// our own session is active (the very act we're trying to avoid), and read stale (false) on a CarPlay + /// background launch. So we accept the takeover as a known side effect. + /// + /// Accepted edge: if the user taps play during the brief muted window, our `.first()` captures that + /// `.playing` and pauses it — they'd tap again. Distinguishing their play from ours on the shared + /// player isn't reliable, and the window is short, so we accept it. Returns whether attempted. + @MainActor + @discardableResult + func claimNowPlayingThenPause() -> Bool { + /// `nowPlayingClaimSubscription == nil` gates re-entrancy: a claim stays "in flight" (subscription + /// non-nil) until its unmute completes below, so a second blip can't start mid-claim and get unmuted + /// by this one's timer. + guard currentItem != nil, !isPlaying, nowPlayingClaimSubscription == nil else { return false } + + audioPlayer.isMuted = true + nowPlayingClaimSubscription = timeControlPassthroughPublisher + .filter { $0 == .playing } + .first() + // DispatchQueue (not RunLoop) so the timeout still fires while the run loop is in tracking mode + // (e.g. CarPlay scrolling). + .timeout(.seconds(3), scheduler: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] _ in + guard let self else { return } + /// Pause regardless of how we completed: on success this is the intended pause; on **timeout** + /// it cancels the still-pending `play()` (which re-checks `Task.isCancelled` after the async + /// `prepareForPlayback`), so a slow/streaming load can't end up playing aloud and un-paused. + self.pause() + /// Unmute only after the pause settles — `timeControlStatus` reaches `.paused` asynchronously, + /// so unmuting on the same tick can leak a few ms of audio (same reason `bindPauseObserver` + /// delays). Clear the subscription here (not earlier), so the re-entrancy guard keeps blocking + /// a new claim until we're fully done and this timer can't unmute someone else's blip. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + guard let self else { return } + self.audioPlayer.isMuted = false + self.nowPlayingClaimSubscription = nil + } + }, + receiveValue: { [weak self] _ in + /// We actually became the Now Playing app — re-publish the cover, whose async load can finish + /// before we own the slot (so its push is ignored) and is never re-pushed otherwise. + guard let self, let chapter = self.currentItem?.currentChapter else { return } + self.setNowPlayingArtwork(chapter: chapter) + } + ) + + play() + return true + } + /// 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) { diff --git a/BookPlayer/Player/PlayerManagerProtocol.swift b/BookPlayer/Player/PlayerManagerProtocol.swift index e75b25ad8..7b7643090 100644 --- a/BookPlayer/Player/PlayerManagerProtocol.swift +++ b/BookPlayer/Player/PlayerManagerProtocol.swift @@ -40,6 +40,9 @@ public protocol PlayerManagerProtocol: AnyObject { func directSkip(_ interval: TimeInterval) func jumpTo(_ time: Double, recordBookmark: Bool) func jumpToChapter(_ chapter: PlayableChapter) + /// Rebuild `currentItem` from storage (and re-bind the chapter subscription) so externally + /// changed data — e.g. re-parsed chapters — takes effect. Preserves playback position. + @MainActor func reloadCurrentItem() func markAsCompleted(_ flag: Bool) func setSpeed(_ newValue: Float) func setBoostVolume(_ newValue: Bool) diff --git a/BookPlayer/Player/Views/Chapters/ChaptersView.swift b/BookPlayer/Player/Views/Chapters/ChaptersView.swift index c4dcffa5f..3f6097905 100644 --- a/BookPlayer/Player/Views/Chapters/ChaptersView.swift +++ b/BookPlayer/Player/Views/Chapters/ChaptersView.swift @@ -36,17 +36,45 @@ struct ChaptersView: View { .navigationTitle("chapters_title") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("done_title") { + ToolbarItem(placement: .cancellationAction) { + Button { dismiss() + } label: { + Label("voiceover_close_button", systemImage: "xmark") } .foregroundStyle(theme.linkColor) } + if model.canReloadChapters { + ToolbarItem(placement: .confirmationAction) { + reloadButton + } + } } + .bpAlert($model.currentAlert) } } } + @ViewBuilder + private var reloadButton: some View { + Button { + Task { await model.reloadChapters() } + } label: { + // Keep the title laid out (just hidden) while loading so the spinner overlay doesn't + // change the toolbar item's width. + Text("reload_button") + .foregroundStyle(theme.linkColor) + .opacity(model.isReloadingChapters ? 0 : 1) + .overlay { + if model.isReloadingChapters { + ProgressView() + } + } + } + .disabled(model.isReloadingChapters) + .accessibilityLabel("reload_chapters_title") + } + @ViewBuilder private func rowView(_ chapter: PlayableChapter, index: Int) -> some View { let title = @@ -83,9 +111,12 @@ struct ChaptersView: View { } extension ChaptersView { + @MainActor class Model: ObservableObject { @Published var chapters: [PlayableChapter] @Published var currentChapter: PlayableChapter? + @Published var isReloadingChapters = false + @Published var currentAlert: BPAlertContent? init(chapters: [PlayableChapter], currentChapter: PlayableChapter?) { self.chapters = chapters @@ -93,6 +124,13 @@ extension ChaptersView { } func handleChapterSelected(_ chapter: PlayableChapter) {} + + /// Whether the "re-parse chapters" action applies to the current item. + var canReloadChapters: Bool { false } + + /// Re-parse chapters from the file, replacing the list when more are found, and surface + /// the outcome via `currentAlert`. + func reloadChapters() async {} } } diff --git a/BookPlayer/Player/Views/Chapters/ChaptersViewModel.swift b/BookPlayer/Player/Views/Chapters/ChaptersViewModel.swift index 319b450e3..4aa31b637 100644 --- a/BookPlayer/Player/Views/Chapters/ChaptersViewModel.swift +++ b/BookPlayer/Player/Views/Chapters/ChaptersViewModel.swift @@ -12,9 +12,11 @@ import Foundation final class ChaptersViewModel: ChaptersView.Model { private let playerManager: PlayerManagerProtocol + private let libraryService: LibraryServiceProtocol - init(playerManager: PlayerManagerProtocol) { + init(playerManager: PlayerManagerProtocol, libraryService: LibraryServiceProtocol) { self.playerManager = playerManager + self.libraryService = libraryService super.init( chapters: playerManager.currentItem?.chapters ?? [], currentChapter: playerManager.currentItem?.currentChapter @@ -24,4 +26,48 @@ final class ChaptersViewModel: ChaptersView.Model { override func handleChapterSelected(_ chapter: PlayableChapter) { self.playerManager.jumpToChapter(chapter) } + + /// Re-parsing only applies to single-file books; bound books and folders derive their + /// chapters from constituent files, so there's no embedded chapter track to re-read. + override var canReloadChapters: Bool { + playerManager.currentItem?.isBoundBook == false + } + + @MainActor + override func reloadChapters() async { + guard let currentItem = playerManager.currentItem, currentItem.isBoundBook == false else { + return + } + + let relativePath = currentItem.relativePath + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + // The file must be downloaded first, through the usual library download flow. + currentAlert = Self.infoAlert(message: "reparse_chapters_download_description".localized) + return + } + + isReloadingChapters = true + defer { isReloadingChapters = false } + + guard let newCount = await libraryService.reloadChapters(relativePath: relativePath) else { + currentAlert = Self.infoAlert(message: "reparse_chapters_none_description".localized) + return + } + + // Rebuild the player's item so the new chapters take effect everywhere (scrubber, now + // playing, end-of-chapter sleep timer), then refresh this screen's list from it. + playerManager.reloadCurrentItem() + chapters = playerManager.currentItem?.chapters ?? [] + currentChapter = playerManager.currentItem?.currentChapter + + currentAlert = Self.infoAlert( + title: "reparse_chapters_found_title".localized, + message: String.localizedStringWithFormat("reparse_chapters_found_description".localized, newCount) + ) + } + + private static func infoAlert(title: String? = nil, message: String) -> BPAlertContent { + BPAlertContent(title: title, message: message, style: .alert, actionItems: [.okAction]) + } } diff --git a/BookPlayer/Player/Views/PlayerView.swift b/BookPlayer/Player/Views/PlayerView.swift index 2e36f4b7d..d99da1ca8 100644 --- a/BookPlayer/Player/Views/PlayerView.swift +++ b/BookPlayer/Player/Views/PlayerView.swift @@ -166,7 +166,10 @@ struct PlayerView: View { .environmentObject(theme) case .chapters: ChaptersView{ - ChaptersViewModel(playerManager: viewModel.playerManager) + ChaptersViewModel( + playerManager: viewModel.playerManager, + libraryService: viewModel.libraryService + ) } .environmentObject(theme) case .bookmark: diff --git a/BookPlayer/Profile/Account/AccountTermsConditionsSectionView.swift b/BookPlayer/Profile/Account/AccountTermsConditionsSectionView.swift index b3ef72225..4dbbd1fb3 100644 --- a/BookPlayer/Profile/Account/AccountTermsConditionsSectionView.swift +++ b/BookPlayer/Profile/Account/AccountTermsConditionsSectionView.swift @@ -16,7 +16,7 @@ struct AccountTermsConditionsSectionView: View { var body: some View { ThemedSection { Button { - let url = URL(string: "https://github.com/TortugaPower/BookPlayer/blob/main/TERMS_CONDITIONS.md")! + let url = URL(string: "https://bookplayer.app/terms")! openURL(url) } label: { Label { @@ -30,7 +30,7 @@ struct AccountTermsConditionsSectionView: View { } Button { - let url = URL(string: "https://github.com/TortugaPower/BookPlayer/blob/main/PRIVACY_POLICY.md")! + let url = URL(string: "https://bookplayer.app/privacy")! openURL(url) } label: { Label { diff --git a/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift b/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift index e2e604da6..784b09f86 100644 --- a/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift +++ b/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift @@ -125,7 +125,7 @@ struct CompleteAccountView: View { var disclaimerView: some View { return Text( - "\("agreement_prefix_title".localized) [\("privacy_policy_title".localized)](https://github.com/TortugaPower/BookPlayer/blob/main/PRIVACY_POLICY.md) \("and_title".localized) [\("terms_conditions_title".localized)](https://github.com/TortugaPower/BookPlayer/blob/main/TERMS_CONDITIONS.md)" + "\("agreement_prefix_title".localized) [\("privacy_policy_title".localized)](https://bookplayer.app/privacy) \("and_title".localized) [\("terms_conditions_title".localized)](https://bookplayer.app/terms)" ) .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.center) diff --git a/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift b/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift index c09e5dbc9..9d363608c 100644 --- a/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift +++ b/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift @@ -96,8 +96,10 @@ struct ProfileSyncTasksSectionView: View { let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) + let accountService = AccountService() + accountService.setup(dataManager: dataManager) let syncService = SyncService() - syncService.setup(isActive: true, libraryService: libraryService, dataManager: dataManager) + syncService.setup(isActive: true, libraryService: libraryService, accountService: accountService, dataManager: dataManager) return syncService }() diff --git a/BookPlayer/Services/CarPlayManager.swift b/BookPlayer/Services/CarPlayManager.swift index 690f51ad9..2e6f470b5 100644 --- a/BookPlayer/Services/CarPlayManager.swift +++ b/BookPlayer/Services/CarPlayManager.swift @@ -23,6 +23,10 @@ class CarPlayManager: NSObject { private var disposeBag = Set() /// Reference for updating boost volume title let boostVolumeItem = CPListItem(text: "", detailText: nil) + /// One-shot flag: when CarPlay connects with no loaded book yet, surface the + /// player on the next `.bookReady` (e.g. a resume-on-connect shortcut). Honors + /// the `carPlayShowPlayerOnConnect` setting without auto-loading a book ourselves. + private var shouldShowPlayerOnConnect = false override init() { super.init() @@ -36,15 +40,83 @@ class CarPlayManager: NSObject { func connect(_ interfaceController: CPInterfaceController) { self.interfaceController = interfaceController self.interfaceController?.delegate = self + /// Reset connect-scoped state so a re-connect without a paired disconnect doesn't act on a stale flag + self.shouldShowPlayerOnConnect = false self.setupNowPlayingTemplate() - self.setRootTemplate() + /// On a cold launch, `initializeDataIfNeeded()` runs and rebuilds the root template in its + /// completion — which tears down anything we push now. So when init is pending, defer presenting + /// the player to that rebuild (see `initializeDataIfNeeded`); otherwise present once the root here + /// is committed (pushing synchronously, before CarPlay sets the root, races and gets dropped). + let willInitializeData = WindowHelper.activeWindow == nil + self.setRootTemplate { [weak self] _, _ in + if !willInitializeData { + self?.showPlayerOnConnectIfNeeded() + } + } self.initializeDataIfNeeded() } + /// On connect, jump to Now Playing if a book is already loaded; otherwise arm a one-shot + /// so the next `.bookReady` (e.g. a resume-on-connect shortcut) surfaces it. + @MainActor + private func showPlayerOnConnectIfNeeded() { + guard UserDefaults.standard.bool(forKey: Constants.UserDefaults.carPlayShowPlayerOnConnect) else { + return + } + + if let playerManager = AppServices.shared.coreServices?.playerManager, + playerManager.currentItem != nil { + /// Take over the system Now Playing slot (brief muted play) so the pushed screen shows our book + /// instead of a blank placeholder. + playerManager.claimNowPlayingThenPause() + pushNowPlayingTemplate() + } else { + /// Nothing loaded yet; arm so the next `.bookReady` presents the player, and on a cold (killed) + /// launch — where no main-app window restored the last book — load it so that `.bookReady` fires. + shouldShowPlayerOnConnect = true + loadLastPlayedBookIfAvailable() + } + } + + /// On a cold (killed) CarPlay launch there's no main-app window to run the last-book restore, so + /// `currentItem` is nil. Load the last played book (paused) so `.bookReady` fires and the armed + /// `shouldShowPlayerOnConnect` flag presents the player. Stays on the tabs if there's no last book. + @MainActor + private func loadLastPlayedBookIfAvailable() { + Task { @MainActor in + guard + let coreServices = AppServices.shared.coreServices, + coreServices.playerManager.currentItem == nil, + let lastItem = coreServices.libraryService.getLibraryLastItem() + else { return } + + do { + try await coreServices.playerLoaderService.loadPlayer( + lastItem.relativePath, + autoplay: false, + recordAsLastBook: false + ) + } catch { + /// The preload failed, so `.bookReady` won't fire — disarm so we don't stay armed waiting to + /// present. Stay silent (no alert): this is an automatic preload, and the user gets the proper + /// error if/when they tap the book themselves (same as the main app's cold-launch restore). + shouldShowPlayerOnConnect = false + } + } + } + func disconnect() { self.interfaceController = nil self.recentTemplate = nil self.libraryTemplate = nil + self.shouldShowPlayerOnConnect = false + } + + /// Push the shared Now Playing template, avoiding a duplicate push if it's already on top + @MainActor + private func pushNowPlayingTemplate() { + guard interfaceController?.topTemplate != CPNowPlayingTemplate.shared else { return } + interfaceController?.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil) } @MainActor @@ -56,7 +128,10 @@ class CarPlayManager: NSObject { let dataInitializerCoordinator = DataInitializerCoordinator(alertPresenter: self) dataInitializerCoordinator.onFinish = { [weak self] in - self?.setRootTemplate() + self?.setRootTemplate { [weak self] _, _ in + /// Present the player now that the post-init root template is in place (deferred from connect) + self?.showPlayerOnConnectIfNeeded() + } if let coreServices = AppServices.shared.coreServices { coreServices.watchService.startSession() let listRefreshService = ListSyncRefreshService( @@ -125,6 +200,12 @@ class CarPlayManager: NSObject { self.reloadRecentItems() self.setupNowPlayingTemplate() + + if self.shouldShowPlayerOnConnect { + self.shouldShowPlayerOnConnect = false + AppServices.shared.coreServices?.playerManager.claimNowPlayingThenPause() + self.pushNowPlayingTemplate() + } }) .store(in: &disposeBag) @@ -237,7 +318,7 @@ class CarPlayManager: NSObject { } /// Setup root Tab bar template with the Recent and Library tabs - func setRootTemplate() { + func setRootTemplate(completion: ((Bool, Error?) -> Void)? = nil) { let recentTemplate = CPListTemplate(title: "recent_title".localized, sections: []) self.recentTemplate = recentTemplate recentTemplate.tabTitle = "recent_title".localized @@ -248,7 +329,7 @@ class CarPlayManager: NSObject { libraryTemplate.tabImage = UIImage(systemName: "books.vertical") let tabTemplate = CPTabBarTemplate(templates: [recentTemplate, libraryTemplate]) tabTemplate.delegate = self - self.interfaceController?.setRootTemplate(tabTemplate, animated: false, completion: nil) + self.interfaceController?.setRootTemplate(tabTemplate, animated: false, completion: completion) } /// Reload content for the root library template diff --git a/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift b/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift index 4411915e1..cf2fa1741 100644 --- a/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift +++ b/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift @@ -21,6 +21,7 @@ struct SettingsPlayerControlsView: View { ProgressSeekingSectionView() ListOptionsSectionView() ProgressLabelsSectionView() + StartupPlayerSectionView() } .environmentObject(theme) .scrollContentBackground(.hidden) diff --git a/BookPlayer/Settings/Sections/PlayerControls/StartupPlayerSectionView.swift b/BookPlayer/Settings/Sections/PlayerControls/StartupPlayerSectionView.swift new file mode 100644 index 000000000..97e070f78 --- /dev/null +++ b/BookPlayer/Settings/Sections/PlayerControls/StartupPlayerSectionView.swift @@ -0,0 +1,41 @@ +// +// StartupPlayerSectionView.swift +// BookPlayer +// +// Created by Gianni Carlo on 27/6/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct StartupPlayerSectionView: View { + @AppStorage(Constants.UserDefaults.openPlayerOnAppLaunch) var openPlayerOnAppLaunch: Bool = false + @AppStorage(Constants.UserDefaults.carPlayShowPlayerOnConnect) var carPlayShowPlayerOnConnect: Bool = false + + @EnvironmentObject var theme: ThemeViewModel + + var body: some View { + ThemedSection { + Toggle(isOn: $openPlayerOnAppLaunch) { + Text("settings_openplayer_launch_title") + .bpFont(.body) + } + Toggle(isOn: $carPlayShowPlayerOnConnect) { + Text("settings_carplay_showplayer_title") + .bpFont(.body) + } + } footer: { + Text("settings_startupplayer_description") + .bpFont(.caption) + .foregroundStyle(theme.secondaryColor) + } + } +} + +#Preview { + Form { + StartupPlayerSectionView() + } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Utils/AppServices.swift b/BookPlayer/Utils/AppServices.swift index 465271e63..7f3319b2c 100644 --- a/BookPlayer/Utils/AppServices.swift +++ b/BookPlayer/Utils/AppServices.swift @@ -76,7 +76,11 @@ final class AppServices: BPLogger { let accountService = makeAccountService(dataManager: dataManager) let audioMetadataService = makeAudioMetadataService() let libraryService = makeLibraryService(dataManager: dataManager, audioMetadataService: audioMetadataService) - let syncService = makeSyncService(accountService: accountService, libraryService: libraryService, dataManager: dataManager) + let syncService = makeSyncService( + accountService: accountService, + libraryService: libraryService, + dataManager: dataManager + ) let playbackService = makePlaybackService(libraryService: libraryService) let playerManager = PlayerManager( libraryService: libraryService, @@ -214,15 +218,27 @@ final class AppServices: BPLogger { return AudioMetadataService() } - private func makeLibraryService(dataManager: DataManager, audioMetadataService: AudioMetadataServiceProtocol) -> LibraryService { + private func makeLibraryService( + dataManager: DataManager, + audioMetadataService: AudioMetadataServiceProtocol + ) -> LibraryService { let service = LibraryService() service.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) return service } - private func makeSyncService(accountService: AccountService, libraryService: LibraryService, dataManager: DataManager) -> SyncService { + private func makeSyncService( + accountService: AccountService, + libraryService: LibraryService, + dataManager: DataManager + ) -> SyncService { let service = SyncService() - service.setup(isActive: accountService.hasSyncEnabled(), libraryService: libraryService, dataManager: dataManager) + service.setup( + isActive: accountService.hasSyncEnabled(), + libraryService: libraryService, + accountService: accountService, + dataManager: dataManager + ) return service } diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 9ab60be25..2474643cc 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "ضاعف مستوى الصوت.\nاستخدم بحذر وعناية."; "settings_globalspeed_title" = "التحكم بالسرعة الافتراضية لجميع الكتب"; "settings_globalspeed_description" = "تعيين السرعة على جميع الكتب."; +"settings_openplayer_launch_title" = "فتح المشغل عند بدء التشغيل"; +"settings_carplay_showplayer_title" = "عرض المشغل عند الاتصال بـ CarPlay"; +"settings_startupplayer_description" = "فتح المشغل لآخر كتاب تم تشغيله عند فتح التطبيق أو الاتصال بـ CarPlay. في CarPlay، يؤدي هذا إلى إيقاف الصوت قيد التشغيل في التطبيقات الأخرى."; "settings_autolock_title" = "تعطيل القفل التلقائي"; "settings_autolock_description" = "منع قفل الجهاز عند فتح شاشة المشغل"; "settings_siri_lastplayed_title" = "آخر كتاب تم تشغيله"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "متبقّي %@"; "chapters_title" = "الفصول"; "chapters_item_description" = "البداية: %@ - المدة: %@"; +"reload_button" = "إعادة التحميل"; +"reload_chapters_title" = "إعادة تحميل الفصول"; +"reparse_chapters_found_title" = "تمت إعادة تحميل الفصول"; +"reparse_chapters_found_description" = "تم العثور على %d فصلًا."; +"reparse_chapters_none_description" = "لم يتم العثور على فصول إضافية في هذا الملف."; +"reparse_chapters_download_description" = "قم بتحميل هذا الكتاب قبل إعادة تحميل فصوله."; "restore_title" = "استعادة"; "themes_caps_title" = "التنسيقات"; "plus_app_icons_title" = "أيقونات التطبيق"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "اسحب عموديًا لإنشاء إشارة مرجعية"; "details_title" = "تفاصيل"; "download_title" = "تحميل"; +"download_incomplete_error" = "لم يكتمل التنزيل. يُرجى المحاولة مرة أخرى."; "cancel_download_title" = "إلغاء التنزيل"; "remove_downloaded_file_title" = "إزالة من الجهاز"; "download_from_url_title" = "تنزيل من URL"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 9ddc6915d..259a189a8 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Dobla el volum.\nFeu servir amb precaució i cura de la vostra audició."; "settings_globalspeed_title" = "Control global de velocitat"; "settings_globalspeed_description" = "Estableix velocitat en tots els llibres."; +"settings_openplayer_launch_title" = "Obre el reproductor en iniciar"; +"settings_carplay_showplayer_title" = "Mostra el reproductor en connectar CarPlay"; +"settings_startupplayer_description" = "Obre el reproductor amb l'últim llibre escoltat en iniciar l'aplicació o connectar-te a CarPlay. A CarPlay, això interromp l'àudio que s'estigui reproduint en altres aplicacions."; "settings_autolock_title" = "Desactiva el bloqueig automàtic"; "settings_autolock_description" = "Eviteu que el dispositiu es bloquegi quan estigui a la pantalla del reproductor."; "settings_siri_lastplayed_title" = "Últim llibre reproduït"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ restant"; "chapters_title" = "Capítols"; "chapters_item_description" = "Inici: %@ - Durada: %@"; +"reload_button" = "Torna a carregar"; +"reload_chapters_title" = "Torna a carregar els capítols"; +"reparse_chapters_found_title" = "Capítols tornats a carregar"; +"reparse_chapters_found_description" = "S'han trobat %d capítols."; +"reparse_chapters_none_description" = "No s'ha trobat cap capítol addicional en aquest fitxer."; +"reparse_chapters_download_description" = "Descarrega aquest llibre abans de tornar a carregar-ne els capítols."; "restore_title" = "Restaura"; "themes_caps_title" = "TEMES"; "plus_app_icons_title" = "Icones de l'aplicació"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Llisca verticalment per crear un marcador"; "details_title" = "Detalls"; "download_title" = "Descarrega"; +"download_incomplete_error" = "La baixada no s'ha completat. Torneu-ho a provar."; "cancel_download_title" = "Cancel·la la descàrrega"; "remove_downloaded_file_title" = "Suprimeix del dispositiu"; "download_from_url_title" = "Descarrega des de l'URL"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index bb6dc6339..434b548a7 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Zdvojnásobí hlasitost přehrávání.\nPoužívejte opatrně a chraňete svůj sluch."; "settings_globalspeed_title" = "Nastavení rychlosti přehrávání"; "settings_globalspeed_description" = "Nastavit rychlost přehrávání pro všechny audioknihy."; +"settings_openplayer_launch_title" = "Otevřít přehrávač při spuštění"; +"settings_carplay_showplayer_title" = "Zobrazit přehrávač při připojení CarPlay"; +"settings_startupplayer_description" = "Otevřít přehrávač s naposledy přehrávanou knihou při spuštění aplikace nebo připojení k CarPlay. V CarPlay to přeruší zvuk přehrávaný v jiných aplikacích."; "settings_autolock_title" = "Zakázat automatické uzamčení"; "settings_autolock_description" = "Zabránit uzamčení zařízení při zobrazení Přehrávače."; "settings_siri_lastplayed_title" = "Naposledy přehrávaná audiokniha"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "Zbývá %@"; "chapters_title" = "Kapitoly"; "chapters_item_description" = "Začátek: %@ - Doba trvání: %@"; +"reload_button" = "Načíst znovu"; +"reload_chapters_title" = "Znovu načíst kapitoly"; +"reparse_chapters_found_title" = "Kapitoly znovu načteny"; +"reparse_chapters_found_description" = "Nalezeno %d kapitol."; +"reparse_chapters_none_description" = "V tomto souboru nebyly nalezeny žádné další kapitoly."; +"reparse_chapters_download_description" = "Před opětovným načtením kapitol nejprve stáhněte tuto knihu."; "restore_title" = "Obnovit"; "themes_caps_title" = "TÉMATA"; "plus_app_icons_title" = "Ikony aplikací"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Záložku vytvoříte přejetím prstem svisle"; "details_title" = "Podrobnosti"; "download_title" = "Stažení"; +"download_incomplete_error" = "Stahování nebylo dokončeno. Zkuste to prosím znovu."; "cancel_download_title" = "Zrušit stahování"; "remove_downloaded_file_title" = "Odebrat ze zařízení"; "download_from_url_title" = "Stáhnout z URL"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 04c1c2c89..4f15542f6 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Fordobler lydstyrken. \nAnvend med forsigtighed og omtanke for din hørelse."; "settings_globalspeed_title" = "Global hastighedskontrol"; "settings_globalspeed_description" = "Indstil hastighed på tværs af alle bøger."; +"settings_openplayer_launch_title" = "Åbn afspilleren ved start"; +"settings_carplay_showplayer_title" = "Vis afspilleren ved CarPlay-forbindelse"; +"settings_startupplayer_description" = "Åbn afspilleren med den senest afspillede bog, når appen startes, eller der oprettes forbindelse til CarPlay. I CarPlay afbryder dette lyd, der afspilles i andre apps."; "settings_autolock_title" = "Deaktiver autolås"; "settings_autolock_description" = "Undgå at enheden låser når afspilleren er i brug."; "settings_siri_lastplayed_title" = "Senest afspillede bog"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ tilbage"; "chapters_title" = "Kapitler"; "chapters_item_description" = "Start: %@ - Varighed: %@"; +"reload_button" = "Genindlæs"; +"reload_chapters_title" = "Genindlæs kapitler"; +"reparse_chapters_found_title" = "Kapitler genindlæst"; +"reparse_chapters_found_description" = "Fandt %d kapitler."; +"reparse_chapters_none_description" = "Der blev ikke fundet yderligere kapitler i denne fil."; +"reparse_chapters_download_description" = "Hent denne bog, før du genindlæser dens kapitler."; "restore_title" = "Gendan"; "themes_caps_title" = "TEMAER"; "plus_app_icons_title" = "App-ikoner"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Stryg lodret for at oprette et bogmærke"; "details_title" = "detaljer"; "download_title" = "Hent"; +"download_incomplete_error" = "Overførslen blev ikke fuldført. Prøv igen."; "cancel_download_title" = "Annuller download"; "remove_downloaded_file_title" = "Fjern fra enheden"; "download_from_url_title" = "Download fra URL"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index 29d3a6b5a..61419022f 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Verdoppelt die Lautstärke. Bitte nur mit Vorsicht verwenden. Dein Gehör ist unersetzlich!"; "settings_globalspeed_title" = "Abspielgeschwindigkeit übergreifend festlegen"; "settings_globalspeed_description" = "Jedes Buch wird mit der gleichen Geschwindigkeit abgespielt."; +"settings_openplayer_launch_title" = "Player beim Start öffnen"; +"settings_carplay_showplayer_title" = "Player bei CarPlay-Verbindung anzeigen"; +"settings_startupplayer_description" = "Öffnet den Player mit dem zuletzt gehörten Buch, wenn die App gestartet oder eine Verbindung zu CarPlay hergestellt wird. Bei CarPlay unterbricht dies die Audiowiedergabe anderer Apps."; "settings_autolock_title" = "Bildschirmsperre verhindern"; "settings_autolock_description" = "Verhindern, dass das Gerät gesperrt wird, wenn der Wiedergabebildschirm aktiv ist."; "settings_siri_lastplayed_title" = "Zuletzt gespieltes Buch fortsetzen"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ übrig"; "chapters_title" = "Kapitel"; "chapters_item_description" = "Beginn: %@ – Dauer: %@"; +"reload_button" = "Neu laden"; +"reload_chapters_title" = "Kapitel neu laden"; +"reparse_chapters_found_title" = "Kapitel neu geladen"; +"reparse_chapters_found_description" = "%d Kapitel gefunden."; +"reparse_chapters_none_description" = "In dieser Datei wurden keine weiteren Kapitel gefunden."; +"reparse_chapters_download_description" = "Lade dieses Buch herunter, bevor du seine Kapitel neu lädst."; "restore_title" = "Wiederherstellen"; "themes_caps_title" = "DESIGNS"; "plus_app_icons_title" = "App Icons"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Streiche vertikal, um ein Lesezeichen zu erstellen"; "details_title" = "Einzelheiten"; "download_title" = "Herunterladen"; +"download_incomplete_error" = "Der Download wurde nicht vollständig abgeschlossen. Bitte versuche es erneut."; "cancel_download_title" = "Download abbrechen"; "remove_downloaded_file_title" = "Vom Gerät entfernen"; "download_from_url_title" = "Von URL herunterladen"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 9e64993cd..b032f8bc6 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Διπλασιάζει την ένταση.\nΧρησιμοποιήστε με προσοχή και φροντίστε την ακοή σας."; "settings_globalspeed_title" = "Παγκόσμιος έλεγχος ταχύτητας"; "settings_globalspeed_description" = "Ρυθμίστε την ταχύτητα σε όλα τα βιβλία."; +"settings_openplayer_launch_title" = "Άνοιγμα αναπαραγωγής κατά την εκκίνηση"; +"settings_carplay_showplayer_title" = "Εμφάνιση αναπαραγωγής κατά τη σύνδεση με CarPlay"; +"settings_startupplayer_description" = "Ανοίξτε την αναπαραγωγή με το τελευταίο βιβλίο που ακούσατε κατά την εκκίνηση της εφαρμογής ή τη σύνδεση με το CarPlay. Στο CarPlay, αυτό διακόπτει τον ήχο που αναπαράγεται σε άλλες εφαρμογές."; "settings_autolock_title" = "Απενεργοποιήστε το αυτόματο κλείδωμα"; "settings_autolock_description" = "Αποτρέψτε το κλείδωμα της συσκευής όταν βρίσκεται στην οθόνη του προγράμματος αναπαραγωγής."; "settings_siri_lastplayed_title" = "Το βιβλίο που παίχτηκε τελευταία"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "Απομένουν %@"; "chapters_title" = "Κεφάλαια"; "chapters_item_description" = "Έναρξη: %@ - Διάρκεια: %@"; +"reload_button" = "Επαναφόρτωση"; +"reload_chapters_title" = "Επαναφόρτωση κεφαλαίων"; +"reparse_chapters_found_title" = "Τα κεφάλαια επαναφορτώθηκαν"; +"reparse_chapters_found_description" = "Βρέθηκαν %d κεφάλαια."; +"reparse_chapters_none_description" = "Δεν βρέθηκαν επιπλέον κεφάλαια σε αυτό το αρχείο."; +"reparse_chapters_download_description" = "Κατεβάστε αυτό το βιβλίο πριν επαναφορτώσετε τα κεφάλαιά του."; "restore_title" = "Επαναφέρω"; "themes_caps_title" = "ΘΕΜΑΤΑ"; "plus_app_icons_title" = "Εικονίδια εφαρμογών"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Σύρετε κάθετα για να δημιουργήσετε σελιδοδείκτη"; "details_title" = "Λεπτομέριες"; "download_title" = "Κατεβάστε"; +"download_incomplete_error" = "Η λήψη δεν ολοκληρώθηκε. Δοκιμάστε ξανά."; "cancel_download_title" = "Ακύρωση λήψης"; "remove_downloaded_file_title" = "Αφαιρέστε από τη συσκευή"; "download_from_url_title" = "Λήψη από τη διεύθυνση URL"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index f5ee30f3a..8cddf428d 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Doubles the volume.\nUse with caution and care for your hearing."; "settings_globalspeed_title" = "Global Speed Control"; "settings_globalspeed_description" = "Set speed across all books."; +"settings_openplayer_launch_title" = "Open Player on Launch"; +"settings_carplay_showplayer_title" = "Show Player on CarPlay Connect"; +"settings_startupplayer_description" = "Open the player for the last played book when launching the app or connecting to CarPlay. On CarPlay, this interrupts audio playing in other apps."; "settings_autolock_title" = "Disable Autolock"; "settings_autolock_description" = "Prevent the device from locking when on the Player screen."; "settings_siri_lastplayed_title" = "Last played book"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ left"; "chapters_title" = "Chapters"; "chapters_item_description" = "Start: %@ - Duration: %@"; +"reload_button" = "Reload"; +"reload_chapters_title" = "Reload Chapters"; +"reparse_chapters_found_title" = "Chapters Reloaded"; +"reparse_chapters_found_description" = "Found %d chapters."; +"reparse_chapters_none_description" = "No additional chapters were found in this file."; +"reparse_chapters_download_description" = "Download this book before reloading its chapters."; "restore_title" = "Restore"; "themes_caps_title" = "THEMES"; "plus_app_icons_title" = "App Icons"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Swipe vertically to create bookmark"; "details_title" = "Details"; "download_title" = "Download"; +"download_incomplete_error" = "The download was incomplete. Please try again."; "cancel_download_title" = "Cancel download"; "remove_downloaded_file_title" = "Remove from device"; "download_from_url_title" = "Download from URL"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 8232a0f7e..53d6317df 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Dobla el volumen.\nUsar con precaución y cuida de tu oído."; "settings_globalspeed_title" = "Velocidad Global"; "settings_globalspeed_description" = "Establece la velocidad para todos los libros."; +"settings_openplayer_launch_title" = "Abrir el reproductor al iniciar"; +"settings_carplay_showplayer_title" = "Mostrar el reproductor al conectar CarPlay"; +"settings_startupplayer_description" = "Abre el reproductor con el último libro reproducido al iniciar la aplicación o conectarte a CarPlay. En CarPlay, esto interrumpe el audio que se esté reproduciendo en otras aplicaciones."; "settings_autolock_title" = "Desactivar el bloqueo automático"; "settings_autolock_description" = "Evita que el dispositivo se bloquee cuando está en la pantalla del Reproductor."; "settings_siri_lastplayed_title" = "Último libro escuchado"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ restante"; "chapters_title" = "Capítulos"; "chapters_item_description" = "Inicio: %@ - Duración: %@"; +"reload_button" = "Recargar"; +"reload_chapters_title" = "Recargar capítulos"; +"reparse_chapters_found_title" = "Capítulos recargados"; +"reparse_chapters_found_description" = "Se encontraron %d capítulos."; +"reparse_chapters_none_description" = "No se encontraron capítulos adicionales en este archivo."; +"reparse_chapters_download_description" = "Descarga este libro antes de recargar sus capítulos."; "restore_title" = "Restaurar"; "themes_caps_title" = "TEMAS"; "plus_app_icons_title" = "Iconos de la Aplicación"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Desliza verticalmente para crear un marcador"; "details_title" = "Detalles"; "download_title" = "Descargar"; +"download_incomplete_error" = "La descarga no se completó. Inténtalo de nuevo."; "cancel_download_title" = "Cancelar descarga"; "remove_downloaded_file_title" = "Eliminar del dispositivo"; "download_from_url_title" = "Descargar desde URL"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index a68bb4f57..92c3207a9 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Kaksinkertaistaa äänenvoimakkuuden.\nKäytä varoen ja huolehdi kuulostasi."; "settings_globalspeed_title" = "Yleinen nopeudenhallinta"; "settings_globalspeed_description" = "Aseta nopeus kaikille kirjoille."; +"settings_openplayer_launch_title" = "Avaa soitin käynnistettäessä"; +"settings_carplay_showplayer_title" = "Näytä soitin CarPlay-yhteyden yhteydessä"; +"settings_startupplayer_description" = "Avaa soittimen viimeksi kuunnellulla kirjalla, kun sovellus käynnistetään tai yhdistetään CarPlayhin. CarPlayssa tämä keskeyttää muissa sovelluksissa toistettavan äänen."; "settings_autolock_title" = "Poista automaattinen lukitus käytöstä"; "settings_autolock_description" = "Estä laitteen lukittuminen Soitin-näyssä"; "settings_siri_lastplayed_title" = "Viimeksi kuunneltu kirja"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ jäljellä"; "chapters_title" = "Kappaleet"; "chapters_item_description" = "Aloitus: %@ - Kesto: %@"; +"reload_button" = "Lataa uudelleen"; +"reload_chapters_title" = "Lataa kappaleet uudelleen"; +"reparse_chapters_found_title" = "Kappaleet ladattu uudelleen"; +"reparse_chapters_found_description" = "Löytyi %d kappaletta."; +"reparse_chapters_none_description" = "Tästä tiedostosta ei löytynyt lisää kappaleita."; +"reparse_chapters_download_description" = "Lataa tämä kirja ennen kuin lataat sen kappaleet uudelleen."; "restore_title" = "Palauta"; "themes_caps_title" = "TEEMAT"; "plus_app_icons_title" = "Appikuvakkeet"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Luo kirjanmerkki pyyhkäisemällä pystysuunnassa"; "details_title" = "Yksityiskohdat"; "download_title" = "ladata"; +"download_incomplete_error" = "Lataus jäi kesken. Yritä uudelleen."; "cancel_download_title" = "Peruuta lataus"; "remove_downloaded_file_title" = "Poista laitteesta"; "download_from_url_title" = "Lataa osoitteesta URL"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index dda5e0da9..12e2099f2 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Double le volume.\nUtilisez avec prudence et faites attention à votre audition."; "settings_globalspeed_title" = "Contrôle de la vitesse globale"; "settings_globalspeed_description" = "Applique la vitesse sur tous les livres."; +"settings_openplayer_launch_title" = "Ouvrir le lecteur au démarrage"; +"settings_carplay_showplayer_title" = "Afficher le lecteur lors de la connexion à CarPlay"; +"settings_startupplayer_description" = "Ouvre le lecteur avec le dernier livre écouté au lancement de l'application ou lors de la connexion à CarPlay. Sur CarPlay, cela interrompt l'audio en cours de lecture dans d'autres applications."; "settings_autolock_title" = "Désactiver le verrouillage auto."; "settings_autolock_description" = "Empêche l'appareil de se verrouiller en étant sur l'écran du Lecteur."; "settings_siri_lastplayed_title" = "Dernier livre lu"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ restant"; "chapters_title" = "Chapitres"; "chapters_item_description" = "Début : %@ - Durée : %@"; +"reload_button" = "Recharger"; +"reload_chapters_title" = "Recharger les chapitres"; +"reparse_chapters_found_title" = "Chapitres rechargés"; +"reparse_chapters_found_description" = "%d chapitres trouvés."; +"reparse_chapters_none_description" = "Aucun chapitre supplémentaire n’a été trouvé dans ce fichier."; +"reparse_chapters_download_description" = "Téléchargez ce livre avant de recharger ses chapitres."; "restore_title" = "Restaurer"; "themes_caps_title" = "THÈMES"; "plus_app_icons_title" = "Icônes d'application"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Faites glisser verticalement pour créer un signet"; "details_title" = "Détails"; "download_title" = "Télécharger"; +"download_incomplete_error" = "Le téléchargement n'a pas abouti. Veuillez réessayer."; "cancel_download_title" = "Annuler le téléchargement"; "remove_downloaded_file_title" = "Supprimer de l’appareil"; "download_from_url_title" = "Télécharger à partir de l'URL"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 4cc441baa..c2883fc73 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Megduplázza a hangerőt.\nÓvatosan és körültekintően használja hallásának védelme érdekében"; "settings_globalspeed_title" = "Globális sebességszabályozás"; "settings_globalspeed_description" = "Sebesség beállítása az összes könyvben"; +"settings_openplayer_launch_title" = "Lejátszó megnyitása indításkor"; +"settings_carplay_showplayer_title" = "Lejátszó megjelenítése CarPlay csatlakozáskor"; +"settings_startupplayer_description" = "Megnyitja a lejátszót az utoljára hallgatott könyvvel az alkalmazás indításakor vagy a CarPlay csatlakoztatásakor. CarPlay esetén ez megszakítja a más alkalmazásokban lejátszott hangot."; "settings_autolock_title" = "Automatikus zárolás tiltása"; "settings_autolock_description" = "A készülék a lejátszó képernyőn nem záródik le"; "settings_siri_lastplayed_title" = "A legutóbb lejátszott könyv"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ van hátra"; "chapters_title" = "Fejezetek"; "chapters_item_description" = "Kezdés: %@ - Hossz: %@"; +"reload_button" = "Újratöltés"; +"reload_chapters_title" = "Fejezetek újratöltése"; +"reparse_chapters_found_title" = "Fejezetek újratöltve"; +"reparse_chapters_found_description" = "%d fejezet található."; +"reparse_chapters_none_description" = "Nem található további fejezet ebben a fájlban."; +"reparse_chapters_download_description" = "Töltsd le ezt a könyvet, mielőtt újratöltöd a fejezeteit."; "restore_title" = "Visszaállítás"; "themes_caps_title" = "TÉMÁK"; "plus_app_icons_title" = "App ikonok"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Függőleges csúsztatással hozzon létre könyvjelzőt"; "details_title" = "Részletek"; "download_title" = "Letöltés"; +"download_incomplete_error" = "A letöltés nem fejeződött be. Próbáld újra."; "cancel_download_title" = "Letöltés megszakítása"; "remove_downloaded_file_title" = "Távolítsa el az eszközről"; "download_from_url_title" = "Letöltés URL-ről"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index c18701ee7..38dbf5c25 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Raddoppia il volume. \nUsa con cautela e fai attenzione al tuo udito."; "settings_globalspeed_title" = "Controllo globale della velocità "; "settings_globalspeed_description" = "Imposta velocità per tutti i libri."; +"settings_openplayer_launch_title" = "Apri il player all'avvio"; +"settings_carplay_showplayer_title" = "Mostra il player alla connessione CarPlay"; +"settings_startupplayer_description" = "Apre il player con l'ultimo libro ascoltato all'avvio dell'app o alla connessione a CarPlay. Su CarPlay, questo interrompe l'audio in riproduzione in altre app."; "settings_autolock_title" = "Disattiva il blocco automatico"; "settings_autolock_description" = "Impedisci il blocco del dispositivo quando ci si trova sulla schermata del lettore."; "settings_siri_lastplayed_title" = "Ultimo libro riprodotto"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ rimanenti"; "chapters_title" = "Capitoli"; "chapters_item_description" = "Inizio: %@ - Durata: %@"; +"reload_button" = "Ricarica"; +"reload_chapters_title" = "Ricarica capitoli"; +"reparse_chapters_found_title" = "Capitoli ricaricati"; +"reparse_chapters_found_description" = "Trovati %d capitoli."; +"reparse_chapters_none_description" = "Nessun capitolo aggiuntivo trovato in questo file."; +"reparse_chapters_download_description" = "Scarica questo libro prima di ricaricarne i capitoli."; "restore_title" = "Ripristina"; "themes_caps_title" = "TEMI"; "plus_app_icons_title" = "Icone dell'App"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Scorri verticalmente per creare un segnalibro"; "details_title" = "Dettagli"; "download_title" = "Scaricamento"; +"download_incomplete_error" = "Il download non è stato completato. Riprova."; "cancel_download_title" = "Annulla il download"; "remove_downloaded_file_title" = "Rimuovi dal dispositivo"; "download_from_url_title" = "Scarica dall'URL"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index 86d470319..a87f59ac0 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "音量を2倍にします。 \n聴覚に配慮し、注意して使用してください。"; "settings_globalspeed_title" = "再生速度の共通設定"; "settings_globalspeed_description" = "すべてのブックに速度を設定します。"; +"settings_openplayer_launch_title" = "起動時にプレーヤーを開く"; +"settings_carplay_showplayer_title" = "CarPlay接続時にプレーヤーを表示"; +"settings_startupplayer_description" = "アプリの起動時やCarPlayへの接続時に、最後に再生したブックのプレーヤーを開きます。CarPlayでは、他のアプリで再生中のオーディオを中断します。"; "settings_autolock_title" = "自動ロックを無効にする"; "settings_autolock_description" = "プレーヤー画面上でデバイスがロックされないようにします。"; "settings_siri_lastplayed_title" = "前回再生していたブック"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "残り%@"; "chapters_title" = "チャプタ"; "chapters_item_description" = "開始: %@ - 長さ: %@"; +"reload_button" = "再読み込み"; +"reload_chapters_title" = "チャプタを再読み込み"; +"reparse_chapters_found_title" = "チャプタを再読み込みしました"; +"reparse_chapters_found_description" = "%d 件のチャプタが見つかりました。"; +"reparse_chapters_none_description" = "このファイルから追加のチャプタは見つかりませんでした。"; +"reparse_chapters_download_description" = "チャプタを再読み込みする前に、この本をダウンロードしてください。"; "restore_title" = "復元"; "themes_caps_title" = "テーマ"; "plus_app_icons_title" = "アプリアイコン"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "縦にスワイプしてブックマークを作成"; "details_title" = "詳細"; "download_title" = "ダウンロード"; +"download_incomplete_error" = "ダウンロードが完了しませんでした。もう一度お試しください。"; "cancel_download_title" = "ダウンロードをキャンセル"; "remove_downloaded_file_title" = "デバイスから削除"; "download_from_url_title" = "URLを指定してダウンロード"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index a6d0d4fba..cb9d1ecef 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Dobler volumet.\nBruk med forsiktighet og ta vare på hørselen din."; "settings_globalspeed_title" = "Global hastighetskontroll"; "settings_globalspeed_description" = "Angi hastighet for alle bøker."; +"settings_openplayer_launch_title" = "Åpne spilleren ved oppstart"; +"settings_carplay_showplayer_title" = "Vis spilleren ved CarPlay-tilkobling"; +"settings_startupplayer_description" = "Åpne spilleren med den sist spilte boken når appen startes eller kobles til CarPlay. På CarPlay avbryter dette lyd som spilles av i andre apper."; "settings_autolock_title" = "Deaktiver autolås"; "settings_autolock_description" = "Hindre at enheten låses når den viser spillerskjermen."; "settings_siri_lastplayed_title" = "Sist spilte bok"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ igjen"; "chapters_title" = "Kapitler"; "chapters_item_description" = "Start: %@ - Varighet: %@"; +"reload_button" = "Last inn på nytt"; +"reload_chapters_title" = "Last inn kapitler på nytt"; +"reparse_chapters_found_title" = "Kapitler lastet inn på nytt"; +"reparse_chapters_found_description" = "Fant %d kapitler."; +"reparse_chapters_none_description" = "Ingen flere kapitler ble funnet i denne filen."; +"reparse_chapters_download_description" = "Last ned denne boken før du laster inn kapitlene på nytt."; "restore_title" = "Gjenopprett"; "themes_caps_title" = "Temaer"; "plus_app_icons_title" = "App ikoner"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Sveip vertikalt for å opprette bokmerke"; "details_title" = "Detaljer"; "download_title" = "Last ned"; +"download_incomplete_error" = "Nedlastingen ble ikke fullført. Prøv igjen."; "cancel_download_title" = "Avbryt nedlasting"; "remove_downloaded_file_title" = "Fjern fra enhet"; "download_from_url_title" = "Last ned fra URL"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index a54b740dd..5d727c0c8 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Verdubbelt het volume. \n Wees voorzichtig en zorg voor uw gehoor."; "settings_globalspeed_title" = "Globale snelheidsregeling"; "settings_globalspeed_description" = "Stel snelheid in voor alle boeken."; +"settings_openplayer_launch_title" = "Speler openen bij opstarten"; +"settings_carplay_showplayer_title" = "Speler tonen bij CarPlay-verbinding"; +"settings_startupplayer_description" = "Open de speler met het laatst afgespeelde boek bij het starten van de app of het verbinden met CarPlay. Op CarPlay onderbreekt dit audio die in andere apps wordt afgespeeld."; "settings_autolock_title" = "Autolock uitschakelen"; "settings_autolock_description" = "Voorkom dat het apparaat vergrendelt wanneer het zich op het spelerscherm bevindt."; "settings_siri_lastplayed_title" = "Laatst afgespeelde boek"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "Nog %@"; "chapters_title" = "Hoofdstukken"; "chapters_item_description" = "Start: %@ - Duur: %@"; +"reload_button" = "Herladen"; +"reload_chapters_title" = "Hoofdstukken herladen"; +"reparse_chapters_found_title" = "Hoofdstukken herladen"; +"reparse_chapters_found_description" = "%d hoofdstukken gevonden."; +"reparse_chapters_none_description" = "Er zijn geen extra hoofdstukken gevonden in dit bestand."; +"reparse_chapters_download_description" = "Download dit boek voordat je de hoofdstukken herlaadt."; "restore_title" = "Herstellen"; "themes_caps_title" = "THEMA'S"; "plus_app_icons_title" = "App-pictogrammen"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Veeg verticaal om een bladwijzer te maken"; "details_title" = "Details"; "download_title" = "Downloaden"; +"download_incomplete_error" = "De download is niet voltooid. Probeer het opnieuw."; "cancel_download_title" = "Download annuleren"; "remove_downloaded_file_title" = "Verwijderen van apparaat"; "download_from_url_title" = "Downloaden van URL"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 6d027493d..de08ef8af 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Podwaja głośność. Używaj z rozwagą i troską o swój słuch."; "settings_globalspeed_title" = "Globalna Kontrola Prędkości"; "settings_globalspeed_description" = "Ustaw prędkość dla wszystkich książek."; +"settings_openplayer_launch_title" = "Otwórz odtwarzacz przy uruchomieniu"; +"settings_carplay_showplayer_title" = "Pokaż odtwarzacz po połączeniu z CarPlay"; +"settings_startupplayer_description" = "Otwórz odtwarzacz z ostatnio odtwarzaną książką po uruchomieniu aplikacji lub połączeniu z CarPlay. W CarPlay przerywa to dźwięk odtwarzany w innych aplikacjach."; "settings_autolock_title" = "Wyłącz Autolock"; "settings_autolock_description" = "Zapobiegaj blokowaniu urządzenia na ekranie odtwarzacza."; "settings_siri_lastplayed_title" = "Ostatnio odtwarzana książka"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "Pozostało %@"; "chapters_title" = "Rozdziały"; "chapters_item_description" = "Start: %@ - Czas trwania: %@"; +"reload_button" = "Odśwież"; +"reload_chapters_title" = "Odśwież rozdziały"; +"reparse_chapters_found_title" = "Rozdziały odświeżone"; +"reparse_chapters_found_description" = "Znaleziono %d rozdziałów."; +"reparse_chapters_none_description" = "Nie znaleziono dodatkowych rozdziałów w tym pliku."; +"reparse_chapters_download_description" = "Pobierz tę książkę przed odświeżeniem jej rozdziałów."; "restore_title" = "Przywróć"; "themes_caps_title" = "MOTYWY"; "plus_app_icons_title" = "Ikony Aplikacji"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Przeciągnij w pionie, aby utworzyć zakładkę"; "details_title" = "Szczegóły"; "download_title" = "Pobierz"; +"download_incomplete_error" = "Pobieranie nie zostało ukończone. Spróbuj ponownie."; "cancel_download_title" = "Anuluj pobieranie"; "remove_downloaded_file_title" = "Usuń z urządzenia"; "download_from_url_title" = "Pobierz z adresu URL"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index a670f5ebe..12bcdfc09 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Dobra o volume.\nUse com cautela e cuidado com sua audição."; "settings_globalspeed_title" = "Controle de Velocidade Global"; "settings_globalspeed_description" = "Defina a velocidade em todos os livros."; +"settings_openplayer_launch_title" = "Abrir o player ao iniciar"; +"settings_carplay_showplayer_title" = "Mostrar o player ao conectar ao CarPlay"; +"settings_startupplayer_description" = "Abre o player com o último livro reproduzido ao iniciar o aplicativo ou conectar ao CarPlay. No CarPlay, isso interrompe o áudio que estiver sendo reproduzido em outros aplicativos."; "settings_autolock_title" = "Desativar Bloqueio Automático"; "settings_autolock_description" = "Evita o bloqueio do dispositivo quando estiver na tela de reprodução."; "settings_siri_lastplayed_title" = "Último livro reproduzido"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ restante"; "chapters_title" = "Capítulos"; "chapters_item_description" = "Início: %@ - Duração: %@"; +"reload_button" = "Recarregar"; +"reload_chapters_title" = "Recarregar capítulos"; +"reparse_chapters_found_title" = "Capítulos recarregados"; +"reparse_chapters_found_description" = "%d capítulos encontrados."; +"reparse_chapters_none_description" = "Nenhum capítulo adicional foi encontrado neste arquivo."; +"reparse_chapters_download_description" = "Baixe este livro antes de recarregar seus capítulos."; "restore_title" = "Restaurar"; "themes_caps_title" = "TEMAS"; "plus_app_icons_title" = "Ícones do aplicativo"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Deslize verticalmente para criar um marcador"; "details_title" = "Detalhes"; "download_title" = "Download"; +"download_incomplete_error" = "O download não foi concluído. Tente novamente."; "cancel_download_title" = "Cancelar download"; "remove_downloaded_file_title" = "Remover do dispositivo"; "download_from_url_title" = "Baixar do URL"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index efc0e2979..6f781a4b7 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Duplicar o volume.\nUse com precaução e tenha cuidado com sua audição."; "settings_globalspeed_title" = "Controlo da Velocidade Global"; "settings_globalspeed_description" = "Definição da velocidade para todos os livros."; +"settings_openplayer_launch_title" = "Abrir o leitor ao iniciar"; +"settings_carplay_showplayer_title" = "Mostrar o leitor ao ligar ao CarPlay"; +"settings_startupplayer_description" = "Abre o leitor com o último livro reproduzido ao iniciar a aplicação ou ao ligar ao CarPlay. No CarPlay, isto interrompe o áudio que esteja a ser reproduzido noutras aplicações."; "settings_autolock_title" = "Desativar Bloqueio Automático"; "settings_autolock_description" = "Prevenir o bloqueio do dispositivo quando estiver no ecrã de reprodução."; "settings_siri_lastplayed_title" = "Último livro reproduzido"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ restante"; "chapters_title" = "Capítulos"; "chapters_item_description" = "Início: %@ - Duração: %@"; +"reload_button" = "Recarregar"; +"reload_chapters_title" = "Recarregar capítulos"; +"reparse_chapters_found_title" = "Capítulos recarregados"; +"reparse_chapters_found_description" = "%d capítulos encontrados."; +"reparse_chapters_none_description" = "Não foram encontrados capítulos adicionais neste ficheiro."; +"reparse_chapters_download_description" = "Transfira este livro antes de recarregar os seus capítulos."; "restore_title" = "Restaurar"; "themes_caps_title" = "TEMAS"; "plus_app_icons_title" = "Ícones da aplicação"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Deslize na vertical para criar um marcador"; "details_title" = "Detalhes"; "download_title" = "Download"; +"download_incomplete_error" = "O download não foi concluído. Tente novamente."; "cancel_download_title" = "Cancelar o download"; "remove_downloaded_file_title" = "Apagar do dispositivo"; "download_from_url_title" = "Descarregar do URL"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index 9c1263cbb..36b1f5fee 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Dublează volumul.\nUtilizați cu prudență și grijă pentru auzul dvs."; "settings_globalspeed_title" = "Controlul global al vitezei"; "settings_globalspeed_description" = "Setați viteza în toate cărțile."; +"settings_openplayer_launch_title" = "Deschide playerul la pornire"; +"settings_carplay_showplayer_title" = "Afișează playerul la conectarea CarPlay"; +"settings_startupplayer_description" = "Deschide playerul cu ultima carte redată la pornirea aplicației sau la conectarea la CarPlay. Pe CarPlay, acest lucru întrerupe sunetul redat în alte aplicații."; "settings_autolock_title" = "Dezactivează Autolock"; "settings_autolock_description" = "Împiedicați blocarea dispozitivului pe ecranul Playerului."; "settings_siri_lastplayed_title" = "Ultima carte redată"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ rămase"; "chapters_title" = "Capitole"; "chapters_item_description" = "Start: %@ - Durata: %@"; +"reload_button" = "Reîncarcă"; +"reload_chapters_title" = "Reîncarcă capitolele"; +"reparse_chapters_found_title" = "Capitole reîncărcate"; +"reparse_chapters_found_description" = "S-au găsit %d capitole."; +"reparse_chapters_none_description" = "Nu au fost găsite capitole suplimentare în acest fișier."; +"reparse_chapters_download_description" = "Descarcă această carte înainte de a-i reîncărca capitolele."; "restore_title" = "Restabilire"; "themes_caps_title" = "Teme"; "plus_app_icons_title" = "Pictogramele aplicației"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Glisați vertical pentru a crea un marcaj"; "details_title" = "Detalii"; "download_title" = "Descarca"; +"download_incomplete_error" = "Descărcarea nu s-a finalizat. Încercați din nou."; "cancel_download_title" = "Anulați descărcarea"; "remove_downloaded_file_title" = "Scoateți de pe dispozitiv"; "download_from_url_title" = "Descărcați de la URL"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index d4efb0a42..74a97ceb2 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Удваивает громкость. \nИспользуйте с осторожностью и заботой о вашем слухе."; "settings_globalspeed_title" = "Глобальное управление скоростью"; "settings_globalspeed_description" = "Установите скорость для всех книг."; +"settings_openplayer_launch_title" = "Открывать плеер при запуске"; +"settings_carplay_showplayer_title" = "Показывать плеер при подключении CarPlay"; +"settings_startupplayer_description" = "Открывать плеер с последней прослушанной книгой при запуске приложения или подключении к CarPlay. В CarPlay это прерывает звук, воспроизводимый в других приложениях."; "settings_autolock_title" = "Отключить автоблокировку"; "settings_autolock_description" = "Запретить блокировку устройства на экране плеера."; "settings_siri_lastplayed_title" = "Последняя воспроизведенная книга"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "Осталось %@"; "chapters_title" = "Главы"; "chapters_item_description" = "Начало: %@ - Длительность: %@"; +"reload_button" = "Обновить"; +"reload_chapters_title" = "Обновить главы"; +"reparse_chapters_found_title" = "Главы обновлены"; +"reparse_chapters_found_description" = "Найдено глав: %d."; +"reparse_chapters_none_description" = "В этом файле не найдено дополнительных глав."; +"reparse_chapters_download_description" = "Скачайте эту книгу перед обновлением её глав."; "restore_title" = "Восстановить покупки"; "themes_caps_title" = "ТЕМЫ"; "plus_app_icons_title" = "Иконки приложения"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Проведите по вертикали, чтобы создать закладку"; "details_title" = "Подробности"; "download_title" = "Скачать"; +"download_incomplete_error" = "Загрузка не завершена. Повторите попытку."; "cancel_download_title" = "Отменить загрузку"; "remove_downloaded_file_title" = "Удалить с устройства"; "download_from_url_title" = "Скачать с URL-адреса"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 91cac9226..cb29a237a 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Zdvojnásobí hlasitosť prehrávania.\nPoužívajte opatrne a chráňte si svoj sluch."; "settings_globalspeed_title" = "Globálne ovládanie rýchlosti"; "settings_globalspeed_description" = "Nastaví rýchlosť prehrávania pre všetky knihy."; +"settings_openplayer_launch_title" = "Otvoriť prehrávač pri spustení"; +"settings_carplay_showplayer_title" = "Zobraziť prehrávač pri pripojení CarPlay"; +"settings_startupplayer_description" = "Otvorí prehrávač s naposledy prehrávanou knihou pri spustení aplikácie alebo pripojení k CarPlay. V CarPlay to preruší zvuk prehrávaný v iných aplikáciách."; "settings_autolock_title" = "Zakázať automatické uzamknutie"; "settings_autolock_description" = "Zabráni uzamknutiu zariadenia pri zobrazení Prehrávača."; "settings_siri_lastplayed_title" = "Naposledy prehrávaná kniha"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "Zostáva %@"; "chapters_title" = "Kapitoly"; "chapters_item_description" = "Začiatok: %@ - Trvanie: %@"; +"reload_button" = "Obnoviť"; +"reload_chapters_title" = "Obnoviť kapitoly"; +"reparse_chapters_found_title" = "Kapitoly obnovené"; +"reparse_chapters_found_description" = "Nájdených kapitol: %d."; +"reparse_chapters_none_description" = "V tomto súbore sa nenašli žiadne ďalšie kapitoly."; +"reparse_chapters_download_description" = "Pred obnovením kapitol si túto knihu stiahnite."; "restore_title" = "Obnoviť"; "themes_caps_title" = "TÉMY"; "plus_app_icons_title" = "Ikony aplikácie"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Potiahnutím zvisle vytvoríte záložku"; "details_title" = "Podrobnosti"; "download_title" = "Stiahnuť"; +"download_incomplete_error" = "Sťahovanie sa nedokončilo. Skúste to znova."; "cancel_download_title" = "Zrušiť sťahovanie"; "remove_downloaded_file_title" = "Odstrániť zo zariadenia"; "download_from_url_title" = "Sťahovanie z adresy URL"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index 8c91a1e74..8d5baff42 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Dubblar volymen. \nAnvänd med försiktighet och omsorg för din hörsel."; "settings_globalspeed_title" = "Global Uppspelningshastighet"; "settings_globalspeed_description" = "Ange uppspelningshastighet för alla böcker."; +"settings_openplayer_launch_title" = "Öppna spelaren vid start"; +"settings_carplay_showplayer_title" = "Visa spelaren vid CarPlay-anslutning"; +"settings_startupplayer_description" = "Öppna spelaren med den senast spelade boken när appen startas eller ansluts till CarPlay. På CarPlay avbryter detta ljud som spelas upp i andra appar."; "settings_autolock_title" = "Avaktivera Automatisk Låsning"; "settings_autolock_description" = "Förhindra att enheten låses när du är på spelarens skärmsida."; "settings_siri_lastplayed_title" = "Senast spelad bok"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ kvar"; "chapters_title" = "Kapitel"; "chapters_item_description" = "Start: %@ - Längd: %@"; +"reload_button" = "Uppdatera"; +"reload_chapters_title" = "Uppdatera kapitel"; +"reparse_chapters_found_title" = "Kapitel uppdaterade"; +"reparse_chapters_found_description" = "Hittade %d kapitel."; +"reparse_chapters_none_description" = "Inga ytterligare kapitel hittades i den här filen."; +"reparse_chapters_download_description" = "Ladda ner den här boken innan du uppdaterar dess kapitel."; "restore_title" = "Återställ"; "themes_caps_title" = "TEMAN"; "plus_app_icons_title" = "App-ikoner"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Svep vertikalt för att skapa ett bokmärke"; "details_title" = "Detaljer"; "download_title" = "Ladda ner"; +"download_incomplete_error" = "Nedladdningen slutfördes inte. Försök igen."; "cancel_download_title" = "Avbryt nedladdning"; "remove_downloaded_file_title" = "Ta bort från enheten"; "download_from_url_title" = "Ladda ner från URL"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 68c4d71da..6688acf65 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Sesi iki katına çıkarır.\nİşitme duyunuz için dikkat edin ve dikkatli kullanın."; "settings_globalspeed_title" = "Evrensel Hız Kontrolü"; "settings_globalspeed_description" = "Tüm kitaplarda hızı ayarlayın."; +"settings_openplayer_launch_title" = "Başlangıçta oynatıcıyı aç"; +"settings_carplay_showplayer_title" = "CarPlay bağlantısında oynatıcıyı göster"; +"settings_startupplayer_description" = "Uygulamayı başlatırken veya CarPlay'e bağlanırken son çalınan kitabın oynatıcısını açar. CarPlay'de bu, diğer uygulamalarda çalan sesi kesintiye uğratır."; "settings_autolock_title" = "Otomatik Kilidi Devre Dışı Bırak"; "settings_autolock_description" = "Oynatıcı ekranındayken cihazın kilitlenmesini önleyin."; "settings_siri_lastplayed_title" = "En son oynatılan kitap"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "%@ kaldı"; "chapters_title" = "Bölümler"; "chapters_item_description" = "Başlangıç: %@ - Süre: %@"; +"reload_button" = "Yenile"; +"reload_chapters_title" = "Bölümleri yenile"; +"reparse_chapters_found_title" = "Bölümler Yenilendi"; +"reparse_chapters_found_description" = "%d bölüm bulundu."; +"reparse_chapters_none_description" = "Bu dosyada ek bölüm bulunamadı."; +"reparse_chapters_download_description" = "Bölümleri yenilemeden önce bu kitabı indirin."; "restore_title" = "Geri Yükle"; "themes_caps_title" = "TEMALAR"; "plus_app_icons_title" = "Uygulama Simgeleri"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Yer imi oluşturmak için dikey olarak kaydırın"; "details_title" = "Detaylar"; "download_title" = "İndirmek"; +"download_incomplete_error" = "İndirme tamamlanmadı. Lütfen tekrar deneyin."; "cancel_download_title" = "İndirmeyi iptal et"; "remove_downloaded_file_title" = "Cihazdan kaldır"; "download_from_url_title" = "URL'den indir"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 93d3bb6dc..192d02589 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "Подвоює гучність. \n Використовуйте обережно та дбайте про свій слух."; "settings_globalspeed_title" = "Глобальний контроль швидкості"; "settings_globalspeed_description" = "Встановити швидкість для всіх книг."; +"settings_openplayer_launch_title" = "Відкривати програвач під час запуску"; +"settings_carplay_showplayer_title" = "Показувати програвач під час підключення CarPlay"; +"settings_startupplayer_description" = "Відкривати програвач з останньою прослуханою книгою під час запуску застосунку або підключення до CarPlay. У CarPlay це перериває звук, що відтворюється в інших застосунках."; "settings_autolock_title" = "Вимкнути автоблокування"; "settings_autolock_description" = "Запобігати блокуванню пристрою при відкритому програвачі."; "settings_siri_lastplayed_title" = "Остання відтворена книга"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "Залишилось %@"; "chapters_title" = "Розділи"; "chapters_item_description" = "Початок: %@ - Тривалість: %@"; +"reload_button" = "Оновити"; +"reload_chapters_title" = "Оновити розділи"; +"reparse_chapters_found_title" = "Розділи оновлено"; +"reparse_chapters_found_description" = "Знайдено розділів: %d."; +"reparse_chapters_none_description" = "У цьому файлі не знайдено додаткових розділів."; +"reparse_chapters_download_description" = "Завантажте цю книгу, перш ніж оновлювати її розділи."; "restore_title" = "Відновити"; "themes_caps_title" = "Теми"; "plus_app_icons_title" = "Піктограми"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "Проведіть вертикально, щоб створити закладку"; "details_title" = "Подробиці"; "download_title" = "Завантажити"; +"download_incomplete_error" = "Завантаження не завершено. Спробуйте ще раз."; "cancel_download_title" = "Скасувати завантаження"; "remove_downloaded_file_title" = "Видалити з пристрою"; "download_from_url_title" = "Завантажити з URL"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index a91e076a7..1bb19fa28 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -9,6 +9,9 @@ "settings_boostvolume_description" = "将音量加倍。 \n请谨慎使用并避免影响听力。"; "settings_globalspeed_title" = "全局播放速度"; "settings_globalspeed_description" = "在所有有声书中使用同一播放速度。"; +"settings_openplayer_launch_title" = "启动时打开播放器"; +"settings_carplay_showplayer_title" = "连接 CarPlay 时显示播放器"; +"settings_startupplayer_description" = "在启动应用或连接 CarPlay 时,打开上次播放有声书的播放器。在 CarPlay 上,这会中断其他应用正在播放的音频。"; "settings_autolock_title" = "禁用自动锁定"; "settings_autolock_description" = "播放时防止锁定。"; "settings_siri_lastplayed_title" = "最后播放的书"; @@ -102,6 +105,12 @@ "player_book_remaining_title" = "剩余 %@"; "chapters_title" = "章节"; "chapters_item_description" = "开始: %@ - 时长: %@"; +"reload_button" = "刷新"; +"reload_chapters_title" = "刷新章节"; +"reparse_chapters_found_title" = "章节已刷新"; +"reparse_chapters_found_description" = "找到 %d 个章节。"; +"reparse_chapters_none_description" = "此文件中未找到其他章节。"; +"reparse_chapters_download_description" = "刷新章节前请先下载本书。"; "restore_title" = "恢复"; "themes_caps_title" = "主题"; "plus_app_icons_title" = "应用图标"; @@ -236,6 +245,7 @@ "gesture_swipe_vertically_title" = "垂直滑动以创建书签"; "details_title" = "细节"; "download_title" = "下载"; +"download_incomplete_error" = "下载未完成,请重试。"; "cancel_download_title" = "取消下载"; "remove_downloaded_file_title" = "从设备中删除"; "download_from_url_title" = "从网址下载"; diff --git a/BookPlayerTests/Resources/ChapterFixtures/m4b_MALFORMED.m4b b/BookPlayerTests/Resources/ChapterFixtures/m4b_MALFORMED.m4b new file mode 100644 index 000000000..570c7ff69 Binary files /dev/null and b/BookPlayerTests/Resources/ChapterFixtures/m4b_MALFORMED.m4b differ diff --git a/BookPlayerTests/Resources/ChapterFixtures/m4b_WELLFORMED.m4b b/BookPlayerTests/Resources/ChapterFixtures/m4b_WELLFORMED.m4b new file mode 100644 index 000000000..e33217067 Binary files /dev/null and b/BookPlayerTests/Resources/ChapterFixtures/m4b_WELLFORMED.m4b differ diff --git a/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_chapters.mp3 b/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_chapters.mp3 new file mode 100644 index 000000000..800264840 Binary files /dev/null and b/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_chapters.mp3 differ diff --git a/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_toc_v23.mp3 b/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_toc_v23.mp3 new file mode 100644 index 000000000..bac22f5a9 Binary files /dev/null and b/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_toc_v23.mp3 differ diff --git a/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_toc_v24.mp3 b/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_toc_v24.mp3 new file mode 100644 index 000000000..45a92b1e4 Binary files /dev/null and b/BookPlayerTests/Resources/ChapterFixtures/mp3_NO_toc_v24.mp3 differ diff --git a/BookPlayerTests/Resources/ChapterFixtures/mp3_WITH_toc.mp3 b/BookPlayerTests/Resources/ChapterFixtures/mp3_WITH_toc.mp3 new file mode 100644 index 000000000..495257b75 Binary files /dev/null and b/BookPlayerTests/Resources/ChapterFixtures/mp3_WITH_toc.mp3 differ diff --git a/BookPlayerTests/Services/AudioMetadataServiceTests.swift b/BookPlayerTests/Services/AudioMetadataServiceTests.swift new file mode 100644 index 000000000..2d85d5dc5 --- /dev/null +++ b/BookPlayerTests/Services/AudioMetadataServiceTests.swift @@ -0,0 +1,96 @@ +// +// AudioMetadataServiceTests.swift +// BookPlayerTests +// +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import AVFoundation +@testable import BookPlayer +@testable import BookPlayerKit +import XCTest + +final class AudioMetadataServiceTests: XCTestCase { + var sut: AudioMetadataService! + + override func setUp() { + super.setUp() + sut = AudioMetadataService() + } + + private func fixtureURL(_ name: String, _ ext: String) throws -> URL { + let url = Bundle(for: Self.self).url(forResource: name, withExtension: ext) + return try XCTUnwrap(url, "Missing fixture \(name).\(ext)") + } + + // MARK: - extractMetadata (native-first, with manual fallbacks) + + func testExtractMetadata_wellFormedM4B_returnsChaptersViaNative() async throws { + let url = try fixtureURL("m4b_WELLFORMED", "m4b") + let metadata = await sut.extractMetadata(from: url) + XCTAssertEqual(metadata?.chapters?.count, 4) + XCTAssertEqual(metadata?.chapters?.first?.title, "Chapter One – Intro") + } + + func testExtractMetadata_mp3WithCTOC_returnsChaptersViaNative() async throws { + let url = try fixtureURL("mp3_WITH_toc", "mp3") + let metadata = await sut.extractMetadata(from: url) + XCTAssertEqual(metadata?.chapters?.count, 4) + } + + func testExtractMetadata_malformedM4B_recoversViaQuickTimeFallback() async throws { + // AVFoundation reports no chapters for this file; extractMetadata must fall through to the + // manual QuickTime text-track parser. + let url = try fixtureURL("m4b_MALFORMED", "m4b") + let metadata = await sut.extractMetadata(from: url) + XCTAssertEqual(metadata?.chapters?.count, 4) + } + + func testExtractMetadata_mp3WithoutCTOC_recoversViaID3Fallback() async throws { + // No CTOC frame, so AVFoundation exposes nothing; the ID3 CHAP parser recovers them. + let url = try fixtureURL("mp3_NO_toc_v23", "mp3") + let metadata = await sut.extractMetadata(from: url) + XCTAssertEqual(metadata?.chapters?.count, 4) + } + + func testExtractMetadata_noChapters_returnsNilChapters() async throws { + let url = try fixtureURL("mp3_NO_chapters", "mp3") + let metadata = await sut.extractMetadata(from: url) + XCTAssertNil(metadata?.chapters) + } + + // MARK: - extractManualChapters (bypasses AVFoundation native) + + func testExtractManualChapters_malformedM4B() async throws { + let url = try fixtureURL("m4b_MALFORMED", "m4b") + let chapters = await sut.extractManualChapters(from: url) + XCTAssertEqual(chapters?.count, 4) + } + + func testExtractManualChapters_mp3NoCTOC_v23() async throws { + let url = try fixtureURL("mp3_NO_toc_v23", "mp3") + let chapters = await sut.extractManualChapters(from: url) + XCTAssertEqual(chapters?.count, 4) + } + + func testExtractManualChapters_mp3NoCTOC_v24() async throws { + let url = try fixtureURL("mp3_NO_toc_v24", "mp3") + let chapters = await sut.extractManualChapters(from: url) + XCTAssertEqual(chapters?.count, 4) + } + + func testExtractManualChapters_wellFormedM4B_isChronologicalAndTitled() async throws { + let url = try fixtureURL("m4b_WELLFORMED", "m4b") + let parsed = await sut.extractManualChapters(from: url) + let chapters = try XCTUnwrap(parsed) + XCTAssertEqual(chapters.count, 4) + XCTAssertEqual(chapters.map(\.start), chapters.map(\.start).sorted()) + XCTAssertFalse(chapters.contains { $0.title.isEmpty }) + } + + func testExtractManualChapters_noChapters_returnsNil() async throws { + let url = try fixtureURL("mp3_NO_chapters", "mp3") + let chapters = await sut.extractManualChapters(from: url) + XCTAssertNil(chapters) + } +} diff --git a/BookPlayerTests/Services/LibraryServiceReloadChaptersTests.swift b/BookPlayerTests/Services/LibraryServiceReloadChaptersTests.swift new file mode 100644 index 000000000..57db44fc1 --- /dev/null +++ b/BookPlayerTests/Services/LibraryServiceReloadChaptersTests.swift @@ -0,0 +1,106 @@ +// +// LibraryServiceReloadChaptersTests.swift +// BookPlayerTests +// +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import AVFoundation +import CoreData +@testable import BookPlayer +@testable import BookPlayerKit +import XCTest + +/// Test double so we can control what the manual parser "finds" without a real audio file. +private final class StubAudioMetadataService: AudioMetadataServiceProtocol { + var manualChaptersResult: [ChapterMetadata]? + private(set) var extractManualChaptersCallCount = 0 + + func extractMetadata(from fileURL: URL) async -> AudioMetadata? { nil } + func extractMetadata(from asset: AVAsset) async -> AudioMetadata? { nil } + func extractManualChapters(from fileURL: URL) async -> [ChapterMetadata]? { + extractManualChaptersCallCount += 1 + return manualChaptersResult + } +} + +final class LibraryServiceReloadChaptersTests: XCTestCase { + // swiftlint:disable force_cast + private var sut: LibraryService! + private var metadataStub: StubAudioMetadataService! + + override func setUp() { + super.setUp() + DataTestUtils.clearFolderContents(url: DataManager.getProcessedFolderURL()) + let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + metadataStub = StubAudioMetadataService() + sut = LibraryService() + sut.setup(dataManager: dataManager, audioMetadataService: metadataStub) + _ = sut.getLibrary() + } + + private func makeChapters(_ count: Int) -> [ChapterMetadata] { + (0.. Book { + let book = StubFactory.book(dataManager: sut.dataManager, title: title, duration: 100) + sut.getLibraryReference().addToItems(book) + for index in 0.. PlayableItem { + let chapters = (0.. ChaptersViewModel { + ChaptersViewModel(playerManager: playerManagerMock, libraryService: libraryServiceMock) + } + + private func createLocalFile(named name: String) { + _ = DataTestUtils.generateTestFile( + name: name, + contents: Data("stub".utf8), + destinationFolder: DataManager.getProcessedFolderURL() + ) + } + + func testCanReloadChapters_singleFileBook_isTrue() { + playerManagerMock.currentItem = makeItem(relativePath: "book.m4b", isBoundBook: false) + XCTAssertTrue(makeSUT().canReloadChapters) + } + + func testCanReloadChapters_boundBook_isFalse() { + playerManagerMock.currentItem = makeItem(relativePath: "folder", isBoundBook: true) + XCTAssertFalse(makeSUT().canReloadChapters) + } + + func testReloadChapters_whenMoreFound_reloadsItemAndRefreshesList() async { + createLocalFile(named: "reparse.m4b") + playerManagerMock.currentItem = makeItem(relativePath: "reparse.m4b", isBoundBook: false, chapterCount: 1) + libraryServiceMock.reloadChaptersRelativePathReturnValue = 5 + // Simulate PlayerManager rebuilding currentItem from storage with the new chapter count. + let reloadedItem = makeItem(relativePath: "reparse.m4b", isBoundBook: false, chapterCount: 5) + playerManagerMock.reloadCurrentItemClosure = { [weak self] in + self?.playerManagerMock.currentItem = reloadedItem + } + let sut = makeSUT() + + await sut.reloadChapters() + + XCTAssertTrue(libraryServiceMock.reloadChaptersRelativePathCalled) + XCTAssertTrue(playerManagerMock.reloadCurrentItemCalled) + XCTAssertEqual(sut.chapters.count, 5) + XCTAssertNotNil(sut.currentAlert) + XCTAssertFalse(sut.isReloadingChapters) + } + + func testReloadChapters_whenFileNotDownloaded_alertsWithoutParsing() async { + // No file created on disk for this relativePath. + playerManagerMock.currentItem = makeItem(relativePath: "missing.m4b", isBoundBook: false) + let sut = makeSUT() + + await sut.reloadChapters() + + XCTAssertFalse(libraryServiceMock.reloadChaptersRelativePathCalled) + XCTAssertFalse(playerManagerMock.reloadCurrentItemCalled) + XCTAssertNotNil(sut.currentAlert) + XCTAssertFalse(sut.isReloadingChapters) + } + + func testReloadChapters_whenNoAdditionalFound_alertsAndDoesNotReloadItem() async { + createLocalFile(named: "none.m4b") + playerManagerMock.currentItem = makeItem(relativePath: "none.m4b", isBoundBook: false) + libraryServiceMock.reloadChaptersRelativePathReturnValue = nil + let sut = makeSUT() + + await sut.reloadChapters() + + XCTAssertTrue(libraryServiceMock.reloadChaptersRelativePathCalled) + XCTAssertFalse(playerManagerMock.reloadCurrentItemCalled) + XCTAssertNotNil(sut.currentAlert) + XCTAssertFalse(sut.isReloadingChapters) + } + + func testReloadChapters_boundBook_isNoOp() async { + playerManagerMock.currentItem = makeItem(relativePath: "folder", isBoundBook: true) + let sut = makeSUT() + + await sut.reloadChapters() + + XCTAssertFalse(libraryServiceMock.reloadChaptersRelativePathCalled) + XCTAssertNil(sut.currentAlert) + } +} diff --git a/BookPlayerWatch/CoreServices.swift b/BookPlayerWatch/CoreServices.swift index 49e8ba797..a37c700b4 100644 --- a/BookPlayerWatch/CoreServices.swift +++ b/BookPlayerWatch/CoreServices.swift @@ -48,6 +48,6 @@ class CoreServices: ObservableObject { func updateSyncEnabled(_ enabled: Bool) { hasSyncEnabled = enabled - syncService.isActive = enabled + syncService.updateSyncEnabled(enabled) } } diff --git a/BookPlayerWatch/ExtensionDelegate.swift b/BookPlayerWatch/ExtensionDelegate.swift index 49abef38e..dfefad392 100644 --- a/BookPlayerWatch/ExtensionDelegate.swift +++ b/BookPlayerWatch/ExtensionDelegate.swift @@ -75,6 +75,7 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { syncService.setup( isActive: accountService.hasSyncEnabled(), libraryService: libraryService, + accountService: accountService, dataManager: dataManager ) let playbackService = PlaybackService() diff --git a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift index 3d522d618..0b19a3341 100644 --- a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift +++ b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift @@ -667,6 +667,30 @@ extension PlayerManager { jumpTo(chapter.start + 0.1, recordBookmark: false) } + @MainActor + func reloadCurrentItem() { + // Rebuild `currentItem` from storage and re-bind the chapter subscription so externally + // changed data takes effect while keeping the end-of-chapter handling working. + guard let relativePath = currentItem?.relativePath, + let libraryItem = libraryService.getSimpleItem(with: relativePath), + let updatedItem = try? playbackService.getPlayableItem(from: libraryItem) else { + return + } + currentItem = updatedItem + + playableChapterSubscription?.cancel() + // `dropFirst()` skips the immediate replay of the current value on rebind — otherwise it + // would spuriously re-fire `.chapterChange` / now-playing / widget reload for the chapter + // we're already on. + playableChapterSubscription = updatedItem.$currentChapter.dropFirst().sink { [weak self] chapter in + guard let chapter = chapter else { return } + + self?.setNowPlayingBookTitle(chapter: chapter) + NotificationCenter.default.post(name: .chapterChange, object: nil, userInfo: nil) + self?.widgetReloadService.scheduleWidgetReload(of: .sharedNowPlayingWidget) + } + } + func initializeChapterTime(_ time: Double) { guard let currentItem = self.currentItem else { return } diff --git a/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift b/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift index e3ad19e11..6614825a4 100644 --- a/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift +++ b/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift @@ -50,6 +50,20 @@ class RemoteItemCellViewModel: ObservableObject { self?.downloadState = .downloading(progress: progress) }.store(in: &disposeBag) + + /// A download/verification failure (e.g. a truncated file that was discarded) + /// must reset the cell — otherwise it would stay stuck showing the in-progress + /// or downloaded state for a file that no longer exists on disk. The error + /// publisher only carries the failing `relativePath`, so this matches single + /// items directly (bound-book chapter errors are surfaced but don't reset the + /// parent cell — handled when the error payload carries the initiating path). + coreServices.syncService.downloadErrorPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] (relativePath, _) in + guard relativePath == self?.item.relativePath else { return } + + self?.downloadState = .notDownloaded + }.store(in: &disposeBag) } func startDownload() async throws { diff --git a/GENERAL_TERMS.md b/GENERAL_TERMS.md index 9b2d420aa..f2dd3ebca 100644 --- a/GENERAL_TERMS.md +++ b/GENERAL_TERMS.md @@ -32,7 +32,7 @@ These Terms of Use – General (**“General Terms”**) supplement BookPlayer **4. Termination** -4.1 These Terms terminate automatically if, for any reason, we cease to operate the Application. Sections 2-7 of these General Terms will survive termination. +4.1 These Terms terminate automatically if, for any reason, we cease to operate the Application. Sections 2-6 of these General Terms will survive termination. 4.2 BookPlayer retains the right to terminate these Terms immediately if you have breached these Terms in any way. @@ -49,8 +49,8 @@ We respect the intellectual property rights of others and will respond to clear **6. Miscellaneous** -7.1 Severability. In the event that one or more terms of these Terms becomes or is declared to be illegal or otherwise unenforceable by any court of competent jurisdiction, each such term shall be deemed deleted from these Terms in such jurisdiction. All remaining terms of these Terms shall remain in full force and effect and such deletion shall not apply to the Terms in other jurisdictions. +6.1 Severability. In the event that one or more terms of these Terms becomes or is declared to be illegal or otherwise unenforceable by any court of competent jurisdiction, each such term shall be deemed deleted from these Terms in such jurisdiction. All remaining terms of these Terms shall remain in full force and effect and such deletion shall not apply to the Terms in other jurisdictions. -7.2 The failure of us to exercise or enforce any right or provision of these Terms shall not constitute a waiver of such right or provision. Our notice to you via email, regular mail, or notices or links on the Application shall constitute acceptable notice to you under these Terms. +6.2 The failure of us to exercise or enforce any right or provision of these Terms shall not constitute a waiver of such right or provision. Our notice to you via email, regular mail, or notices or links on the Application shall constitute acceptable notice to you under these Terms. -7.3 These Terms constitutes the entire agreement between us and unless specifically stated in these Terms, no other representation, document or statement shall be considered part of these Terms. \ No newline at end of file +6.3 These Terms constitutes the entire agreement between us and unless specifically stated in these Terms, no other representation, document or statement shall be considered part of these Terms. \ No newline at end of file diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 661be37e2..f479e60c0 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -39,6 +39,8 @@ public enum Constants { public static let autolockDisabled = "userSettingsDisableAutolock" public static let autolockDisabledOnlyWhenPowered = "userSettingsAutolockOnlyWhenPowered" public static let playerListPrefersBookmarks = "userSettingsPlayerListPrefersBookmarks" + public static let carPlayShowPlayerOnConnect = "userSettingsCarPlayShowPlayerOnConnect" + public static let openPlayerOnAppLaunch = "userSettingsOpenPlayerOnAppLaunch" public static let storageFilesSortOrder = "userSettingsStorageFilesSortOrder" public static let customSleepTimerDuration = "userSettingsCustomSleepTimerDuration" public static let autoTimerEnabled = "userSettingsAutoTimerEnabled" diff --git a/Shared/Network/BPTaskDownloadDelegate.swift b/Shared/Network/BPTaskDownloadDelegate.swift index 25f3ae293..5e5978af9 100644 --- a/Shared/Network/BPTaskDownloadDelegate.swift +++ b/Shared/Network/BPTaskDownloadDelegate.swift @@ -8,6 +8,25 @@ import Foundation +/// Errors raised when a download finishes but the resulting file can't be trusted. +/// The associated values carry diagnostic detail (logged at `.warning`); the +/// user-facing `errorDescription` is a single localized, non-technical message. +public enum DownloadError: LocalizedError { + /// The transfer finished without a network error, but fewer bytes were written + /// than the server's `Content-Length`, i.e. a truncated/botched file. + case incompleteDownload(received: Int64, expected: Int64) + /// The downloaded file's real audio duration is meaningfully shorter than the + /// duration we expected from the sync metadata. + case durationMismatch(expected: Double, actual: Double) + /// The duration couldn't be read from the downloaded file at all, despite a + /// known-good synced duration — a strong truncation/corruption signal. + case durationUnreadable + + public var errorDescription: String? { + return "download_incomplete_error".localized + } +} + public class BPTaskDownloadDelegate: NSObject, URLSessionDownloadDelegate { /// Callback triggered when the download task is finished public var didFinishDownloadingTask: ((URLSessionTask, URL?, Error?) -> Void)? @@ -25,7 +44,7 @@ public class BPTaskDownloadDelegate: NSObject, URLSessionDownloadDelegate { didFinishDownloadingTask?( downloadTask, location, - parseErrorFromTask(downloadTask) + parseErrorFromTask(downloadTask) ?? verifyDownloadIsComplete(downloadTask) ) } @@ -56,6 +75,24 @@ public class BPTaskDownloadDelegate: NSObject, URLSessionDownloadDelegate { } } + /// Verifies the bytes written to disk match the `Content-Length` the server + /// reported. Background downloads can finish "successfully" with a truncated + /// body (interrupted transfer, early connection close) and `URLSession` won't + /// flag it, so we treat a short file as an error instead of accepting it. + private func verifyDownloadIsComplete(_ task: URLSessionTask) -> Error? { + let expectedBytes = task.countOfBytesExpectedToReceive + /// `NSURLSessionTransferSizeUnknown` (-1) means the server didn't send a + /// Content-Length; we can't validate the size, so don't block the download. + guard expectedBytes != NSURLSessionTransferSizeUnknown else { return nil } + + let receivedBytes = task.countOfBytesReceived + guard receivedBytes >= expectedBytes else { + return DownloadError.incompleteDownload(received: receivedBytes, expected: expectedBytes) + } + + return nil + } + private func parseErrorFromTask(_ task: URLSessionTask) -> Error? { guard let response = task.response as? HTTPURLResponse, diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index 4bc679bac..8cc30c6c4 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -57,6 +57,13 @@ public protocol AudioMetadataServiceProtocol { /// - Parameter asset: The AVAsset to extract metadata from /// - Returns: AudioMetadata if extraction succeeds, nil otherwise func extractMetadata(from asset: AVAsset) async -> AudioMetadata? + + /// Extract chapters from a file using only our manual parsers, bypassing AVFoundation's + /// native chapter API. Use this to recover chapters when the native reader returns an + /// incomplete list (e.g. an interleaved text chapter track it collapses into a few entries). + /// - Parameter fileURL: URL to a local audio file + /// - Returns: The parsed chapters, or nil if none could be parsed + func extractManualChapters(from fileURL: URL) async -> [ChapterMetadata]? } public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { @@ -157,16 +164,21 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { // 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 locales exist but yield no usable chapters, fall through to the manual fallbacks. + if let availableChapterLocales = try? await asset.load(.availableChapterLocales), + !availableChapterLocales.isEmpty, + let standardChapters = await extractStandardChapters(from: asset, locales: availableChapterLocales) { + return standardChapters + } - // 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 - } + return await extractFallbackChapters(from: asset, metadata: metadata, duration: duration) + } + /// Chapter parsers that don't rely on AVFoundation's native chapter API. Also used directly + /// (via `extractManualChapters`) to recover chapters when the native reader under-reports. + private func extractFallbackChapters(from asset: AVAsset, metadata: [AVMetadataItem], duration: TimeInterval) async -> [ChapterMetadata]? { + do { // 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 @@ -218,7 +230,19 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { return nil } } - + + public func extractManualChapters(from fileURL: URL) async -> [ChapterMetadata]? { + let asset = AVURLAsset(url: fileURL) + do { + let metadata = try await asset.load(.metadata) + let duration = CMTimeGetSeconds(try await asset.load(.duration)) + return await extractFallbackChapters(from: asset, metadata: metadata, duration: duration) + } catch { + Self.logger.error("Failed to manually extract chapters: \(error)") + return nil + } + } + private func extractStandardChapters(from asset: AVAsset, locales: [Locale]) async -> [ChapterMetadata]? { var allChapters: [ChapterMetadata] = [] diff --git a/Shared/Services/LibraryService+Sync.swift b/Shared/Services/LibraryService+Sync.swift index ebc354d11..7e03fa99d 100644 --- a/Shared/Services/LibraryService+Sync.swift +++ b/Shared/Services/LibraryService+Sync.swift @@ -39,6 +39,9 @@ public protocol LibrarySyncProtocol { func updateLibraryLastBook(with relativePath: String?) async /// Returns boolean determining if the item exists for the relativePath func itemExists(for relativePath: String) async -> Bool + /// Fetch the stored (synced) duration for the item, read on a background context + /// so it's safe to call off the main thread (e.g. from a download delegate queue). + func getItemDuration(at relativePath: String) async -> Double? /// Load encoded chapters from file into DB func loadChaptersIfNeeded(relativePath: String) async @@ -85,6 +88,21 @@ extension LibraryService: LibrarySyncProtocol { } } + public func getItemDuration(at relativePath: String) async -> Double? { + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [unowned self, context] in + let duration = self.getItemProperty( + #keyPath(LibraryItem.duration), + relativePath: relativePath, + context: context + ) as? Double + + continuation.resume(returning: duration) + } + } + } + public func updateInfo(for itemsDict: [String: SyncableItem], parentFolder: String?) async { return await withCheckedContinuation { continuation in let context = dataManager.getBackgroundContext() diff --git a/Shared/Services/LibraryService.swift b/Shared/Services/LibraryService.swift index 35642b5dd..a8896fd2a 100644 --- a/Shared/Services/LibraryService.swift +++ b/Shared/Services/LibraryService.swift @@ -90,6 +90,9 @@ public protocol LibraryServiceProtocol: AnyObject { func createBook(from url: URL) async -> Book /// Load metadata chapters if needed func loadChaptersIfNeeded(relativePath: String, asset: AVAsset) async + /// Re-parse chapters from the file with our manual parsers, replacing the stored list only + /// when more chapters are found. Returns the new chapter count, or nil if nothing changed. + func reloadChapters(relativePath: String) async -> Int? /// Create folder func createFolder(with title: String, inside relativePath: String?) throws -> SimpleLibraryItem /// Update folder type @@ -1778,6 +1781,35 @@ extension LibraryService { } } + public func reloadChapters(relativePath: String) async -> Int? { + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + + // Parse outside of context.perform (it does file I/O). + guard let newChapters = await audioMetadataService.extractManualChapters(from: fileURL), + !newChapters.isEmpty else { + return nil + } + + let context = dataManager.getBackgroundContext() + return await context.perform { [unowned self] in + guard let book = self.getItem(with: relativePath, context: context) as? Book else { + return nil + } + // Only replace when the manual parser found more than what's stored, so the action can + // only improve a list — never degrade one AVFoundation already read correctly. + let existingCount = book.chapters?.count ?? 0 + guard newChapters.count > existingCount else { return nil } + + if let existing = book.chapters?.array as? [Chapter] { + existing.forEach { context.delete($0) } + } + self.storeChapters(newChapters, for: book, context: context) + self.dataManager.saveSyncContext(context) + return newChapters.count + } + } + func createFolderOnDisk(title: String, inside relativePath: String?, context: NSManagedObjectContext) throws { let processedFolder = DataManager.getProcessedFolderURL() let destinationURL: URL diff --git a/Shared/Services/Sync/SyncService.swift b/Shared/Services/Sync/SyncService.swift index 36839b003..5622c8a38 100644 --- a/Shared/Services/Sync/SyncService.swift +++ b/Shared/Services/Sync/SyncService.swift @@ -6,6 +6,7 @@ // Copyright © 2022 BookPlayer LLC. All rights reserved. // +import AVFoundation import Combine import Foundation @@ -21,8 +22,15 @@ public enum BPSyncError: Error { /// sourcery: AutoMockable public protocol SyncServiceProtocol { - /// Flag to check if it can sync or not - var isActive: Bool { get set } + /// Flag to check if it can sync or not. Owned by `SyncService`; mutate it through + /// `updateSyncEnabled(_:)` / `logout()` rather than assigning directly. + var isActive: Bool { get } + /// Enable or disable syncing in response to account/subscription state. Disabling + /// also cancels any queued jobs. + func updateSyncEnabled(_ enabled: Bool) + /// Tear down sync state on logout/account deletion (stop syncing, clear the queue + /// and the scheduled-contents flag). + func logout() async /// Completion publisher for ongoing-download tasks var downloadCompletedPublisher: PassthroughSubject<(String, String, String?), Never> { get } /// Progress publisher for ongoing-download tasks @@ -101,10 +109,16 @@ public protocol SyncServiceProtocol { @Observable public final class SyncService: SyncServiceProtocol, BPLogger { private var libraryService: LibrarySyncProtocol! + private var accountService: AccountServiceProtocol! private var tasksCountService: SyncTasksCountService! var jobManager: JobSchedulerProtocol! private var client: NetworkClientProtocol! - public var isActive: Bool = false + /// Owned here: writes go through `updateSyncEnabled(_:)` / `logout()`, mutated on the + /// main actor. External callers read only. + public private(set) var isActive: Bool = false + /// In-flight logout teardown, awaited before an initial library sync re-schedules, + /// so a re-login can't begin scheduling until the queue reset has finished. + private var teardownTask: Task? /// Dictionary holding the initiating item relative path as key and the download tasks as value private var downloadTasksDictionary = [String: [URLSessionTask]]() @@ -132,11 +146,13 @@ public final class SyncService: SyncServiceProtocol, BPLogger { public func setup( isActive: Bool, libraryService: LibrarySyncProtocol, + accountService: AccountServiceProtocol, client: NetworkClientProtocol = NetworkClient(), dataManager: DataManager ) { self.isActive = isActive self.libraryService = libraryService + self.accountService = accountService let tasksDataManager = TasksDataManager() self.tasksCountService = SyncTasksCountService(tasksDataManager: tasksDataManager) self.jobManager = SyncJobScheduler(tasksDataManager: tasksDataManager, dataManager: dataManager) @@ -183,11 +199,21 @@ public final class SyncService: SyncServiceProtocol, BPLogger { func bindObservers() { NotificationCenter.default.publisher(for: .logout, object: nil) - .sink(receiveValue: { _ in - UserDefaults.standard.set( - false, - forKey: Constants.UserDefaults.hasScheduledLibraryContents - ) + .sink(receiveValue: { [weak self] _ in + /// Covers every logout path (iOS sign-out, account deletion, Watch), since + /// they all post `.logout`. Tears down the queue, the flag, and `isActive`. + /// Held in `teardownTask` so a re-login's initial sync awaits it first. + self?.teardownTask = Task { await self?.logout() } + }) + .store(in: &disposeBag) + + /// Sync ownership lives here, not in the views: any account/subscription change + /// re-derives whether syncing should be active. All logout paths post `.logout` + /// (handled above), so account-present-but-sync-disabled is the case we map here. + NotificationCenter.default.publisher(for: .accountUpdate, object: nil) + .sink(receiveValue: { [weak self] _ in + guard let self, self.accountService.hasAccount() else { return } + self.updateSyncEnabled(self.accountService.hasSyncEnabled()) }) .store(in: &disposeBag) @@ -240,6 +266,11 @@ public final class SyncService: SyncServiceProtocol, BPLogger { ) async throws { Self.logger.trace("Fetching list of contents") + /// Same gate as `syncLibraryContents()`: don't reconcile while a logout teardown + /// is still clearing the queue, in case a fast re-login routed here before the + /// `hasScheduledLibraryContents` flag was reset. + await teardownTask?.value + let response = try await fetchContents(at: relativePath) try await processContentsResponse(response, parentFolder: relativePath, canDelete: true) @@ -255,6 +286,10 @@ public final class SyncService: SyncServiceProtocol, BPLogger { throw BookPlayerError.networkError("Sync is not enabled") } + /// Wait for any in-flight logout teardown to finish before scheduling, so a fast + /// logout→login can't have a late `resetAllJobs()` wipe freshly-scheduled jobs. + await teardownTask?.value + if await queuedJobsCount() > 0 { Self.logger.trace("Clearing orphaned tasks before initial library sync") await resetAllJobs() @@ -486,6 +521,32 @@ public final class SyncService: SyncServiceProtocol, BPLogger { public func resetAllJobs() async { await jobManager.resetAllJobs() } + + /// Enables or disables syncing in response to account/subscription state. Disabling + /// also cancels any queued jobs. Idempotent — a no-op when the state is unchanged. + /// `isActive` is mutated on the main actor since it's an `@Observable` value read by + /// SwiftUI; the actual sync-content refresh is triggered by observers of `isActive`. + public func updateSyncEnabled(_ enabled: Bool) { + Task { @MainActor in + guard self.isActive != enabled else { return } + self.isActive = enabled + if !enabled { + self.cancelAllJobs() + } + } + } + + /// Tears down sync state when the account logs out (or is deleted): stops syncing, + /// clears the persisted task queue, and resets the "scheduled library contents" flag + /// so the next login runs a fresh initial sync from an empty queue. Idempotent. + public func logout() async { + await MainActor.run { self.isActive = false } + UserDefaults.standard.set( + false, + forKey: Constants.UserDefaults.hasScheduledLibraryContents + ) + await resetAllJobs() + } } extension SyncService { @@ -643,55 +704,146 @@ extension SyncService { ) { guard let relativePath = task.taskDescription else { return } - do { - if error == nil, - let location - { - let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + var finalError = error + var movedFileURL: URL? + if error == nil, + let location + { + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + + do { /// If there's already something there, replace with new finished download if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) } try DataManager.createContainingFolderIfNeeded(for: fileURL) try FileManager.default.moveItem(at: location, to: fileURL) - - Task { - await self.libraryService.loadChaptersIfNeeded(relativePath: relativePath) - } + movedFileURL = fileURL + } catch { + finalError = error + Self.logger.warning("Error moving downloaded file to the destination: \(error.localizedDescription)") } - } catch { - Self.logger.trace("Error moving downloaded file to the destination: \(error.localizedDescription)") } - if let error { - DispatchQueue.main.async { - self.downloadErrorPublisher.send((relativePath, error)) - } + /// Capture and clear the per-task bookkeeping synchronously (we're on the + /// delegate queue). The completion event is emitted later, only after the + /// file is verified — see `finalizeDownloadedFile`. + let startingItemPath = ongoingTasksParentReference[relativePath] + let parentFolderPath = initiatingFolderReference[relativePath] + if let startingItemPath, + downloadTasksDictionary[startingItemPath]? + .filter({ $0 != task }) + .allSatisfy({ $0.state == .completed }) == true + { + downloadTasksDictionary[startingItemPath] = nil } + ongoingTasksParentReference[relativePath] = nil + initiatingFolderReference[relativePath] = nil - guard let startingItemPath = ongoingTasksParentReference[relativePath] else { - initiatingFolderReference[relativePath] = nil + /// The download itself failed (network/move error): surface it and stop. We + /// never emit a completion event for a failed download. + if let finalError { + DispatchQueue.main.async { + self.downloadErrorPublisher.send((relativePath, finalError)) + } return } - let parentFolderPath = initiatingFolderReference[relativePath] + /// Second delegate callback (`didCompleteWithError` with no location) for a + /// download that already finished: nothing to move, nothing to announce. + guard let movedFileURL else { return } - /// cleanup individual reference - if downloadTasksDictionary[startingItemPath]? - .filter({ $0 != task }) - .allSatisfy({ $0.state == .completed }) == true - { - downloadTasksDictionary[startingItemPath] = nil + Task { + await self.finalizeDownloadedFile( + relativePath: relativePath, + fileURL: movedFileURL, + startingItemPath: startingItemPath, + parentFolderPath: parentFolderPath + ) + } + } + + /// Loads chapters, validates the downloaded file, and only then announces + /// completion. Splitting this out (and gating the completion event on the + /// validation result) ensures a truncated file is never broadcast as + /// `.downloaded` before being discarded. + private func finalizeDownloadedFile( + relativePath: String, + fileURL: URL, + startingItemPath: String?, + parentFolderPath: String? + ) async { + await libraryService.loadChaptersIfNeeded(relativePath: relativePath) + + /// Read on a background context (off the main/view context) — this runs on + /// the download delegate's queue / a detached task. + let expectedDuration = await libraryService.getItemDuration(at: relativePath) + + if let verificationError = await verifyDownloadedFile( + relativePath: relativePath, + fileURL: fileURL, + expectedDuration: expectedDuration + ) { + DispatchQueue.main.async { + self.downloadErrorPublisher.send((relativePath, verificationError)) + } + return } - ongoingTasksParentReference[relativePath] = nil - initiatingFolderReference[relativePath] = nil + /// Only announce completion once the file is verified, and only for a tracked + /// (user-initiated) download — restored/untracked tasks just get validated. + guard let startingItemPath else { return } DispatchQueue.main.async { self.downloadCompletedPublisher.send((relativePath, startingItemPath, parentFolderPath)) } } + /// Backstop against truncated/botched downloads that finish without surfacing a + /// network error (e.g. the connection closed early, or watchOS suspended the + /// background transfer). `URLSession` won't flag these, so a short file would be + /// promoted as a fully-downloaded book and silently cut playback off partway + /// through. Returns a non-nil error (and deletes the file) when the download is + /// rejected; `nil` when the file is acceptable or can't be validated. + private func verifyDownloadedFile( + relativePath: String, + fileURL: URL, + expectedDuration: Double? + ) async -> Error? { + /// No trustworthy reference duration (item not yet stored, or a duration of 0) + /// means we can't validate — leave the download in place. + guard let expectedDuration, expectedDuration > 0 else { return nil } + + let actualDuration: Double + do { + let asset = AVURLAsset(url: fileURL) + actualDuration = CMTimeGetSeconds(try await asset.load(.duration)) + } catch { + /// We have a trustworthy synced duration, so this is a format we can play — + /// a complete file would be readable. A load failure on a fresh download is + /// a strong truncation/corruption signal (e.g. a cut-off AAC/m4b missing its + /// `moov` atom), which is exactly the case a byte/duration check would + /// otherwise miss. Reject it. + try? FileManager.default.removeItem(at: fileURL) + Self.logger.warning( + "Discarding download for \(relativePath); duration unreadable (likely truncated): \(error.localizedDescription)" + ) + return DownloadError.durationUnreadable + } + + guard actualDuration.isFinite else { return nil } + + /// Allow a small tolerance for container/encoder rounding differences. + let tolerance = max(2, expectedDuration * 0.02) + guard expectedDuration - actualDuration > tolerance else { return nil } + + try? FileManager.default.removeItem(at: fileURL) + Self.logger.warning( + "Discarding truncated download for \(relativePath): expected \(expectedDuration)s, got \(actualDuration)s" + ) + return DownloadError.durationMismatch(expected: expectedDuration, actual: actualDuration) + } + public func cancelDownload(of item: SimpleLibraryItem) throws { guard let tasks = downloadTasksDictionary[item.relativePath] else { return } diff --git a/Shared/SwiftData/TasksDataManager.swift b/Shared/SwiftData/TasksDataManager.swift index 7da449e92..99637860b 100644 --- a/Shared/SwiftData/TasksDataManager.swift +++ b/Shared/SwiftData/TasksDataManager.swift @@ -67,6 +67,8 @@ public final class TasksDataManager { } public func deleteAllTasks(with context: ModelContext) throws { + // Task payload models are standalone (no relationships), so a store-level + // batch delete is safe and fast. try context.delete(model: UploadTaskModel.self) try context.delete(model: UpdateTaskModel.self) try context.delete(model: MoveTaskModel.self) @@ -76,9 +78,22 @@ public final class TasksDataManager { try context.delete(model: RenameFolderTaskModel.self) try context.delete(model: ArtworkUploadTaskModel.self) try context.delete(model: MatchUuidsTaskModel.self) - try context.delete(model: SyncTaskReferenceModel.self) - try context.delete(model: SyncTasksContainer.self) - + + // SyncTaskReferenceModel.container participates in a cascade relationship with + // SyncTasksContainer. A store-level batch delete runs below the object graph and + // skips relationship-maintenance (cascade/nullify) entirely, which trips a + // constraint-trigger / optimistic-lock error on that inverse. Delete through the + // object graph instead: removing each container cascades to its task references. + let containers = try context.fetch(FetchDescriptor()) + for container in containers { + context.delete(container) + } + // Defensively clear any references that aren't attached to a container. + let orphanedReferences = try context.fetch(FetchDescriptor()) + for reference in orphanedReferences { + context.delete(reference) + } + try context.save() }