diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 2c91a7dbf..4b166193f 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 */; }; @@ -444,8 +446,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 +632,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 +843,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 +857,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 +871,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 +1026,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 +1278,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 +1308,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 = ""; }; @@ -1401,8 +1411,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 +1594,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 +1621,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 +1764,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 +1792,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 +1919,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 +2274,8 @@ FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */, 6279361D272D0CF50097837D /* Coordinators */, 418B6D141D2707F800F974FB /* Info.plist */, + 730EC9788E81A550CC46D98C /* ViewModels */, + 349F568001B5C23CF1DD5AF6 /* Resources */, ); path = BookPlayerTests; sourceTree = ""; @@ -3088,10 +3131,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 +4170,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; }; @@ -4799,6 +4859,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; }; diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index b72a17a88..6f6f3ab23 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index 12bf1ccc7..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 diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index d0fc7722d..33aa2aae1 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -714,6 +714,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 } 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/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 9ab60be25..630115fbf 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -102,6 +102,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" = "أيقونات التطبيق"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 9ddc6915d..687533225 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -102,6 +102,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ó"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index bb6dc6339..39e311aa3 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -102,6 +102,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í"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 04c1c2c89..0767a0201 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index 29d3a6b5a..9f86986a8 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 9e64993cd..249261094 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -102,6 +102,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" = "Εικονίδια εφαρμογών"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index f5ee30f3a..2c3133c40 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 8232a0f7e..14c5d3342 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index a68bb4f57..a37a9a0ce 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index dda5e0da9..32895ac6a 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 4cc441baa..fc9ffb7e3 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index c18701ee7..3e09bcceb 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index 86d470319..9c058d0ea 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -102,6 +102,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" = "アプリアイコン"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index a6d0d4fba..8a9a2d678 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index a54b740dd..1df720729 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 6d027493d..49acf5e3e 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index a670f5ebe..373fec95a 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index efc0e2979..d95878cfc 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index 9c1263cbb..0dec0ea52 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index d4efb0a42..1273407a1 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -102,6 +102,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" = "Иконки приложения"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 91cac9226..f7f883f55 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index 8c91a1e74..d4a7c9192 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 68c4d71da..088e91fff 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -102,6 +102,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"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 93d3bb6dc..20175eb07 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -102,6 +102,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" = "Піктограми"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index a91e076a7..c219f5425 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -102,6 +102,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" = "应用图标"; 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/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/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.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