diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 027c11d1c..5b4a8abf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Select Xcode 26.0 run: | ls -1 /Applications | grep -E '^Xcode_26\.' || true - sudo xcode-select -s "/Applications/Xcode_26.0.app" + sudo xcode-select -s "/Applications/Xcode_26.4.app" xcodebuild -version xcrun simctl list runtimes @@ -29,7 +29,7 @@ jobs: - name: Reset Simulator run: | set -x - export DEVELOPER_DIR="/Applications/Xcode_26.0.app" + export DEVELOPER_DIR="/Applications/Xcode_26.4.app" xcrun simctl shutdown all || true xcrun simctl erase all || true launchctl kickstart -k gui/$UID/com.apple.CoreSimulator.CoreSimulatorService || true @@ -40,7 +40,7 @@ jobs: xcodebuild \ -scheme BookPlayer \ -configuration Debug \ - -destination "platform=iOS Simulator,name=iPhone 17,OS=26.1" \ + -destination "platform=iOS Simulator,name=iPhone 17,OS=26.4" \ build-for-testing - name: Run unit tests @@ -53,6 +53,6 @@ jobs: -configuration Debug \ -testPlan "Unit Tests" \ -only-testing:BookPlayerTests \ - -destination "platform=iOS Simulator,name=iPhone 17,OS=26.1" \ + -destination "platform=iOS Simulator,name=iPhone 17,OS=26.4" \ -destination-timeout 120 \ test-without-building diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index c910d45bd..4fc99eee8 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 07416E5AD384927D90BFB6EE /* PasskeyCreatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C084D4BC05BF6B413C86C6F0 /* PasskeyCreatingView.swift */; }; + 0EF52614EF6770D09CA81CE4 /* IntegrationConnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */; }; + 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 */; }; 3F66408A2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; 3F66408B2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; 3F66408D2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 3F66408C2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel */; }; @@ -181,6 +185,7 @@ 41A359C5276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 41A359C2276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel */; }; 41A359C6276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 41A359C2276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel */; }; 41A359C7276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 41A359C2276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel */; }; + 41A6E5ED3159D85692581CC9 /* IntegrationLibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25063B02760AB32CB8F38E0 /* IntegrationLibraryGridView.swift */; }; 41A894202652A5DE0032E972 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8941F2652A5DE0032E972 /* Configuration.swift */; }; 41A894212652A5DE0032E972 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8941F2652A5DE0032E972 /* Configuration.swift */; }; 41A8942A2652A7DF0032E972 /* Bundle+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A894292652A7DF0032E972 /* Bundle+BookPlayer.swift */; }; @@ -404,8 +409,6 @@ 634E162D2E6EA10800ED9589 /* fruit-based-ipad-26@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 634E162B2E6EA10800ED9589 /* fruit-based-ipad-26@3x.png */; }; 634E16302E6EA73E00ED9589 /* Platinum@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 634E162E2E6EA73E00ED9589 /* Platinum@2x.png */; }; 634E16312E6EA73E00ED9589 /* Platinum@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 634E162F2E6EA73E00ED9589 /* Platinum@3x.png */; }; - 634FF79F2DF6932E005D1C0D /* JellyfinSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634FF79E2DF6932E005D1C0D /* JellyfinSettingsView.swift */; }; - 634FF7A22DF73DDE005D1C0D /* JellyfinLibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634FF7A12DF73DDE005D1C0D /* JellyfinLibraryListView.swift */; }; 6350E4642CF004160077CDC1 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6350E4632CF004160077CDC1 /* LoadingView.swift */; }; 6350E4662CF423030077CDC1 /* PlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6350E4652CF423030077CDC1 /* PlayerManager.swift */; }; 6350E4672CF423E80077CDC1 /* PlayerManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E893972CAFAC7500946CD4 /* PlayerManagerProtocol.swift */; }; @@ -435,12 +438,7 @@ 636086012C5B3EB400341D78 /* CustomRewindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636086002C5B3EB400341D78 /* CustomRewindIntent.swift */; }; 63652C732E6E8A9700231202 /* ChaptersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63652C722E6E8A9700231202 /* ChaptersView.swift */; }; 63682CBE2E2F422900C15FC1 /* DebugFileTransferable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63682CBD2E2F422900C15FC1 /* DebugFileTransferable.swift */; }; - 636E77792DF28E9400D4DC0A /* JellyfinLibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E77782DF28E9400D4DC0A /* JellyfinLibraryGridView.swift */; }; - 636E777F2DF33BF100D4DC0A /* JellyfinDisconnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E777E2DF33BF100D4DC0A /* JellyfinDisconnectedView.swift */; }; 636E77802DF33DA200D4DC0A /* BP+ErrorAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CD85262CE3064600EDBEA8 /* BP+ErrorAlerts.swift */; }; - 636E77822DF3499000D4DC0A /* JellyfinServerFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E77812DF3499000D4DC0A /* JellyfinServerFoundView.swift */; }; - 636E77842DF34A2900D4DC0A /* JellyfinServerInformationSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E77832DF34A2900D4DC0A /* JellyfinServerInformationSectionView.swift */; }; - 636E77862DF34F1900D4DC0A /* JellyfinConnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E77852DF34F1900D4DC0A /* JellyfinConnectedView.swift */; }; 636E77892DF4C76600D4DC0A /* JellyfinRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E77882DF4C76600D4DC0A /* JellyfinRootView.swift */; }; 636ED1E52D51254E00BFF3FD /* TipOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636ED1E42D51254E00BFF3FD /* TipOption.swift */; }; 636ED1E82D51331E00BFF3FD /* TipJarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636ED1E72D51331E00BFF3FD /* TipJarViewModel.swift */; }; @@ -476,28 +474,17 @@ 638487A32EC7722400DF442B /* AudiobookShelfLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879A2EC7722400DF442B /* AudiobookShelfLibraryItem.swift */; }; 638487A42EC7722400DF442B /* AudiobookShelfConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */; }; 638487A52EC7722400DF442B /* AudiobookShelfConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */; }; - 638487A62EC7722400DF442B /* AudiobookShelfError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879E2EC7722400DF442B /* AudiobookShelfError.swift */; }; 638487A72EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487962EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift */; }; - 638487AF2EC7750100DF442B /* AudiobookShelfConnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487A82EC7750100DF442B /* AudiobookShelfConnectedView.swift */; }; - 638487B02EC7750100DF442B /* AudiobookShelfDisconnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487AC2EC7750100DF442B /* AudiobookShelfDisconnectedView.swift */; }; - 638487B12EC7750100DF442B /* AudiobookShelfConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487AA2EC7750100DF442B /* AudiobookShelfConnectionView.swift */; }; - 638487B22EC7750100DF442B /* AudiobookShelfConnectionFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487A92EC7750100DF442B /* AudiobookShelfConnectionFormViewModel.swift */; }; 638487B32EC7750100DF442B /* AudiobookShelfConnectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487AB2EC7750100DF442B /* AudiobookShelfConnectionViewModel.swift */; }; - 638487B42EC7750100DF442B /* AudiobookShelfServerInformationSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487AE2EC7750100DF442B /* AudiobookShelfServerInformationSectionView.swift */; }; - 638487B52EC7750100DF442B /* AudiobookShelfServerFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487AD2EC7750100DF442B /* AudiobookShelfServerFoundView.swift */; }; 638487B72EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487B62EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift */; }; - 638487BA2EC7764800DF442B /* AudiobookShelfLibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487B92EC7764800DF442B /* AudiobookShelfLibraryListView.swift */; }; 638487BB2EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487B82EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift */; }; - 638487BE2EC7765000DF442B /* AudiobookShelfLibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487BD2EC7765000DF442B /* AudiobookShelfLibraryGridView.swift */; }; 638487BF2EC7765000DF442B /* AudiobookShelfLibraryGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487BC2EC7765000DF442B /* AudiobookShelfLibraryGridItemView.swift */; }; 638487C32EC7767000DF442B /* AudiobookShelfLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487C22EC7767000DF442B /* AudiobookShelfLibraryViewModel.swift */; }; 638487C42EC7767000DF442B /* AudiobookShelfLibraryItemImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487C02EC7767000DF442B /* AudiobookShelfLibraryItemImageView.swift */; }; 638487C52EC7767000DF442B /* AudiobookShelfLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487C12EC7767000DF442B /* AudiobookShelfLibraryView.swift */; }; - 638487C82EC77AF200DF442B /* AudiobookShelfSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487C72EC77AF200DF442B /* AudiobookShelfSettingsView.swift */; }; 638487C92EC77AF200DF442B /* AudiobookShelfRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487C62EC77AF200DF442B /* AudiobookShelfRootView.swift */; }; 638487CD2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487CA2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsView.swift */; }; 638487CE2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487CB2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift */; }; - 638487CF2EC77B1200DF442B /* AudiobookShelfTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487CC2EC77B1200DF442B /* AudiobookShelfTagsView.swift */; }; 638761D32E62BD7A009332E8 /* ItemListSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638761D22E62BD7A009332E8 /* ItemListSelectionView.swift */; }; 638D91A82E60AAA600B62BDD /* ImportOperationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638D91A72E60AAA600B62BDD /* ImportOperationState.swift */; }; 638E64CE2B8E1CFD00DCFA3B /* SyncTasksCountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638E64CD2B8E1CFD00DCFA3B /* SyncTasksCountService.swift */; }; @@ -627,16 +614,17 @@ 63F8BDE92E421C3B0088FB66 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F8BDE82E421C3B0088FB66 /* CircularProgressView.swift */; }; 63FB67882E714F0A00331767 /* ButtonFreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FB67872E714F0A00331767 /* ButtonFreeView.swift */; }; 63FB9E032E5774A500D1966F /* ListStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FB9E022E5774A500D1966F /* ListStateManager.swift */; }; - 63FCAB1B2E3BBA5F005EB9DE /* JellyfinTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FCAB1A2E3BBA5F005EB9DE /* JellyfinTagsView.swift */; }; - 63FCAB1D2E3BBEA0005EB9DE /* TagsFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FCAB1C2E3BBEA0005EB9DE /* TagsFlowLayout.swift */; }; 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 */; }; 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 */; }; + 760180C62F243705DE6B3224 /* IntegrationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */; }; + 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */; }; + 8322A3C6479B099448C63C47 /* IntegrationLibraryViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */; }; 8A2A22392CEFEB8E00E73A2D /* AdaptiveVGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A2A22382CEFEB8800E73A2D /* AdaptiveVGrid.swift */; }; 8A495CC62CC85D27001B4244 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 8A495CC52CC85D27001B4244 /* JellyfinAPI */; }; - 8A9D0D172CCBBF00007A924D /* JellyfinConnectionFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9D0D162CCBBF00007A924D /* JellyfinConnectionFormViewModel.swift */; }; 8A9D0D242CCED53C007A924D /* JellyfinLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9D0D232CCED53C007A924D /* JellyfinLibraryViewModel.swift */; }; 8A9D0D262CCED543007A924D /* JellyfinLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9D0D252CCED543007A924D /* JellyfinLibraryView.swift */; }; 8A9D0D282CCEEE30007A924D /* NavigationLazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9D0D272CCEEE30007A924D /* NavigationLazyView.swift */; }; @@ -652,10 +640,11 @@ 8ADD46242CF38F9F002E9C50 /* JellyfinAudiobookDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD46232CF38F91002E9C50 /* JellyfinAudiobookDetailsView.swift */; }; 8ADD46262CF67592002E9C50 /* JellyfinAudiobookDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD46252CF67587002E9C50 /* JellyfinAudiobookDetailsViewModel.swift */; }; 8AE4AAC52CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE4AAC42CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift */; }; - 8AE4AACB2CCBAA3800BAA927 /* JellyfinConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE4AACA2CCBAA3800BAA927 /* JellyfinConnectionView.swift */; }; 8AF18A332CF0A26200238F8D /* JellyfinLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */; }; - 8AF18A352CF0C42E00238F8D /* JellyfinError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF18A342CF0C42C00238F8D /* JellyfinError.swift */; }; 8AF18A3B2CF0E92F00238F8D /* KeychainServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF18A3A2CF0E92800238F8D /* KeychainServiceMock.swift */; }; + 9236C57F833C70652BDD1FA3 /* TabEditingEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */; }; + 9586CECD8FB418C6CFB0A7DD /* IntegrationLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C407B5BD19D3CF9CC64EC7 /* IntegrationLibraryView.swift */; }; + 98BA9BA4D6A94BC8BCCE5F8B /* IntegrationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */; }; 99329DB72F3AA8F6003F8E73 /* PlayControlsRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DB62F3AA8F6003F8E73 /* PlayControlsRowView.swift */; }; 99329DBB2F3AAA61003F8E73 /* ListeningProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DBA2F3AAA61003F8E73 /* ListeningProgressView.swift */; }; 99329DBD2F3AAAB4003F8E73 /* NavigationRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DBC2F3AAAB4003F8E73 /* NavigationRowView.swift */; }; @@ -814,20 +803,29 @@ 9FF710BD2A215686006490E0 /* QueuedSyncTaskType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF710BC2A215686006490E0 /* QueuedSyncTaskType.swift */; }; 9FFCC08F289418CA00F4952E /* SimpleChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FFCC08E289418CA00F4952E /* SimpleChapter.swift */; }; 9FFCC090289418CA00F4952E /* SimpleChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FFCC08E289418CA00F4952E /* SimpleChapter.swift */; }; + A34DA648F3EF87A9E11631B3 /* IntegrationAudiobookDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424AE6DFF6B641DB69DF3D78 /* IntegrationAudiobookDetailsView.swift */; }; + A7FA45CB2BD6567C9B5EA372 /* IntegrationTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643E169A5F153B754F726AE1 /* IntegrationTagsView.swift */; }; + B26B34D894351E8452FBB64B /* IntegrationServerInformationSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.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 */; }; C3A479132094C8C300D92122 /* rubberBandDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A479122094C8C300D92122 /* rubberBandDistance.swift */; }; C3A479152094CA3800D92122 /* UIImage+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A479142094CA3800D92122 /* UIImage+BookPlayer.swift */; }; C3A479192094CAF300D92122 /* UIViewController+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A479182094CAF300D92122 /* UIViewController+BookPlayer.swift */; }; + C3C998E7EA2919BB438B337C /* IntegrationLibraryItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */; }; C3EA7176218DEC870005D488 /* .swiftformat in Resources */ = {isa = PBXBuildFile; fileRef = C3EA7175218DEC870005D488 /* .swiftformat */; }; C3EC372E206EE0650094B4E8 /* SleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3EC372D206EE0650094B4E8 /* SleepTimer.swift */; }; C3FA301E20E0024900393DDA /* BPArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FA301D20E0024900393DDA /* BPArtworkView.swift */; }; 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 */; }; + CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */; }; + D080B0A77D9844C3A0737170 /* IntegrationConnectionFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */; }; D6BA8F162A4CA94800C2BD9A /* StorageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */; }; D6BA8F182A4D66CD00C2BD9A /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */; }; DCFC79BEBAEA628FB13F33A1 /* BPFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACFA02FA31A0CB0438C5228 /* BPFont.swift */; }; F50163BEFB92FB6B1B778C5E /* WindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */; }; + F7CB281CF765C468BFA9B0D8 /* IntegrationDetailsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */; }; F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */; }; /* End PBXBuildFile section */ @@ -980,7 +978,11 @@ /* Begin PBXFileReference section */ 0FC8B923A5506F865253B97C /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = ""; }; + 13C407B5BD19D3CF9CC64EC7 /* IntegrationLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryView.swift; sourceTree = ""; }; + 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryViewModelProtocol.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 = ""; }; 3ACFA02FA31A0CB0438C5228 /* BPFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPFont.swift; sourceTree = ""; }; 3F6640892E162ABF00356522 /* AudioMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMetadataService.swift; sourceTree = ""; }; 3F66408C2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = MappingModel_v9_to_v10.xcmappingmodel; sourceTree = ""; }; @@ -1222,10 +1224,14 @@ 41EB071A2752FA6B00EFEE13 /* PlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackService.swift; sourceTree = ""; }; 41F898AE2402080C00F58B8A /* ZipArchive.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZipArchive.framework; path = Carthage/Build/iOS/ZipArchive.framework; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 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 = ""; }; 5CBB29522163A17F00E3A9FF /* ZIPFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZIPFoundation.framework; path = Carthage/Build/iOS/ZIPFoundation.framework; sourceTree = ""; }; 620C73C7275DA00300D495AA /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -1332,8 +1338,6 @@ 634E162B2E6EA10800ED9589 /* fruit-based-ipad-26@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "fruit-based-ipad-26@3x.png"; sourceTree = ""; }; 634E162E2E6EA73E00ED9589 /* Platinum@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Platinum@2x.png"; sourceTree = ""; }; 634E162F2E6EA73E00ED9589 /* Platinum@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Platinum@3x.png"; sourceTree = ""; }; - 634FF79E2DF6932E005D1C0D /* JellyfinSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinSettingsView.swift; sourceTree = ""; }; - 634FF7A12DF73DDE005D1C0D /* JellyfinLibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinLibraryListView.swift; sourceTree = ""; }; 6350E4632CF004160077CDC1 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 6350E4652CF423030077CDC1 /* PlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerManager.swift; sourceTree = ""; }; 6350E46F2CF498160077CDC1 /* RemotePlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePlayerView.swift; sourceTree = ""; }; @@ -1359,11 +1363,6 @@ 636086042C5B589900341D78 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/AppShortcuts.strings; sourceTree = ""; }; 63652C722E6E8A9700231202 /* ChaptersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChaptersView.swift; sourceTree = ""; }; 63682CBD2E2F422900C15FC1 /* DebugFileTransferable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugFileTransferable.swift; sourceTree = ""; }; - 636E77782DF28E9400D4DC0A /* JellyfinLibraryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinLibraryGridView.swift; sourceTree = ""; }; - 636E777E2DF33BF100D4DC0A /* JellyfinDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinDisconnectedView.swift; sourceTree = ""; }; - 636E77812DF3499000D4DC0A /* JellyfinServerFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinServerFoundView.swift; sourceTree = ""; }; - 636E77832DF34A2900D4DC0A /* JellyfinServerInformationSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinServerInformationSectionView.swift; sourceTree = ""; }; - 636E77852DF34F1900D4DC0A /* JellyfinConnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinConnectedView.swift; sourceTree = ""; }; 636E77882DF4C76600D4DC0A /* JellyfinRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinRootView.swift; sourceTree = ""; }; 636ED1E42D51254E00BFF3FD /* TipOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipOption.swift; sourceTree = ""; }; 636ED1E72D51331E00BFF3FD /* TipJarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarViewModel.swift; sourceTree = ""; }; @@ -1417,28 +1416,17 @@ 6384879A2EC7722400DF442B /* AudiobookShelfLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryItem.swift; sourceTree = ""; }; 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfConnectionData.swift; sourceTree = ""; }; 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfConnectionService.swift; sourceTree = ""; }; - 6384879E2EC7722400DF442B /* AudiobookShelfError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfError.swift; sourceTree = ""; }; 6384879F2EC7722400DF442B /* AudiobookShelfLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibrary.swift; sourceTree = ""; }; - 638487A82EC7750100DF442B /* AudiobookShelfConnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfConnectedView.swift; sourceTree = ""; }; - 638487A92EC7750100DF442B /* AudiobookShelfConnectionFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfConnectionFormViewModel.swift; sourceTree = ""; }; - 638487AA2EC7750100DF442B /* AudiobookShelfConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfConnectionView.swift; sourceTree = ""; }; 638487AB2EC7750100DF442B /* AudiobookShelfConnectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfConnectionViewModel.swift; sourceTree = ""; }; - 638487AC2EC7750100DF442B /* AudiobookShelfDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfDisconnectedView.swift; sourceTree = ""; }; - 638487AD2EC7750100DF442B /* AudiobookShelfServerFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfServerFoundView.swift; sourceTree = ""; }; - 638487AE2EC7750100DF442B /* AudiobookShelfServerInformationSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfServerInformationSectionView.swift; sourceTree = ""; }; 638487B62EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryLevelData.swift; sourceTree = ""; }; 638487B82EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryListItemView.swift; sourceTree = ""; }; - 638487B92EC7764800DF442B /* AudiobookShelfLibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryListView.swift; sourceTree = ""; }; 638487BC2EC7765000DF442B /* AudiobookShelfLibraryGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryGridItemView.swift; sourceTree = ""; }; - 638487BD2EC7765000DF442B /* AudiobookShelfLibraryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryGridView.swift; sourceTree = ""; }; 638487C02EC7767000DF442B /* AudiobookShelfLibraryItemImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryItemImageView.swift; sourceTree = ""; }; 638487C12EC7767000DF442B /* AudiobookShelfLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryView.swift; sourceTree = ""; }; 638487C22EC7767000DF442B /* AudiobookShelfLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfLibraryViewModel.swift; sourceTree = ""; }; 638487C62EC77AF200DF442B /* AudiobookShelfRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfRootView.swift; sourceTree = ""; }; - 638487C72EC77AF200DF442B /* AudiobookShelfSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfSettingsView.swift; sourceTree = ""; }; 638487CA2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfAudiobookDetailsView.swift; sourceTree = ""; }; 638487CB2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfAudiobookDetailsViewModel.swift; sourceTree = ""; }; - 638487CC2EC77B1200DF442B /* AudiobookShelfTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookShelfTagsView.swift; sourceTree = ""; }; 638761D22E62BD7A009332E8 /* ItemListSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListSelectionView.swift; sourceTree = ""; }; 638D91A72E60AAA600B62BDD /* ImportOperationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportOperationState.swift; sourceTree = ""; }; 638E64CD2B8E1CFD00DCFA3B /* SyncTasksCountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTasksCountService.swift; sourceTree = ""; }; @@ -1546,15 +1534,18 @@ 63F8BDE82E421C3B0088FB66 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; 63FB67872E714F0A00331767 /* ButtonFreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonFreeView.swift; sourceTree = ""; }; 63FB9E022E5774A500D1966F /* ListStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateManager.swift; sourceTree = ""; }; - 63FCAB1A2E3BBA5F005EB9DE /* JellyfinTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinTagsView.swift; sourceTree = ""; }; - 63FCAB1C2E3BBEA0005EB9DE /* TagsFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsFlowLayout.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionFormViewModel.swift; sourceTree = ""; }; + 75AADE0F4BFEC0640FC687FA /* TagsFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsFlowLayout.swift; sourceTree = ""; }; + 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryItemProtocol.swift; sourceTree = ""; }; + 7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabEditingEnvironmentKey.swift; sourceTree = ""; }; + 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationDetailsViewModelProtocol.swift; sourceTree = ""; }; 8A2A22382CEFEB8800E73A2D /* AdaptiveVGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveVGrid.swift; sourceTree = ""; }; - 8A9D0D162CCBBF00007A924D /* JellyfinConnectionFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinConnectionFormViewModel.swift; sourceTree = ""; }; 8A9D0D232CCED53C007A924D /* JellyfinLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinLibraryViewModel.swift; sourceTree = ""; }; 8A9D0D252CCED543007A924D /* JellyfinLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinLibraryView.swift; sourceTree = ""; }; 8A9D0D272CCEEE30007A924D /* NavigationLazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationLazyView.swift; sourceTree = ""; }; @@ -1570,9 +1561,7 @@ 8ADD46232CF38F91002E9C50 /* JellyfinAudiobookDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAudiobookDetailsView.swift; sourceTree = ""; }; 8ADD46252CF67587002E9C50 /* JellyfinAudiobookDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAudiobookDetailsViewModel.swift; sourceTree = ""; }; 8AE4AAC42CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinConnectionViewModel.swift; sourceTree = ""; }; - 8AE4AACA2CCBAA3800BAA927 /* JellyfinConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinConnectionView.swift; sourceTree = ""; }; 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinLibraryItem.swift; sourceTree = ""; }; - 8AF18A342CF0C42C00238F8D /* JellyfinError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinError.swift; sourceTree = ""; }; 8AF18A3A2CF0E92800238F8D /* KeychainServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainServiceMock.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 = ""; }; @@ -1708,6 +1697,10 @@ 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 = ""; }; + A3511F737DCDEE7A12B713AE /* IntegrationServerFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerFoundView.swift; sourceTree = ""; }; + A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationServerInformationSectionView.swift; sourceTree = ""; }; + B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationError.swift; sourceTree = ""; }; + B25063B02760AB32CB8F38E0 /* IntegrationLibraryGridView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryGridView.swift; sourceTree = ""; }; C084D4BC05BF6B413C86C6F0 /* PasskeyCreatingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyCreatingView.swift; sourceTree = ""; }; C30B085E209654E3003F325B /* UIColor+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+BookPlayer.swift"; sourceTree = ""; }; C30CD2A0209791FA00258B09 /* UIColor+Sweetercolor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Sweetercolor.swift"; sourceTree = ""; }; @@ -1729,6 +1722,9 @@ D367F7671FA2A6F000FEDB37 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.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 = ""; }; + E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationDisconnectedView.swift; sourceTree = ""; }; + E55F466D61AE62A69DE4D371 /* BPNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPNavigation.swift; sourceTree = ""; }; + F6F0D505E060FDAEB6DB68E3 /* IntegrationLibraryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryListView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1820,6 +1816,29 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1A3B1ADBE4710D12AF4A65BD /* GridLayout */ = { + isa = PBXGroup; + children = ( + B25063B02760AB32CB8F38E0 /* IntegrationLibraryGridView.swift */, + ); + name = GridLayout; + path = GridLayout; + sourceTree = ""; + }; + 1B61577A638A632A58BCFEAF /* Connection Screen */ = { + isa = PBXGroup; + children = ( + 43D61F68F0A174C7CA44FE8A /* IntegrationConnectionView.swift */, + 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */, + E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */, + A3511F737DCDEE7A12B713AE /* IntegrationServerFoundView.swift */, + 35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */, + A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */, + 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */, + ); + path = "Connection Screen"; + sourceTree = ""; + }; 3F7B64332E0F713200299D97 /* Hardcover */ = { isa = PBXGroup; children = ( @@ -2108,6 +2127,7 @@ 41C8ABCF26836F03003B67D1 /* Import */, 8AE4AAC32CCB9E3A00BAA927 /* Jellyfin */, 638487A12EC7722400DF442B /* AudiobookShelf */, + 9EDB4EF25480E070F2786662 /* MediaServerIntegration */, C35E04F1207E8D0F007D3370 /* Library */, C3EC372A206EDB9A0094B4E8 /* Player */, C35E04F2207E8D4C007D3370 /* Settings */, @@ -2613,7 +2633,6 @@ 634FF7A02DF73DA0005D1C0D /* ListLayout */ = { isa = PBXGroup; children = ( - 634FF7A12DF73DDE005D1C0D /* JellyfinLibraryListView.swift */, 63FCBBBD2DF7404000C50035 /* JellyfinLibraryListItemView.swift */, ); path = ListLayout; @@ -2638,7 +2657,6 @@ 636E777A2DF2907B00D4DC0A /* GridLayout */ = { isa = PBXGroup; children = ( - 636E77782DF28E9400D4DC0A /* JellyfinLibraryGridView.swift */, 8A9D0D292CCFBB96007A924D /* JellyfinLibraryGridItemView.swift */, ); path = GridLayout; @@ -2649,7 +2667,6 @@ children = ( 8AAD8A542CEE8849000A4B4B /* JellyfinConnectionService.swift */, 8AAD8A562CEE88DE000A4B4B /* JellyfinConnectionData.swift */, - 8AF18A342CF0C42C00238F8D /* JellyfinError.swift */, ); path = Network; sourceTree = ""; @@ -2659,8 +2676,6 @@ children = ( 8ADD46252CF67587002E9C50 /* JellyfinAudiobookDetailsViewModel.swift */, 8ADD46232CF38F91002E9C50 /* JellyfinAudiobookDetailsView.swift */, - 63FCAB1A2E3BBA5F005EB9DE /* JellyfinTagsView.swift */, - 63FCAB1C2E3BBEA0005EB9DE /* TagsFlowLayout.swift */, ); path = Details; sourceTree = ""; @@ -2740,13 +2755,7 @@ 638487952EC7722400DF442B /* Connection Screen */ = { isa = PBXGroup; children = ( - 638487A82EC7750100DF442B /* AudiobookShelfConnectedView.swift */, - 638487A92EC7750100DF442B /* AudiobookShelfConnectionFormViewModel.swift */, - 638487AA2EC7750100DF442B /* AudiobookShelfConnectionView.swift */, 638487AB2EC7750100DF442B /* AudiobookShelfConnectionViewModel.swift */, - 638487AC2EC7750100DF442B /* AudiobookShelfDisconnectedView.swift */, - 638487AD2EC7750100DF442B /* AudiobookShelfServerFoundView.swift */, - 638487AE2EC7750100DF442B /* AudiobookShelfServerInformationSectionView.swift */, ); path = "Connection Screen"; sourceTree = ""; @@ -2757,7 +2766,6 @@ 638487962EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift */, 638487CA2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsView.swift */, 638487CB2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift */, - 638487CC2EC77B1200DF442B /* AudiobookShelfTagsView.swift */, ); path = Details; sourceTree = ""; @@ -2766,7 +2774,6 @@ isa = PBXGroup; children = ( 638487BC2EC7765000DF442B /* AudiobookShelfLibraryGridItemView.swift */, - 638487BD2EC7765000DF442B /* AudiobookShelfLibraryGridView.swift */, ); path = GridLayout; sourceTree = ""; @@ -2775,7 +2782,6 @@ isa = PBXGroup; children = ( 638487B82EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift */, - 638487B92EC7764800DF442B /* AudiobookShelfLibraryListView.swift */, ); path = ListLayout; sourceTree = ""; @@ -2800,7 +2806,6 @@ children = ( 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */, 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */, - 6384879E2EC7722400DF442B /* AudiobookShelfError.swift */, 6384879F2EC7722400DF442B /* AudiobookShelfLibrary.swift */, ); path = Network; @@ -2813,7 +2818,6 @@ 6384879B2EC7722400DF442B /* Library Screen */, 638487A02EC7722400DF442B /* Network */, 638487C62EC77AF200DF442B /* AudiobookShelfRootView.swift */, - 638487C72EC77AF200DF442B /* AudiobookShelfSettingsView.swift */, ); path = AudiobookShelf; sourceTree = ""; @@ -3023,7 +3027,6 @@ 8A9D0D1A2CCCF36D007A924D /* Library Screen */, 8AE4AAC92CCBA1D200BAA927 /* Connection Screen */, 636E77882DF4C76600D4DC0A /* JellyfinRootView.swift */, - 634FF79E2DF6932E005D1C0D /* JellyfinSettingsView.swift */, ); path = Jellyfin; sourceTree = ""; @@ -3032,12 +3035,6 @@ isa = PBXGroup; children = ( 8AE4AAC42CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift */, - 8AE4AACA2CCBAA3800BAA927 /* JellyfinConnectionView.swift */, - 636E777E2DF33BF100D4DC0A /* JellyfinDisconnectedView.swift */, - 636E77812DF3499000D4DC0A /* JellyfinServerFoundView.swift */, - 636E77832DF34A2900D4DC0A /* JellyfinServerInformationSectionView.swift */, - 636E77852DF34F1900D4DC0A /* JellyfinConnectedView.swift */, - 8A9D0D162CCBBF00007A924D /* JellyfinConnectionFormViewModel.swift */, ); path = "Connection Screen"; sourceTree = ""; @@ -3084,6 +3081,24 @@ path = Models; sourceTree = ""; }; + 9EDB4EF25480E070F2786662 /* MediaServerIntegration */ = { + isa = PBXGroup; + children = ( + 1B61577A638A632A58BCFEAF /* Connection Screen */, + D0650177D18918D871F75E28 /* Library Screen */, + E55F466D61AE62A69DE4D371 /* BPNavigation.swift */, + 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */, + 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */, + B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */, + 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */, + 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */, + 7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */, + 75AADE0F4BFEC0640FC687FA /* TagsFlowLayout.swift */, + 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */, + ); + path = MediaServerIntegration; + sourceTree = ""; + }; 9F00A6222951DEE6005EA316 /* Views */ = { isa = PBXGroup; children = ( @@ -3506,6 +3521,37 @@ name = Meta; sourceTree = ""; }; + D0650177D18918D871F75E28 /* Library Screen */ = { + isa = PBXGroup; + children = ( + 1A3B1ADBE4710D12AF4A65BD /* GridLayout */, + F195D532A513FBAA4F111DF6 /* ListLayout */, + 13C407B5BD19D3CF9CC64EC7 /* IntegrationLibraryView.swift */, + E0949CFAC928CB7ADDD1000B /* Details */, + ); + name = "Library Screen"; + path = "Library Screen"; + sourceTree = ""; + }; + E0949CFAC928CB7ADDD1000B /* Details */ = { + isa = PBXGroup; + children = ( + 643E169A5F153B754F726AE1 /* IntegrationTagsView.swift */, + 424AE6DFF6B641DB69DF3D78 /* IntegrationAudiobookDetailsView.swift */, + ); + name = Details; + path = Details; + sourceTree = ""; + }; + F195D532A513FBAA4F111DF6 /* ListLayout */ = { + isa = PBXGroup; + children = ( + F6F0D505E060FDAEB6DB68E3 /* IntegrationLibraryListView.swift */, + ); + name = ListLayout; + path = ListLayout; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4265,16 +4311,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C451596D6866C856F3E5F7D1 /* IntegrationConnectionView.swift in Sources */, + 98BA9BA4D6A94BC8BCCE5F8B /* IntegrationSettingsView.swift in Sources */, + 66DF1F3E6AFECB623A558F04 /* IntegrationDisconnectedView.swift in Sources */, + 17239057BBE31E405AFFBBCD /* IntegrationServerFoundView.swift in Sources */, + 0EF52614EF6770D09CA81CE4 /* IntegrationConnectedView.swift in Sources */, + B26B34D894351E8452FBB64B /* IntegrationServerInformationSectionView.swift in Sources */, 634BA58D2C14D5330015314D /* SecondOnboardingType.swift in Sources */, 8AF18A332CF0A26200238F8D /* JellyfinLibraryItem.swift in Sources */, 9F82DF9C27DFE46B001B0EA8 /* PhoneWatchConnectivityService.swift in Sources */, - 636E77842DF34A2900D4DC0A /* JellyfinServerInformationSectionView.swift in Sources */, 9F00A600295001C0005EA316 /* ItemDetailsArtworkSectionView.swift in Sources */, 8A9D0D262CCED543007A924D /* JellyfinLibraryView.swift in Sources */, 63388BE22DB6D7D00042C103 /* CreateBookmarkIntent.swift in Sources */, 8A9D0D2E2CCFD968007A924D /* BlurHashDecode.swift in Sources */, 631908A32E3693BC009249C1 /* SettingsPlayerControlsView.swift in Sources */, - 636E77822DF3499000D4DC0A /* JellyfinServerFoundView.swift in Sources */, 63C48D482E3EFD59005FBB96 /* AccountManageProSectionView.swift in Sources */, 410D0FF11EDF659900A52EB9 /* PlayerManager.swift in Sources */, 637043392E2BF63C005353D1 /* SettingsAppearanceSectionView.swift in Sources */, @@ -4298,7 +4348,6 @@ 6370434E2E2C0481005353D1 /* SettingsSupportSectionView.swift in Sources */, 63D561372E5A26C800615522 /* ItemArtworkView.swift in Sources */, 41B2A5E121CCC21000917584 /* ThemeManager.swift in Sources */, - 8A9D0D172CCBBF00007A924D /* JellyfinConnectionFormViewModel.swift in Sources */, 63C48C842E3E66D1005FBB96 /* PurchasesManager.swift in Sources */, 8ADD46222CF29A02002E9C50 /* TextFieldFocused.swift in Sources */, 63C48C7E2E3DB24A005FBB96 /* LoginDisclaimerSectionView.swift in Sources */, @@ -4313,28 +4362,18 @@ 63FCBBBE2DF7404000C50035 /* JellyfinLibraryListItemView.swift in Sources */, 9F3D0CE728C2BF7700E9E8A3 /* ButtonFreeViewModel.swift in Sources */, 99329DD72F3E8A04003F8E73 /* SlickSlider.swift in Sources */, - 638487AF2EC7750100DF442B /* AudiobookShelfConnectedView.swift in Sources */, - 638487B02EC7750100DF442B /* AudiobookShelfDisconnectedView.swift in Sources */, - 638487B12EC7750100DF442B /* AudiobookShelfConnectionView.swift in Sources */, 99329DD52F3D83A4003F8E73 /* BPDialogModifier.swift in Sources */, - 638487B22EC7750100DF442B /* AudiobookShelfConnectionFormViewModel.swift in Sources */, 638487B32EC7750100DF442B /* AudiobookShelfConnectionViewModel.swift in Sources */, - 638487B42EC7750100DF442B /* AudiobookShelfServerInformationSectionView.swift in Sources */, - 638487B52EC7750100DF442B /* AudiobookShelfServerFoundView.swift in Sources */, 63AD93D72E3C74990077E73A /* ProfileCardSectionView.swift in Sources */, 9F2DC9DB2A008B28006CDF1F /* PricingRowView.swift in Sources */, C3FE3F8220A090880055B9C6 /* limitPanAngle.swift in Sources */, - 8AF18A352CF0C42E00238F8D /* JellyfinError.swift in Sources */, 631908B42E36A26C009249C1 /* SkipIntervalsSectionView.swift in Sources */, 9FB20EB929A479FB0021663B /* BPAlertContent.swift in Sources */, 41670131255B4A2C0054164F /* Sequence+BookPlayer.swift in Sources */, - 634FF79F2DF6932E005D1C0D /* JellyfinSettingsView.swift in Sources */, C3FA301E20E0024900393DDA /* BPArtworkView.swift in Sources */, - 636E777F2DF33BF100D4DC0A /* JellyfinDisconnectedView.swift in Sources */, 63717EAA2B792E350006291E /* RefreshTaskOperation.swift in Sources */, 637043442E2C0000005353D1 /* SettingsShortcutsSectionView.swift in Sources */, 631908B22E36A1AB009249C1 /* SmartRewindSectionView.swift in Sources */, - 63FCAB1B2E3BBA5F005EB9DE /* JellyfinTagsView.swift in Sources */, 63C48D4A2E3F008F005FBB96 /* AccountPerksSectionView.swift in Sources */, 63C48D522E50AAA1005FBB96 /* AccountPasskeySectionView.swift in Sources */, 4165EE0520A743D500616EDF /* BookPlayer.xcdatamodeld in Sources */, @@ -4348,7 +4387,6 @@ 6338C7932E4AA4BA008016E5 /* LibraryNode.swift in Sources */, C3A479132094C8C300D92122 /* rubberBandDistance.swift in Sources */, 99329DBB2F3AAA61003F8E73 /* ListeningProgressView.swift in Sources */, - 638487BE2EC7765000DF442B /* AudiobookShelfLibraryGridView.swift in Sources */, 638487BF2EC7765000DF442B /* AudiobookShelfLibraryGridItemView.swift in Sources */, 9F1345FF293CF00A0089B1DE /* UIEdgeInsets+BookPlayer.swift in Sources */, 631908992E334894009249C1 /* ContributorView.swift in Sources */, @@ -4415,10 +4453,8 @@ 63B760FC2C33B77F00AA98C7 /* SupportProfileView.swift in Sources */, 638487CD2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsView.swift in Sources */, 638487CE2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift in Sources */, - 638487CF2EC77B1200DF442B /* AudiobookShelfTagsView.swift in Sources */, 63344C002EA708F800B90DF7 /* BackupDatabaseOperation.swift in Sources */, 9F00A6212950F44B005EA316 /* ImagePicker.swift in Sources */, - 638487BA2EC7764800DF442B /* AudiobookShelfLibraryListView.swift in Sources */, 638487BB2EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift in Sources */, 638D91A82E60AAA600B62BDD /* ImportOperationState.swift in Sources */, 63FB9E032E5774A500D1966F /* ListStateManager.swift in Sources */, @@ -4451,7 +4487,6 @@ 638487A32EC7722400DF442B /* AudiobookShelfLibraryItem.swift in Sources */, 638487A42EC7722400DF442B /* AudiobookShelfConnectionService.swift in Sources */, 638487A52EC7722400DF442B /* AudiobookShelfConnectionData.swift in Sources */, - 638487A62EC7722400DF442B /* AudiobookShelfError.swift in Sources */, 638487A72EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift in Sources */, 3F7B64362E0F71E900299D97 /* HardcoverSettingsView.swift in Sources */, 631908A12E33EF50009249C1 /* ConfettiView.swift in Sources */, @@ -4463,9 +4498,7 @@ 3F7B64492E0FB2C100299D97 /* InsertUserBookData.swift in Sources */, 636E77802DF33DA200D4DC0A /* BP+ErrorAlerts.swift in Sources */, 63C48D442E3E9338005FBB96 /* AccountLogoutSectionView.swift in Sources */, - 636E77862DF34F1900D4DC0A /* JellyfinConnectedView.swift in Sources */, 6338C7872E48DA5F008016E5 /* LibraryRootView.swift in Sources */, - 63FCAB1D2E3BBEA0005EB9DE /* TagsFlowLayout.swift in Sources */, 418CABB125EF28FC00D8C878 /* MappingModel_v3_to_v4.xcmappingmodel in Sources */, 63C6C2E82B5029FE00FFE0D8 /* SettingsAutolockViewModel.swift in Sources */, 8AE4AAC52CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift in Sources */, @@ -4484,8 +4517,6 @@ 637043542E2DED05005353D1 /* SettingsMailView.swift in Sources */, 8A2A22392CEFEB8E00E73A2D /* AdaptiveVGrid.swift in Sources */, 631908B02E369FFA009249C1 /* AutoSleepTimerSectionView.swift in Sources */, - 634FF7A22DF73DDE005D1C0D /* JellyfinLibraryListView.swift in Sources */, - 8AE4AACB2CCBAA3800BAA927 /* JellyfinConnectionView.swift in Sources */, 9F00A5FA294F8BFE005EA316 /* ClearableTextField.swift in Sources */, 635771D62F378EE800BB5F59 /* ThemedSection.swift in Sources */, 99329DBF2F3AAB24003F8E73 /* ArtworkView.swift in Sources */, @@ -4589,7 +4620,6 @@ 63AE5A8D2E4C59FE002531B8 /* AppHostingViewController.swift in Sources */, 63B4A3BC2E9186CD00784A22 /* ConfirmationDialogType.swift in Sources */, C3EC372E206EE0650094B4E8 /* SleepTimer.swift in Sources */, - 638487C82EC77AF200DF442B /* AudiobookShelfSettingsView.swift in Sources */, 638487C92EC77AF200DF442B /* AudiobookShelfRootView.swift in Sources */, 6338C7852E486232008016E5 /* ItemListSearchScope.swift in Sources */, 63C48C7A2E3DAC9D005FBB96 /* LoginView.swift in Sources */, @@ -4597,7 +4627,6 @@ 41A1B12F226FE0F900EA0400 /* Notification+BookPlayer.swift in Sources */, 3F6640942E17386400356522 /* DeleteUserBookData.swift in Sources */, 6356F9BD2AC7CFFB00B7A027 /* CustomSleepTimerIntent.swift in Sources */, - 636E77792DF28E9400D4DC0A /* JellyfinLibraryGridView.swift in Sources */, 63C48D512E3F1FCC005FBB96 /* ItemListView.swift in Sources */, 412AB70E2701463100969618 /* LoadingViewModel.swift in Sources */, 637043402E2BFD6C005353D1 /* SettingsStorageSectionView.swift in Sources */, @@ -4605,6 +4634,21 @@ 631908AC2E369E31009249C1 /* GlobalSpeedSectionView.swift in Sources */, F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */, 07416E5AD384927D90BFB6EE /* PasskeyCreatingView.swift in Sources */, + CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */, + 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */, + C3C998E7EA2919BB438B337C /* IntegrationLibraryItemProtocol.swift in Sources */, + 760180C62F243705DE6B3224 /* IntegrationError.swift in Sources */, + 18D0AD99D1AAA10976F75C11 /* BPNavigation.swift in Sources */, + 9236C57F833C70652BDD1FA3 /* TabEditingEnvironmentKey.swift in Sources */, + 11A972EC3C4426DD4D02867E /* TagsFlowLayout.swift in Sources */, + D080B0A77D9844C3A0737170 /* IntegrationConnectionFormViewModel.swift in Sources */, + 8322A3C6479B099448C63C47 /* IntegrationLibraryViewModelProtocol.swift in Sources */, + 9586CECD8FB418C6CFB0A7DD /* IntegrationLibraryView.swift in Sources */, + 41A6E5ED3159D85692581CC9 /* IntegrationLibraryGridView.swift in Sources */, + C53864BEFAE4D1CEC668A6B3 /* IntegrationLibraryListView.swift in Sources */, + F7CB281CF765C468BFA9B0D8 /* IntegrationDetailsViewModelProtocol.swift in Sources */, + A7FA45CB2BD6567C9B5EA372 /* IntegrationTagsView.swift in Sources */, + A34DA648F3EF87A9E11631B3 /* IntegrationAudiobookDetailsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4998,7 +5042,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; @@ -5032,7 +5076,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5064,7 +5108,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerIntents"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5100,7 +5144,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; @@ -5141,7 +5185,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5179,7 +5223,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5348,7 +5392,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; @@ -5386,7 +5430,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5422,7 +5466,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerWidgetUI"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5579,7 +5623,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; @@ -5617,7 +5661,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; @@ -5841,7 +5885,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; @@ -5879,7 +5923,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5915,7 +5959,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).watchkitapp.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5954,7 +5998,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; @@ -5994,7 +6038,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6032,7 +6076,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER).BookPlayerShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6126,7 +6170,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.18.0; + MARKETING_VERSION = 5.19.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = BookPlayer; PROVISIONING_PROFILE_SPECIFIER = "$(BP_PROVISIONING_MAIN)"; diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index 32754418a..2fd37d958 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -214,7 +214,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { func setupMPSkipRemoteCommands() { let center = MPRemoteCommandCenter.shared() // Forward - center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + if PlayerManager.isForwardChapterSkip { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + } center.skipForwardCommand.addTarget { (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = AppServices.shared.coreServices?.playerManager else { return .commandFailed } @@ -244,7 +248,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { } // Rewind - center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + if PlayerManager.isRewindChapterSkip { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + } center.skipBackwardCommand.addTarget { (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = AppServices.shared.coreServices?.playerManager else { return .commandFailed } diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index d04b148f3..04346d128 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -9,86 +9,399 @@ import SwiftUI struct AudiobookShelfRootView: View { - @StateObject var navigation: BPNavigation - @StateObject var connectionViewModel: AudiobookShelfConnectionViewModel + let connectionService: AudiobookShelfConnectionService + + @StateObject private var connectionViewModel: AudiobookShelfConnectionViewModel + + @State private var resolvedLibrary: AudiobookShelfLibraryItem? + @State private var availableLibraries: [AudiobookShelfLibraryItem]? + @State private var loadError: Error? + + private var savedLibraryId: String? { + connectionService.connection?.selectedLibraryId + } @EnvironmentObject private var singleFileDownloadService: SingleFileDownloadService @EnvironmentObject private var theme: ThemeViewModel @Environment(\.dismiss) var dismiss + @Environment(\.listState) private var listState init(connectionService: AudiobookShelfConnectionService) { + self.connectionService = connectionService + self._connectionViewModel = .init( + wrappedValue: .init(connectionService: connectionService) + ) + } + + @State private var showLibraryPicker = false + @State private var showConnectionForm = false + @State private var isLoadingLibraries = false + + private var isReady: Bool { + resolvedLibrary != nil + } + + private var switchLibraryAction: (() -> Void)? { + guard let libraries = availableLibraries, libraries.count > 1 else { return nil } + return { showLibraryPicker = true } + } + + var body: some View { + TabView { + Tab("books_title", systemImage: "books.vertical.fill") { + AudiobookShelfTabRoot( + source: .books(libraryID: resolvedLibrary?.id ?? "", filter: nil), + libraryTitle: resolvedLibrary?.title ?? "", + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + dismissAll: dismiss + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } + Tab("Series", systemImage: "rectangle.stack.fill") { + AudiobookShelfTabRoot( + source: .entities(libraryID: resolvedLibrary?.id ?? "", category: .series), + libraryTitle: resolvedLibrary?.title ?? "", + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + dismissAll: dismiss + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } + Tab("Collections", systemImage: "square.stack.3d.up.fill") { + AudiobookShelfTabRoot( + source: .entities(libraryID: resolvedLibrary?.id ?? "", category: .collections), + libraryTitle: resolvedLibrary?.title ?? "", + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + dismissAll: dismiss + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } + Tab("Authors", systemImage: "person.2.fill") { + AudiobookShelfTabRoot( + source: .entities(libraryID: resolvedLibrary?.id ?? "", category: .authors), + libraryTitle: resolvedLibrary?.title ?? "", + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + dismissAll: dismiss + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } + Tab("Narrators", systemImage: "mic.fill") { + AudiobookShelfTabRoot( + source: .entities(libraryID: resolvedLibrary?.id ?? "", category: .narrators), + libraryTitle: resolvedLibrary?.title ?? "", + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + dismissAll: dismiss + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } + } + .toolbarColorScheme(theme.useDarkVariant ? .dark : .light, for: .tabBar) + .tint(theme.linkColor) + .disabled(!isReady) + .loadingOverlay(isLoadingLibraries) + .alert( + "error_title".localized, + isPresented: .init(get: { loadError != nil }, set: { if !$0 { loadError = nil } }), + actions: { + Button("ok_button".localized) { + loadError = nil + showConnectionForm = true + } + }, + message: { Text(loadError?.localizedDescription ?? "") } + ) + .sheet(isPresented: $showConnectionForm) { + NavigationStack { + IntegrationConnectionView(viewModel: connectionViewModel, integrationName: "AudiobookShelf") + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + if connectionService.connection != nil { + ToolbarItemGroup(placement: .confirmationAction) { + Button("integration_connect_button") { + showConnectionForm = false + Task { await loadLibraries() } + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + } + .tint(theme.linkColor) + .environmentObject(theme) + .interactiveDismissDisabled() + } + .sheet(isPresented: $showLibraryPicker) { + libraryPickerSheet + .interactiveDismissDisabled(resolvedLibrary == nil) + } + .environmentObject(theme) + .onChange(of: availableLibraries) { _, libraries in + if let libraries, libraries.count > 1, resolvedLibrary == nil { + showLibraryPicker = true + } + } + .onChange(of: connectionViewModel.connectionState) { _, newValue in + if newValue == .connected { + showConnectionForm = false + if resolvedLibrary == nil { + Task { await loadLibraries() } + } + } + } + .task { + if connectionService.connection == nil { + showConnectionForm = true + } else if resolvedLibrary == nil { + await loadLibraries() + } + } + } + + // MARK: - Library Picker + + private func selectLibrary(_ library: AudiobookShelfLibraryItem) { + resolvedLibrary = library + connectionService.saveSelectedLibrary(id: library.id) + showLibraryPicker = false + } + + private var libraryPickerSheet: some View { + NavigationStack { + List(availableLibraries ?? []) { library in + Button { + selectLibrary(library) + } label: { + HStack { + AudiobookShelfLibraryItemImageView(item: library) + .frame(width: 50, height: 50) + VStack(alignment: .leading) { + Text(library.title) + .foregroundStyle(theme.primaryColor) + if let subtitle = library.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(theme.secondaryColor) + } + } + Spacer() + if library.id == resolvedLibrary?.id { + Image(systemName: "checkmark") + .foregroundStyle(theme.linkColor) + } + } + } + } + .scrollContentBackground(.hidden) + .background(theme.systemBackgroundColor) + .navigationTitle("library_title".localized) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if resolvedLibrary != nil { + ToolbarItem(placement: .cancellationAction) { + Button("done_title".localized) { showLibraryPicker = false } + } + } + } + } + .environmentObject(theme) + } + + private func loadLibraries() async { + isLoadingLibraries = true + defer { isLoadingLibraries = false } + do { + let libraries = try await connectionService.fetchLibraries() + let bookLibraries = libraries + .filter { $0.mediaType == "book" } + .map(AudiobookShelfLibraryItem.init(library:)) + + if bookLibraries.count == 1, let library = bookLibraries.first { + selectLibrary(library) + } else if let savedId = savedLibraryId, + let saved = bookLibraries.first(where: { $0.id == savedId }) { + selectLibrary(saved) + availableLibraries = bookLibraries + } else { + availableLibraries = bookLibraries + } + } catch is CancellationError { + // ignore + } catch { + loadError = error + } + } +} + + +// MARK: - Per-Tab NavigationStack + +/// Each tab owns its own NavigationStack and BPNavigation. +/// This matches the MainView pattern where each tab has independent navigation. +private struct AudiobookShelfTabRoot: View { + let connectionService: AudiobookShelfConnectionService + let singleFileDownloadService: SingleFileDownloadService + let onDismiss: () -> Void + var onSwitchLibrary: (() -> Void)? + var dismissAll: DismissAction? + + @StateObject private var navigation = BPNavigation() + @StateObject var viewModel: AudiobookShelfLibraryViewModel + @State private var isEditing = false + @State private var showConnectionDetails = false + + @EnvironmentObject private var theme: ThemeViewModel + + init( + source: AudiobookShelfLibraryViewSource, + libraryTitle: String, + connectionService: AudiobookShelfConnectionService, + singleFileDownloadService: SingleFileDownloadService, + onDismiss: @escaping () -> Void, + onSwitchLibrary: (() -> Void)? = nil, + dismissAll: DismissAction? = nil + ) { + self.connectionService = connectionService + self.singleFileDownloadService = singleFileDownloadService + self.dismissAll = dismissAll + self.onDismiss = onDismiss + self.onSwitchLibrary = onSwitchLibrary + let navigation = BPNavigation() self._navigation = .init(wrappedValue: navigation) - self._connectionViewModel = .init( - wrappedValue: .init( + self._viewModel = .init( + wrappedValue: AudiobookShelfLibraryViewModel( + source: source, connectionService: connectionService, - navigation: navigation + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: libraryTitle ) ) } var body: some View { NavigationStack(path: $navigation.path) { - AudiobookShelfConnectionView(viewModel: connectionViewModel) - .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - cancelToolbarButton - } - } + AudiobookShelfLibraryView(viewModel: viewModel) .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: AudiobookShelfLibraryLevelData.self) { destination in switch destination { - case .topLevel(let libraryName): + case .library(source: let source, title: let title): AudiobookShelfLibraryView( viewModel: AudiobookShelfLibraryViewModel( - libraryID: nil, - connectionService: connectionViewModel.connectionService, + source: source, + connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, navigation: navigation, - navigationTitle: libraryName - ) - ) - case .library(let item): - AudiobookShelfLibraryView( - viewModel: AudiobookShelfLibraryViewModel( - libraryID: item.id, - connectionService: connectionViewModel.connectionService, - singleFileDownloadService: singleFileDownloadService, - navigation: navigation, - navigationTitle: item.title + navigationTitle: title ) ) case .details(let item): AudiobookShelfAudiobookDetailsView( viewModel: AudiobookShelfAudiobookDetailsViewModel( item: item, - connectionService: connectionViewModel.connectionService, + connectionService: connectionService, singleFileDownloadService: singleFileDownloadService ) ) { - dismiss() + onDismiss() + } + } + } + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + Menu { + Button { + showConnectionDetails = true + } label: { + Label("integration_connection_details_title".localized, systemImage: "server.rack") + } + Button { + onDismiss() + } label: { + Label("voiceover_close_button", systemImage: "xmark") + } + } label: { + Image(systemName: "gearshape") + .foregroundStyle(theme.linkColor) + } + .accessibilityLabel("settings_title") + } + if let onSwitchLibrary { + ToolbarItem(placement: .topBarTrailing) { + Button { + onSwitchLibrary() + } label: { + Image(systemName: "building.columns") + .foregroundStyle(theme.linkColor) + } + .accessibilityLabel("Switch Library") } } } } + .environment(\.tabEditing, $isEditing) + .toolbar(isEditing ? .hidden : .visible, for: .tabBar) .tint(theme.linkColor) - .environmentObject(theme) - .onAppear { - navigation.dismiss = dismiss - } - } - - @ViewBuilder - private var cancelToolbarButton: some View { - Button( - action: { - dismiss() - }, - label: { - Image(systemName: "xmark") - .foregroundStyle(theme.linkColor) + .sheet(isPresented: $showConnectionDetails) { + NavigationStack { + IntegrationSettingsView( + viewModel: AudiobookShelfConnectionViewModel( + connectionService: connectionService, + mode: .viewDetails + ), + integrationName: "AudiobookShelf" + ) + .toolbar { + if connectionService.connection == nil { + ToolbarItemGroup(placement: .cancellationAction) { + Button { + dismissAll?() + } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } else { + ToolbarItemGroup(placement: .confirmationAction) { + Button("done_title".localized) { + showConnectionDetails = false + } + } + } + } } - ) + .tint(theme.linkColor) + .environmentObject(theme) + } } } diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfSettingsView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfSettingsView.swift deleted file mode 100644 index ff1115928..000000000 --- a/BookPlayer/AudiobookShelf/AudiobookShelfSettingsView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AudiobookShelfSettingsView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import SwiftUI - -struct AudiobookShelfSettingsView: View { - @StateObject var viewModel: AudiobookShelfConnectionViewModel - - var body: some View { - AudiobookShelfConnectionView(viewModel: viewModel) - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectedView.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectedView.swift deleted file mode 100644 index eb5837fb4..000000000 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectedView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// AudiobookShelfConnectedView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import SwiftUI - -struct AudiobookShelfConnectedView: View { - @ObservedObject var viewModel: AudiobookShelfConnectionViewModel - @EnvironmentObject var theme: ThemeViewModel - - var body: some View { - ThemedSection { - HStack { - Text("integration_username_placeholder") - .foregroundStyle(theme.secondaryColor) - Spacer() - Text(viewModel.form.username) - } - } header: { - Text("integration_section_login") - .foregroundStyle(theme.secondaryColor) - } - - ThemedSection { - Button("logout_title", role: .destructive) { - viewModel.handleSignOutAction() - } - .frame(maxWidth: .infinity) - .foregroundStyle(.red) - } - } -} diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionFormViewModel.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionFormViewModel.swift deleted file mode 100644 index 9a0c0c688..000000000 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionFormViewModel.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// AudiobookShelfConnectionFormViewModel.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import Foundation - -class AudiobookShelfConnectionFormViewModel: ObservableObject { - @Published var serverUrl: String = "" - @Published var serverName: String = "" - @Published var username: String = "" - @Published var password: String = "" - - func setValues(from connection: AudiobookShelfConnectionData) { - serverUrl = connection.url.absoluteString - serverName = connection.serverName - username = connection.userName - } -} diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionView.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionView.swift deleted file mode 100644 index 81d25370c..000000000 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionView.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// AudiobookShelfConnectionView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import BookPlayerKit -import SwiftUI - -enum AudiobookShelfConnectionViewField: Focusable { - case none - case serverUrl, username, password -} - -struct AudiobookShelfConnectionView: View { - /// View model for the form - @ObservedObject var viewModel: AudiobookShelfConnectionViewModel - - @State private var firstAppear = true - @State private var isLoading = false - @State private var error: Error? - - @EnvironmentObject var theme: ThemeViewModel - - @Environment(\.dismiss) var dismiss - - var body: some View { - Form { - switch viewModel.connectionState { - case .disconnected: - AudiobookShelfDisconnectedView( - serverUrl: $viewModel.form.serverUrl, - onCommit: onConnect - ) - case .foundServer: - AudiobookShelfServerInformationSectionView( - serverName: viewModel.form.serverName, - serverUrl: viewModel.form.serverUrl - ) - AudiobookShelfServerFoundView( - username: $viewModel.form.username, - password: $viewModel.form.password, - onCommit: onSignIn - ) - case .connected: - AudiobookShelfServerInformationSectionView( - serverName: viewModel.form.serverName, - serverUrl: viewModel.form.serverUrl - ) - AudiobookShelfConnectedView(viewModel: viewModel) - } - } - .scrollContentBackground(.hidden) - .background(theme.systemBackgroundColor) - .errorAlert(error: $error) - .overlay { - Group { - if isLoading { - ProgressView() - .tint(.white) - .padding() - .background( - Color.black - .opacity(0.9) - .clipShape(RoundedRectangle(cornerRadius: 10)) - ) - .ignoresSafeArea(.all) - } - } - } - .toolbar { - ToolbarItem(placement: .principal) { - Text(localizedNavigationTitle) - .bpFont(.headline) - .foregroundStyle(theme.primaryColor) - } - ToolbarItemGroup(placement: .confirmationAction) { - switch (viewModel.viewMode, viewModel.connectionState) { - case (_, .disconnected): - connectToolbarButton - case (_, .foundServer): - signInToolbarButton - case (.regular, .connected): - goToLibraryToolbarButton - case (.viewDetails, .connected): - EmptyView() - } - } - } - .tint(theme.linkColor) - } - - // MARK: Utils - - func onConnect() { - isLoading = true - Task { - do { - try await viewModel.handleConnectAction() - isLoading = false - } catch { - isLoading = false - self.error = error - } - } - } - - func onSignIn() { - isLoading = true - Task { - do { - try await viewModel.handleSignInAction() - isLoading = false - } catch { - isLoading = false - self.error = error - } - } - } - - // MARK: - Navigation Title - - private var localizedNavigationTitle: String { - switch viewModel.connectionState { - case .disconnected, .foundServer: "AudiobookShelf" - case .connected: "integration_connection_details_title".localized - } - } - - // MARK: - Navigation Buttons - - @ViewBuilder - private var connectToolbarButton: some View { - Button( - "integration_connect_button", - action: onConnect - ) - .foregroundStyle(theme.linkColor) - .disabledWithOpacity(viewModel.form.serverUrl.isEmpty) - } - - @ViewBuilder - private var signInToolbarButton: some View { - Button( - "integration_sign_in_button", - action: onSignIn - ) - .foregroundStyle(theme.linkColor) - .disabledWithOpacity( - viewModel.form.serverUrl.isEmpty || viewModel.form.username.isEmpty - ) - } - - @ViewBuilder - private var goToLibraryToolbarButton: some View { - Button( - "library_title".localized, - systemImage: "chevron.forward", - action: viewModel.handleGoToLibraryAction - ) - .foregroundStyle(theme.linkColor) - } -} - -#Preview("disconnected") { - let viewModel = AudiobookShelfConnectionViewModel( - connectionService: AudiobookShelfConnectionService(), - navigation: BPNavigation() - ) - AudiobookShelfConnectionView(viewModel: viewModel) - .environmentObject(ThemeViewModel()) -} - -#Preview("found server") { - let viewModel = { - let viewModel = AudiobookShelfConnectionViewModel( - connectionService: AudiobookShelfConnectionService(), - navigation: BPNavigation() - ) - viewModel.connectionState = .foundServer - viewModel.form.serverName = "Mock Server" - viewModel.form.serverUrl = "http://example.com" - return viewModel - }() - AudiobookShelfConnectionView(viewModel: viewModel) - .environmentObject(ThemeViewModel()) -} - -#Preview("connected") { - let viewModel = { - let viewModel = AudiobookShelfConnectionViewModel( - connectionService: AudiobookShelfConnectionService(), - navigation: BPNavigation() - ) - viewModel.connectionState = .connected - viewModel.form.serverName = "Mock Server" - viewModel.form.serverUrl = "http://example.com" - viewModel.form.username = "Mock User" - viewModel.form.password = "secret" - return viewModel - }() - AudiobookShelfConnectionView(viewModel: viewModel) - .environmentObject(ThemeViewModel()) -} diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift index 4a1054d4d..7e906e4c7 100644 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift +++ b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift @@ -11,47 +11,26 @@ import Combine import SwiftUI @MainActor -final class AudiobookShelfConnectionViewModel: ObservableObject, BPLogger { - enum ViewMode { - case regular // for the "Download from AudiobookShelf" flow - case viewDetails // for the connection details + sign out option from the Settings screen - } - - enum ConnectionState { - case disconnected - case foundServer - case connected - } - +final class AudiobookShelfConnectionViewModel: IntegrationConnectionViewModelProtocol, BPLogger { let connectionService: AudiobookShelfConnectionService - var navigation: BPNavigation - @Published var form: AudiobookShelfConnectionFormViewModel - @Published var viewMode: ViewMode = .regular - @Published var connectionState: ConnectionState + @Published var form: IntegrationConnectionFormViewModel + @Published var viewMode: IntegrationViewMode = .regular + @Published var connectionState: IntegrationConnectionState private var disposeBag = Set() init( connectionService: AudiobookShelfConnectionService, - navigation: BPNavigation, - mode: ViewMode = .regular + mode: IntegrationViewMode = .regular ) { self.connectionService = connectionService self._viewMode = .init(initialValue: mode) - let form = AudiobookShelfConnectionFormViewModel() - - self.navigation = navigation + let form = IntegrationConnectionFormViewModel() if let data = connectionService.connection { - form.setValues(from: data) + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) self._connectionState = .init(initialValue: .connected) - - Task { @MainActor in - navigation.path.append( - AudiobookShelfLibraryLevelData.topLevel(libraryName: form.serverName) - ) - } } else { self._connectionState = .init(initialValue: .disconnected) } @@ -77,11 +56,8 @@ final class AudiobookShelfConnectionViewModel: ObservableObject, BPLogger { ) connectionState = .connected - navigation.path.append( - AudiobookShelfLibraryLevelData.topLevel(libraryName: form.serverName) - ) - } catch let error as AudiobookShelfError { - throw error.localizedDescription + } catch let error as IntegrationError { + throw error } catch { throw error } @@ -90,14 +66,7 @@ final class AudiobookShelfConnectionViewModel: ObservableObject, BPLogger { @MainActor func handleSignOutAction() { connectionService.deleteConnection() - form = AudiobookShelfConnectionFormViewModel() + form = IntegrationConnectionFormViewModel() connectionState = .disconnected } - - @MainActor - func handleGoToLibraryAction() { - navigation.path.append( - AudiobookShelfLibraryLevelData.topLevel(libraryName: form.serverName) - ) - } } diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfDisconnectedView.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfDisconnectedView.swift deleted file mode 100644 index e0bed1e1e..000000000 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfDisconnectedView.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// AudiobookShelfDisconnectedView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import SwiftUI - -enum AudiobookShelfDisconnectedViewFields: Focusable { - case none, serverUrl -} - -struct AudiobookShelfDisconnectedView: View { - @Binding var serverUrl: String - - @State var focusedField: AudiobookShelfDisconnectedViewFields = .none - - @EnvironmentObject var theme: ThemeViewModel - - var onCommit: () -> Void = {} - - var body: some View { - ThemedSection { - ClearableTextField( - "http://audiobookshelf.example.com", - text: $serverUrl, - onCommit: { - if !serverUrl.isEmpty { - onCommit() - } - } - ) - .keyboardType(.URL) - .textContentType(.URL) - .autocapitalization(.none) - .focused($focusedField, selfKey: .serverUrl) - } header: { - Text("integration_section_server_url") - .foregroundStyle(theme.secondaryColor) - } footer: { - Text( - String( - format: "integration_section_server_url_footer".localized, - "AudiobookShelf" - ) - ) - .foregroundStyle(theme.secondaryColor) - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focusedField = .serverUrl - } - } - } -} diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfServerFoundView.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfServerFoundView.swift deleted file mode 100644 index c29dd726c..000000000 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfServerFoundView.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// AudiobookShelfServerFoundView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import SwiftUI - -enum AudiobookShelfServerFoundViewFields: Focusable { - case none, username, password -} - -struct AudiobookShelfServerFoundView: View { - @Binding var username: String - @Binding var password: String - - @State var focusedField: AudiobookShelfServerFoundViewFields = .none - - @EnvironmentObject var theme: ThemeViewModel - - var onCommit: () -> Void = {} - - var body: some View { - ThemedSection { - ClearableTextField( - "integration_username_placeholder".localized, - text: $username, - onCommit: { - focusedField = .password - } - ) - .textContentType(.username) - .autocapitalization(.none) - .focused($focusedField, selfKey: .username) - - SecureField( - "integration_password_placeholder", - text: $password, - onCommit: { - if !username.isEmpty && !password.isEmpty { - onCommit() - } - } - ) - .textContentType(.password) - .focused($focusedField, selfKey: .password) - } header: { - Text("integration_section_login") - .foregroundStyle(theme.secondaryColor) - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focusedField = .username - } - } - } -} diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfServerInformationSectionView.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfServerInformationSectionView.swift deleted file mode 100644 index 363752569..000000000 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfServerInformationSectionView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// AudiobookShelfServerInformationSectionView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import SwiftUI - -struct AudiobookShelfServerInformationSectionView: View { - let serverName: String - let serverUrl: String - - @EnvironmentObject var theme: ThemeViewModel - - var body: some View { - ThemedSection { - HStack { - Text("integration_server_name_label") - .foregroundStyle(theme.secondaryColor) - Spacer() - Text(serverName) - } - HStack { - Text("integration_server_url_label") - .foregroundStyle(theme.secondaryColor) - Spacer() - Text(serverUrl) - } - } header: { - Text("integration_section_server") - .foregroundStyle(theme.secondaryColor) - } - } -} diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift index a2a718d8d..9d7558379 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift @@ -8,32 +8,52 @@ import Foundation -struct AudiobookShelfLibraryItem: Identifiable, Hashable, Codable { +struct AudiobookShelfSeriesReference: Codable, Hashable { + let id: String + let name: String + let sequence: String? +} + +struct AudiobookShelfLibraryItem: IntegrationLibraryItemProtocol, Codable { enum Kind: String, Codable { case audiobook = "book" case podcast = "podcast" case library = "library" + case browseCategory = "browseCategory" + case series = "series" + case collection = "collection" + case author = "author" + case narrator = "narrator" } let id: String let title: String let kind: Kind let libraryId: String - + // Metadata let authorName: String? let narratorName: String? let duration: TimeInterval? let size: Int64? - + let subtitle: String? + let series: [AudiobookShelfSeriesReference]? + let addedAt: Int64? + let updatedAt: Int64? + // Cover image let coverPath: String? - + let coverItemId: String? + // Progress (if included) let progress: Double? let currentTime: TimeInterval? let isFinished: Bool? - + + // Browse metadata + let browseCategory: AudiobookShelfBrowseCategory? + let filter: AudiobookShelfItemFilter? + init( id: String, title: String, @@ -43,10 +63,17 @@ struct AudiobookShelfLibraryItem: Identifiable, Hashable, Codable { narratorName: String? = nil, duration: TimeInterval? = nil, size: Int64? = nil, + subtitle: String? = nil, + series: [AudiobookShelfSeriesReference]? = nil, + addedAt: Int64? = nil, + updatedAt: Int64? = nil, coverPath: String? = nil, + coverItemId: String? = nil, progress: Double? = nil, currentTime: TimeInterval? = nil, - isFinished: Bool? = nil + isFinished: Bool? = nil, + browseCategory: AudiobookShelfBrowseCategory? = nil, + filter: AudiobookShelfItemFilter? = nil ) { self.id = id self.title = title @@ -56,14 +83,120 @@ struct AudiobookShelfLibraryItem: Identifiable, Hashable, Codable { self.narratorName = narratorName self.duration = duration self.size = size + self.subtitle = subtitle + self.series = series + self.addedAt = addedAt + self.updatedAt = updatedAt self.coverPath = coverPath + self.coverItemId = coverItemId self.progress = progress self.currentTime = currentTime self.isFinished = isFinished + self.browseCategory = browseCategory + self.filter = filter } } extension AudiobookShelfLibraryItem { + var displayName: String { title } + + var isDownloadable: Bool { + kind == .audiobook || kind == .podcast + } + + var isNavigable: Bool { + !isDownloadable + } + + var placeholderImageName: String { + switch kind { + case .podcast, .audiobook: "waveform" + case .library: "folder" + case .browseCategory: + switch browseCategory { + case .books: "books.vertical" + case .series: "rectangle.stack" + case .collections: "square.stack.3d.up" + case .authors: "person.2" + case .narrators: "mic" + case .none: "square.grid.2x2" + } + case .series: "rectangle.stack" + case .collection: "square.stack.3d.up" + case .author: "person" + case .narrator: "mic" + } + } + + func seriesSequence(for seriesID: String) -> String? { + series?.first(where: { $0.id == seriesID })?.sequence + } + + init(library: AudiobookShelfLibrary) { + self.init( + id: library.id, + title: library.name, + kind: .library, + libraryId: library.id, + subtitle: library.mediaType == "podcast" ? "Podcast library" : "Audiobook library" + ) + } + + init(category: AudiobookShelfBrowseCategory, libraryId: String) { + self.init( + id: category.rawValue, + title: category.title, + kind: .browseCategory, + libraryId: libraryId, + subtitle: "Browse by \(category.title.lowercased())", + browseCategory: category + ) + } + + init(author: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { + self.init( + id: author.id, + title: author.name, + kind: .author, + libraryId: libraryId, + subtitle: "Author", + filter: AudiobookShelfItemFilter(group: .authors, value: author.id, title: author.name) + ) + } + + init(series: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { + self.init( + id: series.id, + title: series.name, + kind: .series, + libraryId: libraryId, + subtitle: "Series", + filter: AudiobookShelfItemFilter(group: .series, value: series.id, title: series.name) + ) + } + + init(narrator: String, libraryId: String) { + self.init( + id: narrator, + title: narrator, + kind: .narrator, + libraryId: libraryId, + subtitle: "Narrator", + filter: AudiobookShelfItemFilter(group: .narrators, value: narrator, title: narrator) + ) + } + + init(collection: AudiobookShelfCollection) { + self.init( + id: collection.id, + title: collection.name, + kind: .collection, + libraryId: collection.libraryId, + subtitle: collection.description ?? "\(collection.books.count) books", + coverItemId: collection.books.first?.id + ) + } + init?(apiItem: AudiobookShelfAPIItem) { guard let mediaType = apiItem.mediaType, let kind = Kind(rawValue: mediaType) else { @@ -75,10 +208,13 @@ extension AudiobookShelfLibraryItem { title: apiItem.media.metadata.title, kind: kind, libraryId: apiItem.libraryId, - authorName: apiItem.media.metadata.authorName, - narratorName: apiItem.media.metadata.narratorName, + authorName: apiItem.media.metadata.primaryAuthorName, + narratorName: apiItem.media.metadata.primaryNarratorName, duration: apiItem.media.duration, size: apiItem.size, + series: apiItem.media.metadata.series, + addedAt: apiItem.addedAt, + updatedAt: apiItem.updatedAt, coverPath: apiItem.media.coverPath, progress: apiItem.userMediaProgress?.progress, currentTime: apiItem.userMediaProgress?.currentTime, @@ -92,23 +228,68 @@ extension AudiobookShelfLibraryItem { struct AudiobookShelfAPIItem: Codable { let id: String let libraryId: String + let addedAt: Int64? + let updatedAt: Int64? let mediaType: String? let media: Media let size: Int64? let userMediaProgress: UserMediaProgress? - + struct Media: Codable { let metadata: Metadata let coverPath: String? let duration: TimeInterval? - + struct Metadata: Codable { let title: String let authorName: String? let narratorName: String? + let authors: [NamedEntity]? + let narrators: [String]? + let series: [AudiobookShelfSeriesReference]? + + enum CodingKeys: String, CodingKey { + case title + case authorName + case narratorName + case authors + case narrators + case series + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + title = try container.decode(String.self, forKey: .title) + authorName = try container.decodeIfPresent(String.self, forKey: .authorName) + narratorName = try container.decodeIfPresent(String.self, forKey: .narratorName) + authors = try container.decodeIfPresent([NamedEntity].self, forKey: .authors) + narrators = try container.decodeIfPresent([String].self, forKey: .narrators) + + if let seriesArray = try? container.decode([AudiobookShelfSeriesReference].self, forKey: .series) { + series = seriesArray + } else if let seriesSingle = try? container.decode(AudiobookShelfSeriesReference.self, forKey: .series) { + series = [seriesSingle] + } else { + series = nil + } + } + + var primaryAuthorName: String? { + authorName ?? authors?.first?.name + } + + var primaryNarratorName: String? { + narratorName ?? narrators?.first + } + } + + struct NamedEntity: Codable { + let id: String + let name: String } } - + struct UserMediaProgress: Codable { let progress: Double let currentTime: TimeInterval @@ -130,3 +311,29 @@ struct AudiobookShelfSearchResponse: Codable { let libraryItem: AudiobookShelfAPIItem } } + +struct AudiobookShelfLibraryFilterData: Codable { + let authors: [NamedEntity] + let genres: [String] + let tags: [String] + let series: [NamedEntity] + let narrators: [String] + let languages: [String] + + struct NamedEntity: Codable, Hashable { + let id: String + let name: String + } +} + +struct AudiobookShelfCollection: Codable { + let id: String + let libraryId: String + let name: String + let description: String? + let books: [AudiobookShelfAPIItem] +} + +struct AudiobookShelfCollectionsResponse: Codable { + let results: [AudiobookShelfCollection] +} diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift index 095fdc2be..37bc42c3d 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift @@ -48,7 +48,7 @@ fileprivate struct AudiobookShelfLibraryItemImageViewWrapper: View, Equatable { } static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.item.kind == rhs.item.kind && lhs.item.id == rhs.item.id + return lhs.item.id == rhs.item.id && lhs.url == rhs.url } @ViewBuilder @@ -65,9 +65,6 @@ fileprivate struct AudiobookShelfLibraryItemImageViewWrapper: View, Equatable { } private var placeholderImageName: String { - switch item.kind { - case .podcast, .audiobook: "waveform" - case .library: "folder" - } + item.placeholderImageName } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift index 50b3d6601..fe61bbf5a 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift @@ -8,8 +8,58 @@ import Foundation +enum AudiobookShelfBrowseCategory: String, CaseIterable, Codable, Hashable { + case books + case series + case collections + case authors + case narrators + + var title: String { + switch self { + case .books: "Books" + case .series: "Series" + case .collections: "Collections" + case .authors: "Authors" + case .narrators: "Narrators" + } + } +} + +enum AudiobookShelfItemFilterGroup: String, Codable, Hashable { + case authors + case series + case narrators +} + +struct AudiobookShelfItemFilter: Codable, Hashable { + let group: AudiobookShelfItemFilterGroup + let value: String + let title: String + + var queryValue: String { + let base64Value = Data(value.utf8).base64EncodedString() + return "\(group.rawValue).\(base64Value)" + } +} + +enum AudiobookShelfLibraryViewSource: Equatable, Hashable { + case libraries + case books(libraryID: String, filter: AudiobookShelfItemFilter?) + case entities(libraryID: String, category: AudiobookShelfBrowseCategory) + case collection(id: String) + + var libraryID: String { + switch self { + case .libraries: "" + case .books(let libraryID, _): libraryID + case .entities(let libraryID, _): libraryID + case .collection(let id): id + } + } +} + enum AudiobookShelfLibraryLevelData: Equatable, Hashable { - case topLevel(libraryName: String) - case library(data: AudiobookShelfLibraryItem) + case library(source: AudiobookShelfLibraryViewSource, title: String) case details(data: AudiobookShelfLibraryItem) } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift index 44e3acc76..918205861 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift @@ -6,125 +6,43 @@ // Copyright © 2025 BookPlayer LLC. All rights reserved. // -import BookPlayerKit -import Kingfisher import SwiftUI -struct AudiobookShelfLibraryView: View { +/// Thin wrapper providing AudiobookShelf-specific cell, row, sort picker, and environment +/// to the shared `IntegrationLibraryView`. +struct AudiobookShelfLibraryView: View +where Model.Item == AudiobookShelfLibraryItem { @StateObject var viewModel: Model - @EnvironmentObject private var theme: ThemeViewModel - - var navigationTitle: Text { - if viewModel.editMode.isEditing, !viewModel.selectedItems.isEmpty { - return Text( - String(format: "integration_selection_count".localized, viewModel.selectedItems.count, viewModel.totalItems) - ) - } else { - return Text(viewModel.navigationTitle) - } - } var body: some View { - Group { - if viewModel.layout == .grid { - ScrollView { - AudiobookShelfLibraryGridView(viewModel: viewModel) - .padding() - } - } else { - AudiobookShelfLibraryListView(viewModel: viewModel) - .scrollContentBackground(.hidden) - } - } - .scrollDismissesKeyboard(.interactively) - .background(theme.systemBackgroundColor) - .environment(\.audiobookshelfService, viewModel.connectionService) - .modifier(ConditionalSearchableModifier(isSearchable: viewModel.isSearchable, text: $viewModel.searchQuery)) - .searchPresentationToolbarBehavior(.avoidHidingContent) - .onAppear { viewModel.fetchInitialItems() } - .onDisappear { viewModel.cancelFetchItems() } - .errorAlert(error: $viewModel.error) - .environment(\.editMode, $viewModel.editMode) - .toolbar { - ToolbarItem(placement: .principal) { - navigationTitle - .bpFont(.headline) - .foregroundStyle(theme.primaryColor) - } - ToolbarItemGroup(placement: .topBarTrailing) { - toolbarTrailing - } - } - .toolbar { - if viewModel.editMode.isEditing { - ToolbarItemGroup(placement: .bottomBar) { - bottomBar - } - } - } + IntegrationLibraryView( + viewModel: viewModel, + gridCell: { item in + AudiobookShelfLibraryGridItemView( + item: item, + isSelected: viewModel.selectedItems.contains(item.id) + ) + }, + listRow: { item in + AudiobookShelfLibraryListItemView(item: item) + }, + sortPicker: { + sortPickerContent + } + ) + .environment(\.audiobookshelfService, (viewModel as? AudiobookShelfLibraryViewModel)?.connectionService ?? .init()) } @ViewBuilder - var toolbarTrailing: some View { - if !viewModel.editMode.isEditing { - Menu { - ThemedSection { - Button(action: viewModel.onEditToggleSelectTapped) { - Label("select_title".localized, systemImage: "checkmark.circle") - } - } - - layoutPreferences - } label: { - Label("more_title".localized, systemImage: "ellipsis.circle") - } - } else { - Button(action: viewModel.onEditToggleSelectTapped) { - Text("done_title".localized).bold() - } - } - } - - @ViewBuilder - var layoutPreferences: some View { - ThemedSection { - Picker(selection: $viewModel.layout, label: Text("Layout options".localized)) { - Label("Grid".localized, systemImage: "square.grid.2x2").tag(AudiobookShelfLayout.Options.grid) - Label("List".localized, systemImage: "list.bullet").tag(AudiobookShelfLayout.Options.list) - } - } - ThemedSection { - Picker(selection: $viewModel.sortBy, label: Text("Sort by".localized)) { + private var sortPickerContent: some View { + if let vm = viewModel as? AudiobookShelfLibraryViewModel { + Picker(selection: Binding( + get: { vm.sortBy }, + set: { vm.sortBy = $0 } + ), label: Text("Sort by".localized)) { Label("sort_most_recent_button", systemImage: "clock").tag(AudiobookShelfLayout.SortBy.recent) Label("Title".localized, systemImage: "textformat.abc").tag(AudiobookShelfLayout.SortBy.title) } } } - - @ViewBuilder - var bottomBar: some View { - Button(action: viewModel.onSelectAllTapped) { - Image(systemName: viewModel.selectedItems.isEmpty ? "checklist.checked" : "checklist.unchecked") - } - - Spacer() - - Button(action: viewModel.onDownloadTapped) { - Image(systemName: "arrow.down.to.line") - } - .disabled(viewModel.selectedItems.isEmpty) - } -} - -private struct ConditionalSearchableModifier: ViewModifier { - let isSearchable: Bool - @Binding var text: String - - func body(content: Content) -> some View { - if isSearchable { - content.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) - } else { - content - } - } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift index 1932eea39..9844ed8b4 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift @@ -11,69 +11,28 @@ import Combine import Foundation import SwiftUI -protocol AudiobookShelfLibraryViewModelProtocol: ObservableObject { - var navigation: BPNavigation { get set } - var navigationTitle: String { get } - var layout: AudiobookShelfLayout.Options { get set } - var sortBy: AudiobookShelfLayout.SortBy { get set } - - var items: [AudiobookShelfLibraryItem] { get set } - var totalItems: Int { get } - var error: Error? { get set } - - var editMode: EditMode { get set } - var selectedItems: Set { get set } - - var searchQuery: String { get set } - var isSearchable: Bool { get } - - var connectionService: AudiobookShelfConnectionService { get } - - func fetchInitialItems() - func fetchMoreItemsIfNeeded(currentItem: AudiobookShelfLibraryItem) - func cancelFetchItems() - - @MainActor - func handleDoneAction() - - @MainActor - func onEditToggleSelectTapped() - @MainActor - func onSelectTapped(for item: AudiobookShelfLibraryItem) - @MainActor - func onSelectAllTapped() - @MainActor - func onDownloadTapped() -} - enum AudiobookShelfLayout { - enum Options: String { - case grid, list - } - enum SortBy: String { case recent, title } } -final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtocol, BPLogger { +final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLogger { enum Routes { case done } var navigation: BPNavigation let navigationTitle: String + let source: AudiobookShelfLibraryViewSource @AppStorage(Constants.UserDefaults.audiobookshelfLibraryLayout) - var layout: AudiobookShelfLayout.Options = .grid + var layout: IntegrationLayout.Options = .grid @AppStorage(Constants.UserDefaults.audiobookshelfLibraryLayoutSortBy) var sortBy: AudiobookShelfLayout.SortBy = .recent { didSet { - guard let libraryID = libraryID else { return } - items = [] - nextPage = 0 - fetchLibraryItems(libraryID: libraryID) + handleSortChanged() } } @@ -85,16 +44,14 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc @Published var editMode: EditMode = .inactive @Published var selectedItems: Set = [] - var isSearchable: Bool { libraryID != nil } - var onTransition: BPTransition? - var libraryID: String? let connectionService: AudiobookShelfConnectionService private let singleFileDownloadService: SingleFileDownloadService private var fetchTask: Task<(), any Error>? private var nextPage = 0 + private var allItems: [AudiobookShelfLibraryItem] = [] private static let itemBatchSize = 20 private static let itemFetchMargin = 3 @@ -102,25 +59,79 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc private var disposeBag = Set() - var canFetchMoreItems: Bool { - items.count < totalItems + var isSearchable: Bool { + switch source { + case .books(_, .none): + true + case .books(_, _), .entities(_, _), .collection(_): + true + case .libraries: + false + } + } + + var isGridEnabled: Bool { + switch source { + case .libraries, .books(_, _), .collection(_), .entities(_, _): + true + } + } + + var showsLayoutPreferences: Bool { + isGridEnabled + } + + var showsSortPreferences: Bool { + switch source { + case .books(_, _), .collection(_): + true + case .libraries, .entities(_, _): + false + } + } + + var allowsEditing: Bool { + switch source { + case .books(_, _), .collection(_): + true + case .libraries, .entities(_, _): + false + } + } + + private var usesRemoteBookSearch: Bool { + if case .books(_, .none) = source { + return true + } + return false + } + + private var usesPagedFetching: Bool { + if case .books(_, .none) = source, searchQuery.isEmpty { + return true + } + return false + } + + private var canFetchMoreItems: Bool { + usesPagedFetching && items.count < totalItems } init( - libraryID: String?, + source: AudiobookShelfLibraryViewSource, connectionService: AudiobookShelfConnectionService, singleFileDownloadService: SingleFileDownloadService, navigation: BPNavigation, navigationTitle: String ) { - self.libraryID = libraryID + self.source = source self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService self.navigation = navigation self.navigationTitle = navigationTitle $searchQuery - .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .debounce(for: .milliseconds(350), scheduler: RunLoop.main) .removeDuplicates() .dropFirst() .sink { [weak self] _ in @@ -130,14 +141,17 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } func fetchInitialItems() { - fetchMoreItems() + guard items.isEmpty, fetchTask == nil else { return } + // Don't fetch if source has an empty library ID (library not yet resolved) + guard !source.libraryID.isEmpty else { return } + fetchSourceItems() } func fetchMoreItemsIfNeeded(currentItem: AudiobookShelfLibraryItem) { - guard items.count >= Self.itemFetchMargin else { return } + guard canFetchMoreItems, items.count >= Self.itemFetchMargin else { return } let thresholdIndex = items.index(items.endIndex, offsetBy: -Self.itemFetchMargin) if items.firstIndex(where: { $0.id == currentItem.id }) == thresholdIndex { - fetchMoreItems() + fetchBooksPage() } } @@ -146,45 +160,152 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc fetchTask = nil } - private func fetchMoreItems() { - guard fetchTask == nil && canFetchMoreItems else { - return + func destination(for item: AudiobookShelfLibraryItem) -> AudiobookShelfLibraryLevelData? { + switch item.kind { + case .audiobook, .podcast: + return .details(data: item) + case .library: + return nil + case .browseCategory: + return nil + case .collection: + return .library(source: .collection(id: item.id), title: item.title) + case .author, .series, .narrator: + guard let filter = item.filter else { return nil } + return .library(source: .books(libraryID: item.libraryId, filter: filter), title: item.title) + } + } + + @MainActor + func handleDoneAction() { + onTransition?(.done) + } + + @MainActor + func onEditToggleSelectTapped() { + guard allowsEditing else { return } + + withAnimation { + editMode = editMode.isEditing ? .inactive : .active + } + + if !editMode.isEditing { + selectedItems.removeAll() + } + } + + @MainActor + func onSelectTapped(for item: AudiobookShelfLibraryItem) { + guard item.isDownloadable else { return } + + if let index = selectedItems.firstIndex(of: item.id) { + selectedItems.remove(at: index) + } else { + selectedItems.insert(item.id) } + } + + @MainActor + func onSelectAllTapped() { + guard allowsEditing else { return } - if let libraryID { - fetchLibraryItems(libraryID: libraryID) + if selectedItems.isEmpty { + let ids = items.compactMap { item in + item.isDownloadable ? item.id : nil + } + selectedItems = Set(ids) } else { - fetchTopLevelItems() + selectedItems.removeAll() + } + } + + @MainActor + func onDownloadTapped() { + let items = selectedItems.compactMap { id in + self.items.first(where: { $0.id == id && $0.isDownloadable }) + } + + var urls = [URL]() + for item in items { + do { + let url = try connectionService.createItemDownloadUrl(item) + urls.append(url) + } catch { + self.error = error + } + } + + guard !urls.isEmpty else { return } + singleFileDownloadService.handleDownload(urls) + navigation.dismiss?() + } + + private func handleSortChanged() { + guard !items.isEmpty else { return } + + switch source { + case .books(let libraryID, .none): + resetForFreshFetch() + fetchBookItems(libraryID: libraryID, filter: nil) + default: + applyLocalSearchAndSort() + } + } + + private func fetchSourceItems() { + switch source { + case .libraries: + fetchLibraries() + case .books(let libraryID, let filter): + fetchBookItems(libraryID: libraryID, filter: filter) + case .entities(let libraryID, let category): + fetchEntityItems(libraryID: libraryID, category: category) + case .collection(let id): + fetchCollectionItems(collectionID: id) } } - private func fetchTopLevelItems() { + private func fetchLibraries() { fetchTask?.cancel() fetchTask = Task { @MainActor in defer { self.fetchTask = nil } - items = [] do { let libraries = try await connectionService.fetchLibraries() + let libraryItems = libraries + .filter { $0.mediaType == "book" } + .map(AudiobookShelfLibraryItem.init(library:)) + loadLocalItems(libraryItems) + } catch is CancellationError { + // ignore + } catch { + self.error = error + } + } + } - // Convert libraries to library items so users can select which library to browse - let libraryItems = libraries.map { library in - AudiobookShelfLibraryItem( - id: library.id, - title: library.name, - kind: .library, - libraryId: library.id, - authorName: nil, - narratorName: nil, - duration: nil, - size: nil, - coverPath: nil, - progress: nil - ) - } + private func fetchEntityItems(libraryID: String, category: AudiobookShelfBrowseCategory) { + fetchTask?.cancel() + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } - self.totalItems = libraryItems.count - self.items = libraryItems + do { + switch category { + case .books: + loadLocalItems([]) + case .authors: + let filterData = try await connectionService.fetchFilterData(in: libraryID) + loadLocalItems(filterData.authors.map { AudiobookShelfLibraryItem(author: $0, libraryId: libraryID) }) + case .series: + let filterData = try await connectionService.fetchFilterData(in: libraryID) + loadLocalItems(filterData.series.map { AudiobookShelfLibraryItem(series: $0, libraryId: libraryID) }) + case .narrators: + let filterData = try await connectionService.fetchFilterData(in: libraryID) + loadLocalItems(filterData.narrators.map { AudiobookShelfLibraryItem(narrator: $0, libraryId: libraryID) }) + case .collections: + let collections = try await connectionService.fetchCollections(in: libraryID) + loadLocalItems(collections.map(AudiobookShelfLibraryItem.init(collection:))) + } } catch is CancellationError { // ignore } catch { @@ -193,36 +314,55 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } } - private func onSearchQueryChanged() { - guard let libraryID else { return } + private func fetchCollectionItems(collectionID: String) { fetchTask?.cancel() - fetchTask = nil - editMode = .inactive - items = [] - selectedItems.removeAll() - nextPage = 0 - totalItems = Int.max + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } - if searchQuery.isEmpty { - fetchLibraryItems(libraryID: libraryID) - } else { + do { + let collection = try await connectionService.fetchCollection(id: collectionID) + let books = collection.books.compactMap(AudiobookShelfLibraryItem.init(apiItem:)) + loadLocalItems(books) + } catch is CancellationError { + // ignore + } catch { + self.error = error + } + } + } + + private func fetchBookItems(libraryID: String, filter: AudiobookShelfItemFilter?) { + resetSelectionState() + + if filter == nil, usesRemoteBookSearch, searchQuery.isEmpty { + fetchBooksPage() + } else if filter == nil, usesRemoteBookSearch { searchLibraryItems(libraryID: libraryID, query: searchQuery) + } else { + fetchAllFilteredBooks(libraryID: libraryID, filter: filter) } } - private func searchLibraryItems(libraryID: String, query: String) { + private func fetchBooksPage() { + guard case .books(let libraryID, let filter) = source else { return } + guard filter == nil, fetchTask == nil, canFetchMoreItems else { return } + fetchTask = Task { @MainActor in defer { self.fetchTask = nil } do { - let items = try await connectionService.searchItems( + let (items, totalItems) = try await connectionService.fetchItems( in: libraryID, - query: query, - limit: Self.searchResultLimit + limit: Self.itemBatchSize, + page: nextPage, + sortBy: sortParameter, + desc: sortDescending, + filter: nil ) - self.totalItems = items.count - self.items = items + self.nextPage += 1 + self.totalItems = totalItems + self.items.append(contentsOf: items) } catch is CancellationError { // ignore } catch { @@ -231,32 +371,44 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } } - private func fetchLibraryItems(libraryID: String) { + private func fetchAllFilteredBooks(libraryID: String, filter: AudiobookShelfItemFilter?) { + fetchTask?.cancel() fetchTask = Task { @MainActor in defer { self.fetchTask = nil } do { - var desc: Bool? - let sortByParam: String - switch sortBy { - case .recent: - sortByParam = "addedAt" - desc = true - case .title: - sortByParam = "media.metadata.title" - } + let (items, _) = try await connectionService.fetchItems( + in: libraryID, + limit: 0, + page: 0, + sortBy: sortParameter, + desc: sortDescending, + filter: filter + ) - let (items, totalItems) = try await connectionService.fetchItems( + loadLocalItems(items) + } catch is CancellationError { + // ignore + } catch { + self.error = error + } + } + } + + private func searchLibraryItems(libraryID: String, query: String) { + fetchTask?.cancel() + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } + + do { + let items = try await connectionService.searchItems( in: libraryID, - limit: Self.itemBatchSize, - page: nextPage, - sortBy: sortByParam, - desc: desc + query: query, + limit: Self.searchResultLimit ) - self.nextPage += 1 - self.totalItems = totalItems - self.items.append(contentsOf: items) + self.totalItems = items.count + self.items = sortItems(items) } catch is CancellationError { // ignore } catch { @@ -265,61 +417,238 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } } - @MainActor - func handleDoneAction() { - onTransition?(.done) + private func onSearchQueryChanged() { + switch source { + case .books(let libraryID, .none): + resetForFreshFetch() + + if searchQuery.isEmpty { + fetchBookItems(libraryID: libraryID, filter: nil) + } else { + searchLibraryItems(libraryID: libraryID, query: searchQuery) + } + default: + applyLocalSearchAndSort() + } } - @MainActor - func onEditToggleSelectTapped() { - withAnimation { - editMode = editMode.isEditing ? .inactive : .active + private func loadLocalItems(_ items: [AudiobookShelfLibraryItem]) { + allItems = items + applyLocalSearchAndSort() + } + + private func applyLocalSearchAndSort() { + let normalizedQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let filteredItems = normalizedQuery.isEmpty + ? allItems + : allItems.filter { item in + item.title.localizedCaseInsensitiveContains(normalizedQuery) + || item.subtitle?.localizedCaseInsensitiveContains(normalizedQuery) == true + || item.authorName?.localizedCaseInsensitiveContains(normalizedQuery) == true + || item.narratorName?.localizedCaseInsensitiveContains(normalizedQuery) == true + } + + let sortedItems = sortItems(filteredItems) + totalItems = sortedItems.count + items = sortedItems + } + + private func sortItems(_ items: [AudiobookShelfLibraryItem]) -> [AudiobookShelfLibraryItem] { + if let seriesID = activeSeriesID { + return items.sorted { lhs, rhs in + compareSeriesItems(lhs, rhs, seriesID: seriesID) + } } - if !editMode.isEditing { - selectedItems.removeAll() + switch sortBy { + case .recent: + return items.sorted { lhs, rhs in + let lhsDate = lhs.updatedAt ?? lhs.addedAt ?? 0 + let rhsDate = rhs.updatedAt ?? rhs.addedAt ?? 0 + if lhsDate == rhsDate { + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + return lhsDate > rhsDate + } + case .title: + return items.sorted { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } } } - @MainActor - func onSelectTapped(for item: AudiobookShelfLibraryItem) { - if let index = selectedItems.firstIndex(of: item.id) { - selectedItems.remove(at: index) - } else { - selectedItems.insert(item.id) + private var activeSeriesID: String? { + guard case .books(_, let filter?) = source, + filter.group == .series + else { + return nil } + + return filter.value } - @MainActor - func onSelectAllTapped() { - if selectedItems.isEmpty { - let ids: [AudiobookShelfLibraryItem.ID] = items.compactMap { item in - guard item.kind == .audiobook else { return nil } - return item.id + private func compareSeriesItems( + _ lhs: AudiobookShelfLibraryItem, + _ rhs: AudiobookShelfLibraryItem, + seriesID: String + ) -> Bool { + let lhsSortValue = seriesSortValue(lhs.seriesSequence(for: seriesID)) + let rhsSortValue = seriesSortValue(rhs.seriesSequence(for: seriesID)) + + switch (lhsSortValue, rhsSortValue) { + case let (.some(lhsValue), .some(rhsValue)): + if lhsValue != rhsValue { + return lhsValue < rhsValue } + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + break + } - selectedItems = Set(ids) - } else { - selectedItems.removeAll() + let lhsSequence = lhs.seriesSequence(for: seriesID) ?? lhs.title + let rhsSequence = rhs.seriesSequence(for: seriesID) ?? rhs.title + let fallbackComparison = lhsSequence.localizedStandardCompare(rhsSequence) + if fallbackComparison != .orderedSame { + return fallbackComparison == .orderedAscending } + + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending } - @MainActor - func onDownloadTapped() { - let items = selectedItems.compactMap({ id in - self.items.first(where: { $0.id == id }) - }) + private func seriesSortValue(_ sequence: String?) -> Decimal? { + guard let trimmedSequence = sequence?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmedSequence.isEmpty + else { + return nil + } - var urls = [URL]() - for item in items { - do { - let url = try connectionService.createItemDownloadUrl(item) - urls.append(url) - } catch { - self.error = error + if let decimal = Decimal(string: trimmedSequence) { + return decimal + } + + if let wordValue = seriesWordSortValue(trimmedSequence) { + return wordValue + } + + let pattern = #"[-+]?\d+(?:\.\d+)?"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: trimmedSequence, + range: NSRange(trimmedSequence.startIndex..., in: trimmedSequence) + ), + let range = Range(match.range, in: trimmedSequence) + else { + return nil + } + + return Decimal(string: String(trimmedSequence[range])) + } + + private static let seriesWordValues: [String: Decimal] = [ + "minus": -1, + "negative": -1, + "zero": 0, + "one": 1, + "first": 1, + "two": 2, + "second": 2, + "three": 3, + "third": 3, + "four": 4, + "fourth": 4, + "five": 5, + "fifth": 5, + "six": 6, + "sixth": 6, + "seven": 7, + "seventh": 7, + "eight": 8, + "eighth": 8, + "nine": 9, + "ninth": 9, + "ten": 10, + "tenth": 10, + "eleven": 11, + "eleventh": 11, + "twelve": 12, + "twelfth": 12, + "thirteen": 13, + "thirteenth": 13, + "fourteen": 14, + "fourteenth": 14, + "fifteen": 15, + "fifteenth": 15, + "sixteen": 16, + "sixteenth": 16, + "seventeen": 17, + "seventeenth": 17, + "eighteen": 18, + "eighteenth": 18, + "nineteen": 19, + "nineteenth": 19, + "twenty": 20, + "twentieth": 20, + "half": Decimal(string: "0.5")! + ] + + private func seriesWordSortValue(_ sequence: String) -> Decimal? { + let normalized = sequence + .lowercased() + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + + let tokens = normalized + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + + for (index, token) in tokens.enumerated() { + guard var value = Self.seriesWordValues[token] else { continue } + + if index > 0 { + let previous = tokens[index - 1] + if previous == "minus" || previous == "negative" { + value *= -1 + } } + + return value + } + + return nil + } + + private func resetSelectionState() { + editMode = .inactive + selectedItems.removeAll() + } + + private func resetForFreshFetch() { + fetchTask?.cancel() + fetchTask = nil + resetSelectionState() + items = [] + totalItems = Int.max + nextPage = 0 + } + + private var sortParameter: String { + switch sortBy { + case .recent: + "addedAt" + case .title: + "media.metadata.title" + } + } + + private var sortDescending: Bool? { + switch sortBy { + case .recent: + true + case .title: + nil } - singleFileDownloadService.handleDownload(urls) - navigation.dismiss?() } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsData.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsData.swift index 40caab67d..6a0ea408f 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsData.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsData.swift @@ -9,7 +9,7 @@ import BookPlayerKit import Foundation -struct AudiobookShelfAudiobookDetailsData { +struct AudiobookShelfAudiobookDetailsData: IntegrationDetailsDataProtocol { let artist: String? let narrator: String? let filePath: String? @@ -35,6 +35,10 @@ struct AudiobookShelfAudiobookDetailsData { return TimeParser.formatTotalDuration(runtime) } + var seriesEntries: [IntegrationSeriesEntry] { + (series ?? []).map { IntegrationSeriesEntry(id: $0.id, name: $0.name, sequence: $0.sequence) } + } + var fileSizeString: String { guard let size = fileSize else { return "file_size_unknown".localized diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift index 24a716c65..81d5ed6f5 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift @@ -6,203 +6,29 @@ // Copyright © 2025 BookPlayer LLC. All rights reserved. // -import BookPlayerKit -import Kingfisher import SwiftUI +/// Thin wrapper providing AudiobookShelf-specific image view to the shared details view. struct AudiobookShelfAudiobookDetailsView< - Model: AudiobookShelfAudiobookDetailsViewModelProtocol ->: View { + Model: IntegrationDetailsViewModelProtocol +>: View +where Model.Item == AudiobookShelfLibraryItem, Model.Details == AudiobookShelfAudiobookDetailsData { - @State private var isFilePathExpanded: Bool = false - @State private var isGenresExpanded: Bool = false - @State private var isOverviewExpanded: Bool = true - @State private var isTagsExpanded: Bool = true - @StateObject var viewModel: Model - @EnvironmentObject private var theme: ThemeViewModel - @State var filePathLineLimit: Int? = 1 - - var onDownloadTap: (() -> Void) - - var voiceOverBookInfo: String { - guard let details = viewModel.details else { - return viewModel.item.title - } - - return VoiceOverService.playerMetaText( - title: viewModel.item.title, - author: details.artist ?? "voiceover_unknown_author".localized - ) - } + @ObservedObject var viewModel: Model + var onDownloadTap: () -> Void var body: some View { - ScrollView { - VStack { + IntegrationAudiobookDetailsView( + viewModel: viewModel, + onDownloadTap: onDownloadTap, + imageContent: { AudiobookShelfLibraryItemImageView(item: viewModel.item) - .environment(\.audiobookshelfService, viewModel.connectionService) - .accessibilityHidden(true) - .padding(.horizontal, Spacing.L1) - - Text(viewModel.item.title) - .bpFont(.titleLarge) - .accessibilityLabel(voiceOverBookInfo) - .foregroundStyle(theme.primaryColor) - .multilineTextAlignment(.center) - - if let artist = viewModel.details?.artist { - Text(artist) - .bpFont(.title2) - .foregroundStyle(theme.secondaryColor) - .lineLimit(1) - .accessibilityHidden(true) - } - - if let narrator = viewModel.details?.narrator, !narrator.isEmpty { - Text("Narrated by \(narrator)") - .bpFont(.subheadline) - .foregroundStyle(theme.secondaryColor) - .lineLimit(1) - .accessibilityHidden(true) - } - - if let details = viewModel.details { - HStack(alignment: .center) { - Text(details.runtimeString) - .accessibilityLabel("book_duration_title".localized + details.runtimeString) - Text(" | ") - Text(details.fileSizeString) - } - .foregroundStyle(theme.primaryColor) - .bpFont(.caption) - } - - Button { - do { - try viewModel.beginDownloadAudiobook(viewModel.item) - onDownloadTap() - } catch { - viewModel.error = error - } - } label: { - HStack { - Image(systemName: "square.and.arrow.down") - Text("download_title".localized) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .foregroundStyle(theme.systemBackgroundColor) - .background(theme.linkColor) - .cornerRadius(10) - } - .padding(.horizontal) - - if let details = viewModel.details { - VStack { - if let filePath = details.filePath { - DisclosureGroup("File Path", isExpanded: $isFilePathExpanded) { - Text(filePath) - } - .accessibilityHidden(true) - } - - if let genres = details.genres, - !genres.isEmpty - { - DisclosureGroup("Genres", isExpanded: $isGenresExpanded) { - AudiobookShelfTagsView(tags: genres) - } - } - - if let overview = details.overview { - DisclosureGroup("Overview", isExpanded: $isOverviewExpanded) { - Text(overview) - } - } - - if let tags = details.tags, - !tags.isEmpty - { - DisclosureGroup("Tags", isExpanded: $isTagsExpanded) { - AudiobookShelfTagsView(tags: tags) - } - } - - if let series = details.series, - !series.isEmpty - { - DisclosureGroup("Series", isExpanded: .constant(true)) { - VStack(alignment: .leading, spacing: 8) { - ForEach(series, id: \.self) { item in - Text(item.name) - } - } - } - } - } - .padding(.horizontal) - } + .environment(\.audiobookshelfService, audiobookShelfConnectionService) } - } - .applyListStyle(with: theme, background: theme.systemBackgroundColor) - .tint(theme.linkColor) - .errorAlert(error: $viewModel.error) - .onAppear { - viewModel.fetchData() - } - .onDisappear { - viewModel.cancelFetchData() - } - .scrollIndicators(.hidden) + ) } -} - -final class MockAudiobookShelfAudiobookDetailsViewModel: AudiobookShelfAudiobookDetailsViewModelProtocol { - var connectionService = AudiobookShelfConnectionService() - let item: AudiobookShelfLibraryItem - let details: AudiobookShelfAudiobookDetailsData? - var error: Error? - - init(item: AudiobookShelfLibraryItem, details: AudiobookShelfAudiobookDetailsData?) { - self.item = item - self.details = details + private var audiobookShelfConnectionService: AudiobookShelfConnectionService { + (viewModel as? AudiobookShelfAudiobookDetailsViewModel)?.connectionService ?? .init() } - - @MainActor - func fetchData() {} - - @MainActor - func cancelFetchData() {} - - @MainActor - func beginDownloadAudiobook(_ item: AudiobookShelfLibraryItem) {} -} - -#Preview { - let item = AudiobookShelfLibraryItem( - id: "0.1", - title: "The Great Gatsby", - kind: .audiobook, - libraryId: "1" - ) - let details = AudiobookShelfAudiobookDetailsData( - artist: "F. Scott Fitzgerald", - narrator: "Jake Gyllenhaal", - filePath: "/audiobooks/The Great Gatsby/The Great Gatsby.m4b", - fileSize: 189_678_390, - overview: - "The Great Gatsby is a 1925 novel by American writer F. Scott Fitzgerald. Set in the Jazz Age on Long Island, near New York City, the novel depicts first-person narrator Nick Carraway's interactions with mysterious millionaire Jay Gatsby and Gatsby's obsession to reunite with his former lover, Daisy Buchanan.", - runtimeInSeconds: 14580.5, - genres: ["Classic", "Fiction"], - tags: ["American Literature", "1920s"], - publishedYear: nil, - publisher: nil, - series: [.init(id: "1", name: "The Great American Novels", sequence: nil)] - ) - let parentData = AudiobookShelfLibraryLevelData.topLevel(libraryName: "Mock Library") - let vm = MockAudiobookShelfAudiobookDetailsViewModel(item: item, details: details) - AudiobookShelfAudiobookDetailsView(viewModel: vm, onDownloadTap: {}) - .environmentObject(MockAudiobookShelfLibraryViewModel(data: parentData)) - .environmentObject(ThemeViewModel()) } diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift index 6d852d7fe..d5d620912 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift @@ -9,23 +9,7 @@ import BookPlayerKit import Foundation -protocol AudiobookShelfAudiobookDetailsViewModelProtocol: ObservableObject { - var item: AudiobookShelfLibraryItem { get } - var details: AudiobookShelfAudiobookDetailsData? { get } - var connectionService: AudiobookShelfConnectionService { get } - var error: Error? { get set } - - @MainActor - func fetchData() - - @MainActor - func cancelFetchData() - - @MainActor - func beginDownloadAudiobook(_ item: AudiobookShelfLibraryItem) throws -} - -class AudiobookShelfAudiobookDetailsViewModel: AudiobookShelfAudiobookDetailsViewModelProtocol { +class AudiobookShelfAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { let item: AudiobookShelfLibraryItem let connectionService: AudiobookShelfConnectionService diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfTagsView.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfTagsView.swift deleted file mode 100644 index 36db8ea32..000000000 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfTagsView.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// AudiobookShelfTagsView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import BookPlayerKit -import SwiftUI - -struct AudiobookShelfTagsView: View { - let tags: [String] - - @EnvironmentObject var theme: ThemeViewModel - - var body: some View { - TagsFlowLayout { - ForEach(tags, id: \.self) { tag in - Text(tag) - .bpFont(.caption) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .foregroundColor(theme.primaryColor) - .overlay( - Capsule() - .stroke(theme.linkColor, lineWidth: 1) - ) - } - } - .padding(Spacing.S4) - } -} - -#Preview { - AudiobookShelfTagsView(tags: ["Sci-Fi", "Fantasy", "Dystopian", "Action", "Adventure", "Mystery", "Horror", "Thriller"]) - .environmentObject(ThemeViewModel()) -} diff --git a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift index d3f29fa20..2ebf6b4e5 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift @@ -24,7 +24,7 @@ struct AudiobookShelfLibraryGridItemView: View { ZStack(alignment: .topTrailing) { AudiobookShelfLibraryItemImageView(item: item) .overlay { - if editMode?.wrappedValue.isEditing == true, item.kind == .audiobook { + if editMode?.wrappedValue.isEditing == true, item.isDownloadable { Image(systemName: isSelected ? "checkmark.circle" : "circle") .foregroundStyle(.white) .background(isSelected ? .blue : .clear) @@ -35,7 +35,7 @@ struct AudiobookShelfLibraryGridItemView: View { } .accessibilityHidden(true) - if item.kind == .library { + if item.isNavigable { libraryBadge } } @@ -52,7 +52,7 @@ struct AudiobookShelfLibraryGridItemView: View { ZStack { Circle().strokeBorder(.foreground, lineWidth: 1 * accessabilityScale) .background(Circle().fill(.background)) - Image(systemName: "folder.fill") + Image(systemName: item.placeholderImageName) .resizable() .aspectRatio(contentMode: .fit) .padding(4) diff --git a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift deleted file mode 100644 index 4605b9d6e..000000000 --- a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// AudiobookShelfLibraryGridView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import BookPlayerKit -import SwiftUI - -struct AudiobookShelfLibraryGridView: View { - @ObservedObject var viewModel: Model - - @ScaledMetric var accessabilityScale: CGFloat = 1 - @State private var availableSize: CGSize = .zero - private let itemMinSizeBase = CGSize(width: 100, height: 100) - private let itemMaxSizeBase = CGSize(width: 250, height: 250) - private let itemSpacingBase = 20.0 - - private func adjustSize(_ size: CGSize, availableSize: CGSize) -> CGSize { - CGSize( - width: min(size.width, availableSize.width), - height: min(size.height * accessabilityScale, availableSize.height) - ) - } - - private var columns: [GridItem] { - [GridItem( - .adaptive( - minimum: itemMinSizeBase.width, - maximum: itemMaxSizeBase.width - ), - spacing: itemSpacingBase * accessabilityScale - )] - } - - var body: some View { - LazyVGrid(columns: columns, spacing: itemSpacingBase * accessabilityScale) { - ForEach(viewModel.items, id: \.id) { item in - AudiobookShelfLibraryGridItemView(item: item, isSelected: viewModel.selectedItems.contains(item.id)) - .accessibilityAddTraits(.isButton) - .onTapGesture { - if viewModel.editMode.isEditing { - guard case .audiobook = item.kind else { return } - viewModel.onSelectTapped(for: item) - } else { - switch item.kind { - case .audiobook, .podcast: - viewModel.navigation.path.append(AudiobookShelfLibraryLevelData.details(data: item)) - case .library: - viewModel.navigation.path.append(AudiobookShelfLibraryLevelData.library(data: item)) - } - } - } - .onAppear { - viewModel.fetchMoreItemsIfNeeded(currentItem: item) - } - } - } - } -} - -final class MockAudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtocol, ObservableObject { - var navigationTitle: String = "" - var navigation = BPNavigation() - var connectionService = AudiobookShelfConnectionService() - - let data: AudiobookShelfLibraryLevelData - - var searchQuery: String = "" - var isSearchable: Bool { false } - - var layout = AudiobookShelfLayout.Options.grid - var sortBy = AudiobookShelfLayout.SortBy.recent - - @Published var items: [AudiobookShelfLibraryItem] = [] - var totalItems: Int { items.count } - var error: Error? - - var editMode: EditMode = .inactive - var selectedItems: Set = [] - - init(data: AudiobookShelfLibraryLevelData) { - self.data = data - } - - func fetchInitialItems() {} - func fetchMoreItemsIfNeeded(currentItem: AudiobookShelfLibraryItem) {} - func cancelFetchItems() {} - - func handleDoneAction() {} - - func onEditToggleSelectTapped() {} - func onSelectTapped(for item: AudiobookShelfLibraryItem) {} - func onSelectAllTapped() {} - func onDownloadTapped() {} -} - -#Preview("top level") { - let model = { - let model = MockAudiobookShelfLibraryViewModel(data: .topLevel(libraryName: "Mock Library")) - model.items = [ - AudiobookShelfLibraryItem( - id: "0.1", - title: "The Great Gatsby", - kind: .audiobook, - libraryId: "1" - ), - AudiobookShelfLibraryItem( - id: "0.2", - title: "To Kill a Mockingbird", - kind: .audiobook, - libraryId: "2" - ), - AudiobookShelfLibraryItem( - id: "0.3", - title: "A Very Long Book Title That Should Wrap to Multiple Lines", - kind: .audiobook, - libraryId: "3" - ), - AudiobookShelfLibraryItem( - id: "0.4", - title: "1984", - kind: .audiobook, - libraryId: "4" - ), - AudiobookShelfLibraryItem( - id: "0.5", - title: "Pride and Prejudice", - kind: .audiobook, - libraryId: "5" - ) - ] - return model - }() - AudiobookShelfLibraryGridView(viewModel: model) - .environmentObject(ThemeViewModel()) -} diff --git a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift b/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift index fda0b90e9..b5ef860d3 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift @@ -18,11 +18,20 @@ struct AudiobookShelfLibraryListItemView: View { AudiobookShelfLibraryItemImageView(item: item) .frame(width: 50, height: 50) .accessibilityHidden(true) - Text(item.title) - .bpFont(.titleRegular) - .foregroundStyle(theme.primaryColor) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .bpFont(.titleRegular) + .foregroundStyle(theme.primaryColor) + + if let subtitle = item.subtitle ?? item.authorName ?? item.narratorName { + Text(subtitle) + .bpFont(.caption) + .foregroundStyle(theme.secondaryColor) + .lineLimit(1) + } + } Spacer() - if item.kind == .library { + if item.isNavigable { Image(systemName: "chevron.forward") .foregroundStyle(theme.secondaryColor) } diff --git a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListView.swift b/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListView.swift deleted file mode 100644 index b98b05b46..000000000 --- a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// AudiobookShelfLibraryListView.swift -// BookPlayer -// -// Created by Gianni Carlo on 11/14/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import BookPlayerKit -import SwiftUI - -struct AudiobookShelfLibraryListView: View { - @ObservedObject var viewModel: Model - @EnvironmentObject var theme: ThemeViewModel - - var body: some View { - List(viewModel.items, selection: $viewModel.selectedItems) { item in - row(item: item) - .selectionDisabled(item.kind != .audiobook) - .listRowBackground(theme.tertiarySystemBackgroundColor) - } - } - - func row(item: AudiobookShelfLibraryItem) -> some View { - AudiobookShelfLibraryListItemView(item: item) - .accessibilityAddTraits(.isButton) - .contentShape(Rectangle()) - .onTapGesture { - if viewModel.editMode.isEditing { - guard case .audiobook = item.kind else { return } - viewModel.onSelectTapped(for: item) - } else { - switch item.kind { - case .audiobook, .podcast: - viewModel.navigation.path.append(AudiobookShelfLibraryLevelData.details(data: item)) - case .library: - viewModel.navigation.path.append(AudiobookShelfLibraryLevelData.library(data: item)) - } - } - } - .onAppear { - viewModel.fetchMoreItemsIfNeeded(currentItem: item) - } - .id("\(item.id)-\(viewModel.selectedItems.contains(item.id))") - } -} diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift index 0ff00ef78..30669cc00 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift @@ -14,6 +14,7 @@ struct AudiobookShelfConnectionData: Codable { let userID: String let userName: String let apiToken: String + var selectedLibraryId: String? } extension AudiobookShelfConnectionData: CustomDebugStringConvertible { diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift index d429df3d1..493353358 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift +++ b/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift @@ -16,9 +16,12 @@ class AudiobookShelfConnectionService: BPLogger { var connection: AudiobookShelfConnectionData? private var urlSession: URLSession + init(keychainService: KeychainServiceProtocol = KeychainService()) { self.keychainService = keychainService - self.urlSession = URLSession.shared + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 15 + self.urlSession = URLSession(configuration: configuration) } func setup() { @@ -28,7 +31,7 @@ class AudiobookShelfConnectionService: BPLogger { /// Pings the server to verify it exists and returns the server version public func pingServer(at absolutePath: String) async throws -> String { guard let url = URL(string: absolutePath) else { - throw AudiobookShelfError.urlMalformed(nil) + throw IntegrationError.urlMalformed(nil) } // Use the public /ping endpoint which doesn't require authentication @@ -40,11 +43,11 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw AudiobookShelfError.unexpectedResponse(code: nil) + throw IntegrationError.unexpectedResponse(code: nil) } guard (200...299).contains(httpResponse.statusCode) else { - throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) } // Try to parse server info - /ping returns a simple success message @@ -68,7 +71,7 @@ class AudiobookShelfConnectionService: BPLogger { serverName: String ) async throws { guard let url = URL(string: serverUrl) else { - throw AudiobookShelfError.urlMalformed(nil) + throw IntegrationError.urlMalformed(nil) } let loginURL = url.appendingPathComponent("login") @@ -82,14 +85,14 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw AudiobookShelfError.unexpectedResponse(code: nil) + throw IntegrationError.unexpectedResponse(code: nil) } guard (200...299).contains(httpResponse.statusCode) else { if httpResponse.statusCode == 401 { throw URLError(.userAuthenticationRequired) } - throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) } // Parse response @@ -98,7 +101,7 @@ class AudiobookShelfConnectionService: BPLogger { let apiToken = user["token"] as? String, let userID = user["id"] as? String else { - throw AudiobookShelfError.unexpectedResponse(code: nil) + throw IntegrationError.unexpectedResponse(code: nil) } let connectionData = AudiobookShelfConnectionData( @@ -117,6 +120,13 @@ class AudiobookShelfConnectionService: BPLogger { self.connection = connectionData } + func saveSelectedLibrary(id: String?) { + guard var data = connection else { return } + data.selectedLibraryId = id + connection = data + try? keychainService.set(data, key: .audiobookshelfConnection) + } + func deleteConnection() { do { try keychainService.remove(.audiobookshelfConnection) @@ -141,11 +151,11 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw AudiobookShelfError.unexpectedResponse(code: nil) + throw IntegrationError.unexpectedResponse(code: nil) } guard (200...299).contains(httpResponse.statusCode) else { - throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) } let decoder = JSONDecoder() @@ -159,6 +169,7 @@ class AudiobookShelfConnectionService: BPLogger { page: Int? = nil, sortBy: String? = "media.metadata.title", desc: Bool? = nil, + filter: AudiobookShelfItemFilter? = nil ) async throws -> (items: [AudiobookShelfLibraryItem], total: Int) { guard let connection else { throw URLError(.userAuthenticationRequired) @@ -191,13 +202,16 @@ class AudiobookShelfConnectionService: BPLogger { queryItems.append(URLQueryItem(name: "desc", value: desc ? "1" : "0")) } } + if let filter { + queryItems.append(URLQueryItem(name: "filter", value: filter.queryValue)) + } if !queryItems.isEmpty { urlComponents.queryItems = queryItems } guard let url = urlComponents.url else { - throw AudiobookShelfError.urlFromComponents(urlComponents) + throw IntegrationError.urlFromComponents(urlComponents) } var request = URLRequest(url: url) @@ -206,11 +220,11 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw AudiobookShelfError.unexpectedResponse(code: nil) + throw IntegrationError.unexpectedResponse(code: nil) } guard (200...299).contains(httpResponse.statusCode) else { - throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) } let decoder = JSONDecoder() @@ -221,6 +235,105 @@ class AudiobookShelfConnectionService: BPLogger { return (items, itemsResponse.total) } + public func fetchFilterData(in libraryId: String) async throws -> AudiobookShelfLibraryFilterData { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + let url = connection.url + .appendingPathComponent("api") + .appendingPathComponent("libraries") + .appendingPathComponent(libraryId) + .appendingPathComponent("filterdata") + + var request = URLRequest(url: url) + request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw IntegrationError.unexpectedResponse(code: nil) + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + return try decoder.decode(AudiobookShelfLibraryFilterData.self, from: data) + } + + public func fetchCollections(in libraryId: String) async throws -> [AudiobookShelfCollection] { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + guard + var urlComponents = URLComponents( + url: connection.url + .appendingPathComponent("api") + .appendingPathComponent("libraries") + .appendingPathComponent(libraryId) + .appendingPathComponent("collections"), + resolvingAgainstBaseURL: false + ) + else { + throw URLError(.badURL) + } + + urlComponents.queryItems = [ + URLQueryItem(name: "minified", value: "1") + ] + + guard let url = urlComponents.url else { + throw IntegrationError.urlFromComponents(urlComponents) + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw IntegrationError.unexpectedResponse(code: nil) + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + let collectionsResponse = try decoder.decode(AudiobookShelfCollectionsResponse.self, from: data) + return collectionsResponse.results + } + + public func fetchCollection(id: String) async throws -> AudiobookShelfCollection { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + let url = connection.url + .appendingPathComponent("api") + .appendingPathComponent("collections") + .appendingPathComponent(id) + + var request = URLRequest(url: url) + request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw IntegrationError.unexpectedResponse(code: nil) + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + return try decoder.decode(AudiobookShelfCollection.self, from: data) + } + public func searchItems( in libraryId: String, query: String, @@ -254,7 +367,7 @@ class AudiobookShelfConnectionService: BPLogger { urlComponents.queryItems = queryItems guard let url = urlComponents.url else { - throw AudiobookShelfError.urlFromComponents(urlComponents) + throw IntegrationError.urlFromComponents(urlComponents) } var request = URLRequest(url: url) @@ -263,11 +376,11 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw AudiobookShelfError.unexpectedResponse(code: nil) + throw IntegrationError.unexpectedResponse(code: nil) } guard (200...299).contains(httpResponse.statusCode) else { - throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) } let decoder = JSONDecoder() @@ -293,14 +406,11 @@ class AudiobookShelfConnectionService: BPLogger { let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw AudiobookShelfError.unexpectedResponse(code: nil) + throw IntegrationError.unexpectedResponse(code: nil) } guard (200...299).contains(httpResponse.statusCode) else { - if httpResponse.statusCode == 404 { - throw URLError(.fileDoesNotExist) - } - throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + throw IntegrationError.unexpectedResponse(code: httpResponse.statusCode) } let decoder = JSONDecoder() @@ -343,7 +453,9 @@ class AudiobookShelfConnectionService: BPLogger { guard let connection = connection else { return nil } let baseURL = connection.url - let itemID = item.id + guard let itemID = item.coverItemId ?? (item.isDownloadable ? item.id : nil) else { + return nil + } // AudiobookShelf image endpoint: /api/items/:id/cover // Optional query params: width, height, format diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfError.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfError.swift deleted file mode 100644 index e78a47372..000000000 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfError.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// AudiobookShelfError.swift -// BookPlayer -// -// Created by Gianni Carlo on 14/11/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import Foundation - -enum AudiobookShelfError: Error, LocalizedError { - case urlMalformed(_ url: URL?) - case urlFromComponents(_ components: URLComponents) - case unexpectedResponse(code: Int?) - case clientError(code: Int) - - var errorDescription: String? { - switch self { - case .urlMalformed(let url): - String(format: "integration_internal_error_invalid_url".localized, String(reflecting: url)) - case .urlFromComponents: - "integration_internal_error_build_url".localized - case .unexpectedResponse(let code): - if let code { - String( - format: "integration_error_unexpected_response_with_code".localized, - code, - HTTPURLResponse.localizedString(forStatusCode: code) - ) - } else { - "integration_error_unexpected_response".localized - } - case .clientError(let code): - switch code { - case 401: - "integration_error_unauthorized".localized - default: - String( - format: "integration_error_unexpected_response_with_code".localized, - code, - HTTPURLResponse.localizedString(forStatusCode: code) - ) - } - } - } -} diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index 4af771ce3..32f5f20f8 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -438,3 +438,8 @@ We're working hard on providing a seamless experience, if possible, please conta "passkey_email_exists_title" = "Account Exists"; "passkey_email_exists_message" = "An account with this email already exists. Please sign in with your existing passkey or Apple ID instead."; "passkey_signin_existing" = "Sign in with existing passkey"; +"Switch Library" = "Switch Library"; +"Authors" = "Authors"; +"Series" = "Series"; +"Collections" = "Collections"; +"Narrators" = "Narrators"; diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index cbc997b25..99033ac21 100644 --- a/BookPlayer/Generated/AutoMockable.generated.swift +++ b/BookPlayer/Generated/AutoMockable.generated.swift @@ -1224,6 +1224,28 @@ class PlayerManagerProtocolMock: PlayerManagerProtocol { forwardCallsCount += 1 forwardClosure?() } + //MARK: - skipToNextChapter + + var skipToNextChapterCallsCount = 0 + var skipToNextChapterCalled: Bool { + return skipToNextChapterCallsCount > 0 + } + var skipToNextChapterClosure: (() -> Void)? + func skipToNextChapter() { + skipToNextChapterCallsCount += 1 + skipToNextChapterClosure?() + } + //MARK: - skipToPreviousChapter + + var skipToPreviousChapterCallsCount = 0 + var skipToPreviousChapterCalled: Bool { + return skipToPreviousChapterCallsCount > 0 + } + var skipToPreviousChapterClosure: (() -> Void)? + func skipToPreviousChapter() { + skipToPreviousChapterCallsCount += 1 + skipToPreviousChapterClosure?() + } //MARK: - skip var skipCallsCount = 0 diff --git a/BookPlayer/Hardcover/Network/HardcoverService.swift b/BookPlayer/Hardcover/Network/HardcoverService.swift index 9757f6531..906f2b6b8 100644 --- a/BookPlayer/Hardcover/Network/HardcoverService.swift +++ b/BookPlayer/Hardcover/Network/HardcoverService.swift @@ -90,7 +90,6 @@ final class HardcoverService: BPLogger, HardcoverServiceProtocol { try? self.keychain.set(newValue, key: .hardcoverToken) } else { try? self.keychain.remove(.hardcoverToken) - } } } @@ -113,9 +112,7 @@ extension HardcoverService { query: $query query_type: "book" per_page: $per_page - page: 1, - fields: "title,series_names,author_names,alternative_titles", - weights: "5,3,3,1" + page: 1 ) { results } @@ -439,9 +436,9 @@ extension HardcoverService { .trimmingCharacters(in: .whitespacesAndNewlines) if author.isEmpty { - return title + return cleaned } else { - return "\(title), \(author)" + return "\(cleaned), \(author)" } } diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionFormViewModel.swift b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionFormViewModel.swift deleted file mode 100644 index 8596b7a84..000000000 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionFormViewModel.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// JellyfinConnectionFormViewModel.swift -// BookPlayer -// -// Created by Lysann Tranvouez on 2024-10-25. -// Copyright © 2024 BookPlayer LLC. All rights reserved. -// - -import Foundation - -class JellyfinConnectionFormViewModel: ObservableObject { - @Published var serverUrl: String = "" - @Published var serverName: String = "" - @Published var username: String = "" - @Published var password: String = "" - - func setValues(from connection: JellyfinConnectionData) { - serverUrl = connection.url.absoluteString - serverName = connection.serverName - username = connection.userName - } -} diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift index 0c8f0f0e4..9784e2e23 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift +++ b/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift @@ -13,47 +13,26 @@ import JellyfinAPI import SwiftUI @MainActor -final class JellyfinConnectionViewModel: ObservableObject, BPLogger { - enum ViewMode { - case regular // for the "Download from Jellyfin" flow - case viewDetails // for the connection details + sign out option from the Settings screen - } - - enum ConnectionState { - case disconnected - case foundServer - case connected - } - +final class JellyfinConnectionViewModel: IntegrationConnectionViewModelProtocol, BPLogger { let connectionService: JellyfinConnectionService - var navigation: BPNavigation - @Published var form: JellyfinConnectionFormViewModel - @Published var viewMode: ViewMode = .regular - @Published var connectionState: ConnectionState + @Published var form: IntegrationConnectionFormViewModel + @Published var viewMode: IntegrationViewMode = .regular + @Published var connectionState: IntegrationConnectionState private var disposeBag = Set() init( connectionService: JellyfinConnectionService, - navigation: BPNavigation, - mode: ViewMode = .regular + mode: IntegrationViewMode = .regular ) { self.connectionService = connectionService self._viewMode = .init(initialValue: mode) - let form = JellyfinConnectionFormViewModel() - - self.navigation = navigation + let form = IntegrationConnectionFormViewModel() if let data = connectionService.connection { - form.setValues(from: data) + form.setValues(url: data.url.absoluteString, serverName: data.serverName, userName: data.userName) self._connectionState = .init(initialValue: .connected) - - Task { @MainActor in - navigation.path.append( - JellyfinLibraryLevelData.topLevel(libraryName: form.serverName) - ) - } } else { self._connectionState = .init(initialValue: .disconnected) } @@ -78,15 +57,12 @@ final class JellyfinConnectionViewModel: ObservableObject, BPLogger { ) connectionState = .connected - navigation.path.append( - JellyfinLibraryLevelData.topLevel(libraryName: form.serverName) - ) } catch APIError.unacceptableStatusCode(let statusCode) { switch statusCode { case 400...499: - throw JellyfinError.clientError(code: statusCode).localizedDescription + throw IntegrationError.clientError(code: statusCode) default: - throw JellyfinError.unexpectedResponse(code: statusCode).localizedDescription + throw IntegrationError.unexpectedResponse(code: statusCode) } } catch { throw error @@ -96,14 +72,7 @@ final class JellyfinConnectionViewModel: ObservableObject, BPLogger { @MainActor func handleSignOutAction() { connectionService.deleteConnection() - form = JellyfinConnectionFormViewModel() + form = IntegrationConnectionFormViewModel() connectionState = .disconnected } - - @MainActor - func handleGoToLibraryAction() { - navigation.path.append( - JellyfinLibraryLevelData.topLevel(libraryName: form.serverName) - ) - } } diff --git a/BookPlayer/Jellyfin/JellyfinRootView.swift b/BookPlayer/Jellyfin/JellyfinRootView.swift index abbb35c78..fdd3c0edf 100644 --- a/BookPlayer/Jellyfin/JellyfinRootView.swift +++ b/BookPlayer/Jellyfin/JellyfinRootView.swift @@ -8,96 +8,608 @@ import SwiftUI -@MainActor -final class BPNavigation: ObservableObject { - var dismiss: DismissAction? +struct JellyfinRootView: View { + let connectionService: JellyfinConnectionService - @Published var path = NavigationPath() + @StateObject private var connectionViewModel: JellyfinConnectionViewModel - nonisolated init() {} -} + @State private var resolvedLibrary: JellyfinLibraryItem? + @State private var availableLibraries: [JellyfinLibraryItem]? + @State private var loadError: Error? -struct JellyfinRootView: View { - @StateObject var navigation: BPNavigation - @StateObject var connectionViewModel: JellyfinConnectionViewModel + private var savedLibraryId: String? { + connectionService.connection?.selectedLibraryId + } @EnvironmentObject private var singleFileDownloadService: SingleFileDownloadService @EnvironmentObject private var theme: ThemeViewModel @Environment(\.dismiss) var dismiss + @Environment(\.listState) private var listState init(connectionService: JellyfinConnectionService) { + self.connectionService = connectionService + self._connectionViewModel = .init( + wrappedValue: .init(connectionService: connectionService) + ) + } + + @State private var showLibraryPicker = false + @State private var showConnectionForm = false + @State private var isLoadingLibraries = false + + private var isReady: Bool { + resolvedLibrary != nil + } + + private var switchLibraryAction: (() -> Void)? { + guard let libraries = availableLibraries, libraries.count > 1 else { return nil } + return { showLibraryPicker = true } + } + + var body: some View { + TabView { + Tab("books_title", systemImage: "books.vertical.fill") { + JellyfinTabRoot( + library: resolvedLibrary, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + dismissAll: dismiss + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } + Tab("Authors", systemImage: "person.2.fill") { + JellyfinEntityTabRoot( + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + makeViewModel: { nav in + JellyfinAuthorsListViewModel( + parentID: resolvedLibrary?.id, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: nav, + navigationTitle: "Authors" + ) + } + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } + } + .toolbarColorScheme(theme.useDarkVariant ? .dark : .light, for: .tabBar) + .tint(theme.linkColor) + .disabled(!isReady) + .loadingOverlay(isLoadingLibraries) + .alert( + "error_title".localized, + isPresented: .init(get: { loadError != nil }, set: { if !$0 { loadError = nil } }), + actions: { + Button("ok_button".localized) { + loadError = nil + showConnectionForm = true + } + }, + message: { Text(loadError?.localizedDescription ?? "") } + ) + .sheet(isPresented: $showConnectionForm) { + NavigationStack { + IntegrationConnectionView(viewModel: connectionViewModel, integrationName: "Jellyfin") + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + if connectionService.connection != nil { + ToolbarItemGroup(placement: .confirmationAction) { + Button("integration_connect_button") { + showConnectionForm = false + Task { await loadLibraries() } + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + } + .tint(theme.linkColor) + .environmentObject(theme) + .interactiveDismissDisabled() + } + .sheet(isPresented: $showLibraryPicker) { + libraryPickerSheet + .interactiveDismissDisabled(resolvedLibrary == nil) + } + .environmentObject(theme) + .onChange(of: availableLibraries) { _, libraries in + if let libraries, libraries.count > 1, resolvedLibrary == nil { + showLibraryPicker = true + } + } + .onChange(of: connectionViewModel.connectionState) { _, newValue in + if newValue == .connected { + showConnectionForm = false + if resolvedLibrary == nil { + Task { await loadLibraries() } + } + } + } + .task { + if connectionService.connection == nil { + showConnectionForm = true + } else if resolvedLibrary == nil { + await loadLibraries() + } + } + } + + // MARK: - Library Picker + + private func selectLibrary(_ library: JellyfinLibraryItem) { + resolvedLibrary = library + connectionService.saveSelectedLibrary(id: library.id) + showLibraryPicker = false + } + + private var libraryPickerSheet: some View { + NavigationStack { + List(availableLibraries ?? []) { library in + Button { + selectLibrary(library) + } label: { + HStack { + JellyfinLibraryItemImageView(item: library) + .frame(width: 50, height: 50) + Text(library.name) + .foregroundStyle(theme.primaryColor) + Spacer() + if library.id == resolvedLibrary?.id { + Image(systemName: "checkmark") + .foregroundStyle(theme.linkColor) + } + } + } + } + .scrollContentBackground(.hidden) + .background(theme.systemBackgroundColor) + .navigationTitle("library_title".localized) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if resolvedLibrary != nil { + ToolbarItem(placement: .cancellationAction) { + Button("done_title".localized) { showLibraryPicker = false } + } + } + } + } + .environmentObject(theme) + } + + private func loadLibraries() async { + isLoadingLibraries = true + defer { isLoadingLibraries = false } + do { + let libraries = try await connectionService.fetchTopLevelItems() + if libraries.count == 1, let library = libraries.first { + selectLibrary(library) + } else if let savedId = savedLibraryId, + let saved = libraries.first(where: { $0.id == savedId }) { + selectLibrary(saved) + availableLibraries = libraries + } else { + availableLibraries = libraries + } + } catch is CancellationError { + // ignore + } catch { + loadError = error + } + } +} + +// MARK: - Books Tab (folder-based, same as original) + +private struct JellyfinTabRoot: View { + let connectionService: JellyfinConnectionService + let singleFileDownloadService: SingleFileDownloadService + let onDismiss: () -> Void + var onSwitchLibrary: (() -> Void)? + var dismissAll: DismissAction? + + @StateObject private var navigation = BPNavigation() + @StateObject var viewModel: JellyfinLibraryViewModel + @State private var isEditing = false + @State private var showConnectionDetails = false + + @EnvironmentObject private var theme: ThemeViewModel + + init( + library: JellyfinLibraryItem?, + connectionService: JellyfinConnectionService, + singleFileDownloadService: SingleFileDownloadService, + onDismiss: @escaping () -> Void, + onSwitchLibrary: (() -> Void)? = nil, + dismissAll: DismissAction? = nil + ) { + self.connectionService = connectionService + self.singleFileDownloadService = singleFileDownloadService + self.onDismiss = onDismiss + self.onSwitchLibrary = onSwitchLibrary + self.dismissAll = dismissAll + let navigation = BPNavigation() self._navigation = .init(wrappedValue: navigation) - self._connectionViewModel = .init( - wrappedValue: .init( + self._viewModel = .init( + wrappedValue: JellyfinLibraryViewModel( + folderID: library?.id, connectionService: connectionService, - navigation: navigation + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: library?.name ?? "" ) ) } var body: some View { NavigationStack(path: $navigation.path) { - JellyfinConnectionView(viewModel: connectionViewModel) + JellyfinLibraryView(viewModel: viewModel) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: JellyfinLibraryLevelData.self) { destination in + destinationView(for: destination) + } .toolbar { ToolbarItemGroup(placement: .cancellationAction) { - cancelToolbarButton + cogMenu + } + if let onSwitchLibrary { + ToolbarItem(placement: .topBarTrailing) { + Button { + onSwitchLibrary() + } label: { + Image(systemName: "building.columns") + .foregroundStyle(theme.linkColor) + } + .accessibilityLabel("Switch Library") + } + } + } + } + .environment(\.tabEditing, $isEditing) + .toolbar(isEditing ? .hidden : .visible, for: .tabBar) + .tint(theme.linkColor) + .sheet(isPresented: $showConnectionDetails) { + connectionDetailsSheet + } + } + + @ViewBuilder + private func destinationView(for destination: JellyfinLibraryLevelData) -> some View { + switch destination { + case .topLevel(let libraryName): + JellyfinLibraryView( + viewModel: JellyfinLibraryViewModel( + folderID: nil, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: libraryName + ) + ) + case .folder(let item): + JellyfinLibraryView( + viewModel: JellyfinLibraryViewModel( + folderID: item.id, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: item.name + ) + ) + case .authorBooks(let authorID, let authorName, let parentID): + JellyfinLibraryView( + viewModel: JellyfinAuthorBooksViewModel( + authorID: authorID, + parentID: parentID, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: authorName + ) + ) + case .narratorBooks(let personID, let personName, let parentID): + JellyfinLibraryView( + viewModel: JellyfinNarratorBooksViewModel( + personID: personID, + parentID: parentID, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: personName + ) + ) + case .details(let item): + JellyfinAudiobookDetailsView( + viewModel: JellyfinAudiobookDetailsViewModel( + item: item, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService + ) + ) { + onDismiss() + } + } + } + + private var cogMenu: some View { + Menu { + Button { + showConnectionDetails = true + } label: { + Label("integration_connection_details_title".localized, systemImage: "server.rack") + } + Button { + onDismiss() + } label: { + Label("voiceover_close_button", systemImage: "xmark") + } + } label: { + Image(systemName: "gearshape") + .foregroundStyle(theme.linkColor) + } + .accessibilityLabel("settings_title") + } + + private var connectionDetailsSheet: some View { + NavigationStack { + IntegrationSettingsView( + viewModel: JellyfinConnectionViewModel( + connectionService: connectionService, + mode: .viewDetails + ), + integrationName: "Jellyfin" + ) + .toolbar { + if connectionService.connection == nil { + ToolbarItemGroup(placement: .cancellationAction) { + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } else { + ToolbarItemGroup(placement: .confirmationAction) { + Button("done_title".localized) { + showConnectionDetails = false + } } } + } + } + .tint(theme.linkColor) + .environmentObject(theme) + } +} + +// MARK: - Entity Tab Root (Authors / Narrators) +// Reuses JellyfinTabRoot structure but with a list-specific ViewModel + +private struct JellyfinEntityTabRoot: View +where ViewModel.Item == JellyfinLibraryItem { + let connectionService: JellyfinConnectionService + let singleFileDownloadService: SingleFileDownloadService + let onDismiss: () -> Void + var onSwitchLibrary: (() -> Void)? + var dismissAll: DismissAction? + + @StateObject private var navigation = BPNavigation() + @StateObject var viewModel: ViewModel + @State private var isEditing = false + @State private var showConnectionDetails = false + + @EnvironmentObject private var theme: ThemeViewModel + + init( + connectionService: JellyfinConnectionService, + singleFileDownloadService: SingleFileDownloadService, + onDismiss: @escaping () -> Void, + onSwitchLibrary: (() -> Void)? = nil, + dismissAll: DismissAction? = nil, + makeViewModel: (BPNavigation) -> ViewModel + ) { + self.connectionService = connectionService + self.singleFileDownloadService = singleFileDownloadService + self.onDismiss = onDismiss + self.onSwitchLibrary = onSwitchLibrary + self.dismissAll = dismissAll + + let navigation = BPNavigation() + let vm = makeViewModel(navigation) + self._navigation = .init(wrappedValue: navigation) + self._viewModel = .init(wrappedValue: vm) + } + + var body: some View { + NavigationStack(path: $navigation.path) { + JellyfinLibraryView(viewModel: viewModel) .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: JellyfinLibraryLevelData.self) { destination in - switch destination { - case .topLevel(let libraryName): - JellyfinLibraryView( - viewModel: JellyfinLibraryViewModel( - folderID: nil, - connectionService: connectionViewModel.connectionService, - singleFileDownloadService: singleFileDownloadService, - navigation: navigation, - navigationTitle: libraryName - ) - ) - case .folder(let item): - JellyfinLibraryView( - viewModel: JellyfinLibraryViewModel( - folderID: item.id, - connectionService: connectionViewModel.connectionService, - singleFileDownloadService: singleFileDownloadService, - navigation: navigation, - navigationTitle: item.name - ) + JellyfinTabRoot.sharedDestinationView( + for: destination, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + onDismiss: onDismiss + ) + } + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + JellyfinTabRoot.cogMenuView( + theme: theme, + connectionService: connectionService, + showConnectionDetails: $showConnectionDetails, + onDismiss: onDismiss ) - case .details(let item): - JellyfinAudiobookDetailsView( - viewModel: JellyfinAudiobookDetailsViewModel( - item: item, - connectionService: connectionViewModel.connectionService, - singleFileDownloadService: singleFileDownloadService - ) - ) { - dismiss() + } + if let onSwitchLibrary { + ToolbarItem(placement: .topBarTrailing) { + Button { + onSwitchLibrary() + } label: { + Image(systemName: "building.columns") + .foregroundStyle(theme.linkColor) + } + .accessibilityLabel("Switch Library") } } } } + .environment(\.tabEditing, $isEditing) + .toolbar(isEditing ? .hidden : .visible, for: .tabBar) .tint(theme.linkColor) - .environmentObject(theme) - .onAppear { - navigation.dismiss = dismiss + .sheet(isPresented: $showConnectionDetails) { + JellyfinTabRoot.connectionDetailsSheetView( + connectionService: connectionService, + showConnectionDetails: $showConnectionDetails, + theme: theme, + dismissAll: dismissAll + ) } } +} + +// MARK: - Shared helpers on JellyfinTabRoot +extension JellyfinTabRoot { @ViewBuilder - private var cancelToolbarButton: some View { - Button( - action: { - dismiss() - }, - label: { - Image(systemName: "xmark") - .foregroundStyle(theme.linkColor) + static func sharedDestinationView( + for destination: JellyfinLibraryLevelData, + connectionService: JellyfinConnectionService, + singleFileDownloadService: SingleFileDownloadService, + navigation: BPNavigation, + onDismiss: @escaping () -> Void + ) -> some View { + switch destination { + case .topLevel(let libraryName): + JellyfinLibraryView( + viewModel: JellyfinLibraryViewModel( + folderID: nil, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: libraryName + ) + ) + case .folder(let item): + JellyfinLibraryView( + viewModel: JellyfinLibraryViewModel( + folderID: item.id, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: item.name + ) + ) + case .authorBooks(let authorID, let authorName, let parentID): + JellyfinLibraryView( + viewModel: JellyfinAuthorBooksViewModel( + authorID: authorID, + parentID: parentID, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: authorName + ) + ) + case .narratorBooks(let personID, let personName, let parentID): + JellyfinLibraryView( + viewModel: JellyfinNarratorBooksViewModel( + personID: personID, + parentID: parentID, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: personName + ) + ) + case .details(let item): + JellyfinAudiobookDetailsView( + viewModel: JellyfinAudiobookDetailsViewModel( + item: item, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService + ) + ) { + onDismiss() } - ) + } + } + + static func cogMenuView( + theme: ThemeViewModel, + connectionService: JellyfinConnectionService, + showConnectionDetails: Binding, + onDismiss: @escaping () -> Void + ) -> some View { + Menu { + Button { + showConnectionDetails.wrappedValue = true + } label: { + Label("integration_connection_details_title".localized, systemImage: "server.rack") + } + Button { + onDismiss() + } label: { + Label("voiceover_close_button", systemImage: "xmark") + } + } label: { + Image(systemName: "gearshape") + .foregroundStyle(theme.linkColor) + } + } + + static func connectionDetailsSheetView( + connectionService: JellyfinConnectionService, + showConnectionDetails: Binding, + theme: ThemeViewModel, + dismissAll: DismissAction? = nil + ) -> some View { + NavigationStack { + IntegrationSettingsView( + viewModel: JellyfinConnectionViewModel( + connectionService: connectionService, + mode: .viewDetails + ), + integrationName: "Jellyfin" + ) + .toolbar { + if connectionService.connection == nil { + ToolbarItemGroup(placement: .cancellationAction) { + Button { + dismissAll?() + } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } else { + ToolbarItemGroup(placement: .confirmationAction) { + Button("done_title".localized) { + showConnectionDetails.wrappedValue = false + } + } + } + } + } + .tint(theme.linkColor) + .environmentObject(theme) } } diff --git a/BookPlayer/Jellyfin/JellyfinSettingsView.swift b/BookPlayer/Jellyfin/JellyfinSettingsView.swift deleted file mode 100644 index 94effcdf5..000000000 --- a/BookPlayer/Jellyfin/JellyfinSettingsView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// JellyfinSettingsView.swift -// BookPlayer -// -// Created by Gianni Carlo on 8/6/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import SwiftUI - -struct JellyfinSettingsView: View { - @StateObject var viewModel: JellyfinConnectionViewModel - - var body: some View { - JellyfinConnectionView(viewModel: viewModel) - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift index a5a9ea005..3162eaeb7 100644 --- a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift +++ b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift @@ -6,173 +6,29 @@ // Copyright © 2024 BookPlayer LLC. All rights reserved. // -import BookPlayerKit -import Kingfisher import SwiftUI +/// Thin wrapper providing Jellyfin-specific image view to the shared details view. struct JellyfinAudiobookDetailsView< - Model: JellyfinAudiobookDetailsViewModelProtocol ->: View { + Model: IntegrationDetailsViewModelProtocol +>: View +where Model.Item == JellyfinLibraryItem, Model.Details == JellyfinAudiobookDetailsData { - @State private var isFilePathExpanded: Bool = false - @State private var isGenresExpanded: Bool = false - @State private var isOverviewExpanded: Bool = true - @State private var isTagsExpanded: Bool = true - @StateObject var viewModel: Model - @EnvironmentObject private var theme: ThemeViewModel - @State var filePathLineLimit: Int? = 1 - - var onDownloadTap: (() -> Void) - - var voiceOverBookInfo: String { - guard let details = viewModel.details else { - return viewModel.item.name - } - - return VoiceOverService.playerMetaText( - title: viewModel.item.name, - author: details.artist ?? "voiceover_unknown_author".localized - ) - } + @ObservedObject var viewModel: Model + var onDownloadTap: () -> Void var body: some View { - ScrollView { - VStack { + IntegrationAudiobookDetailsView( + viewModel: viewModel, + onDownloadTap: onDownloadTap, + imageContent: { JellyfinLibraryItemImageView(item: viewModel.item) - .environment(\.jellyfinService, viewModel.connectionService) - .accessibilityHidden(true) - .padding(.horizontal, Spacing.L1) - - Text(viewModel.item.name) - .bpFont(.titleLarge) - .accessibilityLabel(voiceOverBookInfo) - .foregroundStyle(theme.primaryColor) - .multilineTextAlignment(.center) - - if let artist = viewModel.details?.artist { - Text(artist) - .bpFont(.title2) - .foregroundStyle(theme.secondaryColor) - .lineLimit(1) - .accessibilityHidden(true) - } - - if let details = viewModel.details { - HStack(alignment: .center) { - Text(details.runtimeString) - .accessibilityLabel("book_duration_title".localized + details.runtimeString) - Text(" | ") - Text(details.fileSizeString) - } - .foregroundStyle(theme.primaryColor) - .bpFont(.caption) - } - - Button { - do { - try viewModel.beginDownloadAudiobook(viewModel.item) - onDownloadTap() - } catch { - viewModel.error = error - } - } label: { - HStack { - Image(systemName: "square.and.arrow.down") - Text("download_title".localized) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .foregroundStyle(theme.systemBackgroundColor) - .background(theme.linkColor) - .cornerRadius(10) - } - .padding(.horizontal) - - if let details = viewModel.details { - VStack { - if let filePath = details.filePath { - DisclosureGroup("File Path", isExpanded: $isFilePathExpanded) { - Text(filePath) - } - .accessibilityHidden(true) - } - - if let genres = details.genres, - !genres.isEmpty - { - DisclosureGroup("Genres", isExpanded: $isGenresExpanded) { - JellyfinTagsView(tags: genres) - } - } - - if let overview = details.overview { - DisclosureGroup("Overview", isExpanded: $isOverviewExpanded) { - Text(overview) - } - } - - if let tags = details.tags, - !tags.isEmpty - { - DisclosureGroup("Tags", isExpanded: $isTagsExpanded) { - JellyfinTagsView(tags: tags) - } - } - } - .padding(.horizontal) - } + .environment(\.jellyfinService, jellyfinConnectionService) } - } - .applyListStyle(with: theme, background: theme.systemBackgroundColor) - .tint(theme.linkColor) - .errorAlert(error: $viewModel.error) - .onAppear { - viewModel.fetchData() - } - .onDisappear { - viewModel.cancelFetchData() - } - .scrollIndicators(.hidden) + ) } -} - -final class MockJellyfinAudiobookDetailsViewModel: JellyfinAudiobookDetailsViewModelProtocol { - var connectionService = JellyfinConnectionService() - - let item: JellyfinLibraryItem - let details: JellyfinAudiobookDetailsData? - var error: Error? - init(item: JellyfinLibraryItem, details: JellyfinAudiobookDetailsData?) { - self.item = item - self.details = details + private var jellyfinConnectionService: JellyfinConnectionService { + (viewModel as? JellyfinAudiobookDetailsViewModel)?.connectionService ?? .init() } - - @MainActor - func fetchData() {} - - @MainActor - func cancelFetchData() {} - - @MainActor - func beginDownloadAudiobook(_ item: JellyfinLibraryItem) {} -} - -#Preview { - let item = JellyfinLibraryItem(id: "0", name: "Mock Audiobook", kind: .audiobook) - let details = JellyfinAudiobookDetailsData( - artist: "The Author's Name", - filePath: - "/path/to/file/which/might/be/very/very/very/very/very/very/very/very/very/very/very/very/very/very/long/actually.m4a", - fileSize: 18_967_839, - overview: "Overview", - runtimeInSeconds: 580.1737409, - genres: nil, - tags: nil - ) - let parentData = JellyfinLibraryLevelData.topLevel(libraryName: "Mock Library") - let vm = MockJellyfinAudiobookDetailsViewModel(item: item, details: details) - JellyfinAudiobookDetailsView(viewModel: vm, onDownloadTap: {}) - .environmentObject(MockJellyfinLibraryViewModel(data: parentData)) } diff --git a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift index 11e154544..242c37730 100644 --- a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift +++ b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift @@ -10,7 +10,7 @@ import BookPlayerKit import Foundation import JellyfinAPI -struct JellyfinAudiobookDetailsData { +struct JellyfinAudiobookDetailsData: IntegrationDetailsDataProtocol { let artist: String? let filePath: String? let fileSize: Int? @@ -39,23 +39,7 @@ struct JellyfinAudiobookDetailsData { } } -protocol JellyfinAudiobookDetailsViewModelProtocol: ObservableObject { - var item: JellyfinLibraryItem { get } - var details: JellyfinAudiobookDetailsData? { get } - var connectionService: JellyfinConnectionService { get } - var error: Error? { get set } - - @MainActor - func fetchData() - - @MainActor - func cancelFetchData() - - @MainActor - func beginDownloadAudiobook(_ item: JellyfinLibraryItem) throws -} - -class JellyfinAudiobookDetailsViewModel: JellyfinAudiobookDetailsViewModelProtocol { +class JellyfinAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { let item: JellyfinLibraryItem let connectionService: JellyfinConnectionService diff --git a/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift b/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift index 6396ae14e..e720e5e06 100644 --- a/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift +++ b/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift @@ -44,11 +44,8 @@ struct JellyfinLibraryGridItemView: View { } .accessibilityHidden(true) - switch item.kind { - case .userView, .folder: + if item.isNavigable { folderBadge - case .audiobook: - EmptyView() } } diff --git a/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridView.swift b/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridView.swift deleted file mode 100644 index 67866b4e8..000000000 --- a/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridView.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// JellyfinLibraryGridView.swift -// BookPlayer -// -// Created by Gianni Carlo on 5/6/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import BookPlayerKit -import SwiftUI - -struct JellyfinLibraryGridView: View { - @ObservedObject var viewModel: Model - - @ScaledMetric var accessabilityScale: CGFloat = 1 - @State private var availableSize: CGSize = .zero - private let itemMinSizeBase = CGSize(width: 100, height: 100) - private let itemMaxSizeBase = CGSize(width: 250, height: 250) - private let itemSpacingBase = 20.0 - - private var columns: [GridItem] { - [GridItem( - .adaptive( - minimum: itemMinSizeBase.width, - maximum: itemMaxSizeBase.width - ), - spacing: itemSpacingBase * accessabilityScale - )] - } - - var body: some View { - LazyVGrid(columns: columns, spacing: itemSpacingBase * accessabilityScale) { - ForEach(viewModel.items, id: \.id) { item in - JellyfinLibraryGridItemView(item: item, isSelected: viewModel.selectedItems.contains(item.id)) - .accessibilityAddTraits(.isButton) - .onTapGesture { - if viewModel.editMode.isEditing { - guard case .audiobook = item.kind else { return } - viewModel.onSelectTapped(for: item) - } else { - switch item.kind { - case .audiobook: - viewModel.navigation.path.append(JellyfinLibraryLevelData.details(data: item)) - case .userView, .folder: - viewModel.navigation.path.append(JellyfinLibraryLevelData.folder(data: item)) - } - } - } - .onAppear { - viewModel.fetchMoreItemsIfNeeded(currentItem: item) - } - } - } - } -} - -final class MockJellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, ObservableObject { - var navigationTitle: String = "" - var navigation = BPNavigation() - var connectionService = JellyfinConnectionService() - - let data: JellyfinLibraryLevelData - - var searchQuery: String = "" - var isSearchable: Bool { false } - - var layout = JellyfinLayout.Options.grid - var sortBy = JellyfinLayout.SortBy.smart - - @Published var items: [JellyfinLibraryItem] = [] - var totalItems: Int { items.count } - var error: Error? - - var editMode: EditMode = .inactive - var selectedItems: Set = [] - var downloadRemaining: Int = 0 - var showingDownloadConfirmation: Bool = false - - init(data: JellyfinLibraryLevelData) { - self.data = data - } - - func fetchInitialItems() {} - func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) {} - func cancelFetchItems() {} - - func handleDoneAction() {} - - func onEditToggleSelectTapped() {} - func onSelectTapped(for item: JellyfinLibraryItem) {} - func onSelectAllTapped() {} - func onDownloadTapped() {} - func onDownloadFolderTapped() {} - func confirmDownloadFolder() {} -} - -#Preview("top level") { - let model = { - let model = MockJellyfinLibraryViewModel(data: .topLevel(libraryName: "Mock Library")) - model.items = [ - JellyfinLibraryItem(id: "0.0", name: "subfolder", kind: .folder), - JellyfinLibraryItem(id: "0.1", name: "book", kind: .audiobook), - JellyfinLibraryItem(id: "0.2", name: "another book", kind: .audiobook), - JellyfinLibraryItem(id: "0.3", name: "subfolder 2", kind: .folder), - JellyfinLibraryItem( - id: "0.4", - name: "book 2 with a very very very very very long name\nmaybe even a line break?", - kind: .audiobook - ), - JellyfinLibraryItem(id: "0.5", name: "another book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.6", name: "subfolder 3", kind: .folder), - JellyfinLibraryItem(id: "0.7", name: "book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.8", name: "another book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.9", name: "subfolder 2", kind: .folder), - JellyfinLibraryItem(id: "0.10", name: "book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.11", name: "another book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.12", name: "subfolder 3", kind: .folder), - JellyfinLibraryItem(id: "0.13", name: "book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.14", name: "another book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.15", name: "subfolder 2", kind: .folder), - JellyfinLibraryItem(id: "0.16", name: "book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.17", name: "another book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.18", name: "subfolder 3", kind: .folder), - JellyfinLibraryItem(id: "0.19", name: "book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.20", name: "another book 3", kind: .audiobook), - ] - return model - }() - JellyfinLibraryView(viewModel: model) -} - -#Preview("folder") { - let model = { - let topLevelFolder = JellyfinLibraryItem(id: "0", name: "some folder", kind: .folder) - let model = MockJellyfinLibraryViewModel(data: .folder(data: topLevelFolder)) - model.items = [ - JellyfinLibraryItem(id: "0.0", name: "subfolder", kind: .folder), - JellyfinLibraryItem(id: "0.1", name: "book", kind: .audiobook), - JellyfinLibraryItem(id: "0.2", name: "another book", kind: .audiobook), - JellyfinLibraryItem(id: "0.3", name: "subfolder 2", kind: .folder), - JellyfinLibraryItem( - id: "0.4", - name: "book 2 with a very very long name\nmaybe even a line break?", - kind: .audiobook - ), - JellyfinLibraryItem(id: "0.5", name: "another book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.6", name: "subfolder 3", kind: .folder), - JellyfinLibraryItem(id: "0.7", name: "book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.8", name: "another book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.9", name: "subfolder 2", kind: .folder), - JellyfinLibraryItem(id: "0.10", name: "book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.11", name: "another book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.12", name: "subfolder 3", kind: .folder), - JellyfinLibraryItem(id: "0.13", name: "book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.14", name: "another book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.15", name: "subfolder 2", kind: .folder), - JellyfinLibraryItem(id: "0.16", name: "book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.17", name: "another book 2", kind: .audiobook), - JellyfinLibraryItem(id: "0.18", name: "subfolder 3", kind: .folder), - JellyfinLibraryItem(id: "0.19", name: "book 3", kind: .audiobook), - JellyfinLibraryItem(id: "0.20", name: "another book 3", kind: .audiobook), - ] - return model - }() - JellyfinLibraryView(viewModel: model) -} diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItem.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItem.swift index 67f8361e0..cf4d87354 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItem.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItem.swift @@ -9,11 +9,13 @@ import Foundation import JellyfinAPI -struct JellyfinLibraryItem: Identifiable, Hashable { +struct JellyfinLibraryItem: IntegrationLibraryItemProtocol { enum Kind { case userView case folder case audiobook + case author + case narrator } let id: String @@ -22,6 +24,25 @@ struct JellyfinLibraryItem: Identifiable, Hashable { let blurHash: String? let imageAspectRatio: Double? + + var isDownloadable: Bool { + kind == .audiobook + } + + var isNavigable: Bool { + !isDownloadable + } + + var displayName: String { name } + + var placeholderImageName: String { + switch kind { + case .audiobook: "waveform" + case .userView, .folder: "folder" + case .author: "person" + case .narrator: "mic" + } + } } extension JellyfinLibraryItem { @@ -47,4 +68,20 @@ extension JellyfinLibraryItem { self.init(id: id, name: name, kind: kind, blurHash: blurHash, imageAspectRatio: apiItem.primaryImageAspectRatio) } + + /// Create an author item from an AlbumArtists API response + init?(authorApiItem: BaseItemDto) { + guard let id = authorApiItem.id else { return nil } + let name = authorApiItem.name ?? id + let blurHash = authorApiItem.imageBlurHashes?.primary?.first?.value + self.init(id: id, name: name, kind: .author, blurHash: blurHash, imageAspectRatio: authorApiItem.primaryImageAspectRatio) + } + + /// Create a narrator item from a Persons API response + init?(narratorApiItem: BaseItemDto) { + guard let id = narratorApiItem.id else { return nil } + let name = narratorApiItem.name ?? id + let blurHash = narratorApiItem.imageBlurHashes?.primary?.first?.value + self.init(id: id, name: name, kind: .narrator, blurHash: blurHash, imageAspectRatio: narratorApiItem.primaryImageAspectRatio) + } } diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift index 95f4ca566..4480b93b8 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItemImageView.swift @@ -52,7 +52,7 @@ fileprivate struct JellyfinLibraryItemImageViewWrapper: View, Equatable { } static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.item.kind == rhs.item.kind && lhs.item.id == rhs.item.id + return lhs.item.id == rhs.item.id && lhs.url == rhs.url } @ViewBuilder @@ -80,10 +80,7 @@ fileprivate struct JellyfinLibraryItemImageViewWrapper: View, Equatable { } private var placeholderImageName: String { - switch item.kind { - case .userView, .folder: "folder" - case .audiobook: "waveform" - } + item.placeholderImageName } } diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift index 9b0959330..1fadc20c4 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift @@ -6,140 +6,53 @@ // Copyright © 2024 BookPlayer LLC. All rights reserved. // -import BookPlayerKit -import Kingfisher import SwiftUI -struct JellyfinLibraryView: View { +/// Thin wrapper providing Jellyfin-specific cell, row, sort picker, and environment +/// to the shared `IntegrationLibraryView`. +struct JellyfinLibraryView: View +where Model.Item == JellyfinLibraryItem { @StateObject var viewModel: Model - @EnvironmentObject private var theme: ThemeViewModel - - var navigationTitle: Text { - if viewModel.editMode.isEditing, !viewModel.selectedItems.isEmpty { - return Text( - String(format: "integration_selection_count".localized, viewModel.selectedItems.count, viewModel.totalItems) - ) - } else { - return Text(viewModel.navigationTitle) - } - } var body: some View { - Group { - if viewModel.layout == .grid { - ScrollView { - JellyfinLibraryGridView(viewModel: viewModel) - .padding() - } - } else { - JellyfinLibraryListView(viewModel: viewModel) - .scrollContentBackground(.hidden) - } - } - .scrollDismissesKeyboard(.interactively) - .background(theme.systemBackgroundColor) - .environment(\.jellyfinService, viewModel.connectionService) - .modifier(JellyfinSearchableModifier(isSearchable: viewModel.isSearchable, text: $viewModel.searchQuery)) - .searchPresentationToolbarBehavior(.avoidHidingContent) - .onAppear { viewModel.fetchInitialItems() } - .onDisappear { viewModel.cancelFetchItems() } - .errorAlert(error: $viewModel.error) - .environment(\.editMode, $viewModel.editMode) - .confirmationDialog( - "download_folder_confirmation_title".localized, - isPresented: $viewModel.showingDownloadConfirmation - ) { - Button("download_title".localized) { - viewModel.confirmDownloadFolder() - } - Button("cancel_button".localized, role: .cancel) {} - } message: { - Text(String.localizedStringWithFormat("download_folder_confirmation_message".localized, viewModel.totalItems)) - } - .toolbar { - ToolbarItem(placement: .principal) { - navigationTitle - .bpFont(.headline) - .foregroundStyle(theme.primaryColor) - } - ToolbarItemGroup(placement: .topBarTrailing) { - toolbarTrailing - } - } - .toolbar { - if viewModel.editMode.isEditing { - ToolbarItemGroup(placement: .bottomBar) { - bottomBar - } - } - } + IntegrationLibraryView( + viewModel: viewModel, + gridCell: { item in + JellyfinLibraryGridItemView( + item: item, + isSelected: viewModel.selectedItems.contains(item.id) + ) + }, + listRow: { item in + JellyfinLibraryListItemView(item: item) + }, + sortPicker: { + sortPickerContent + } + ) + .environment(\.jellyfinService, jellyfinConnectionService) } - @ViewBuilder - var toolbarTrailing: some View { - if !viewModel.editMode.isEditing { - Menu { - ThemedSection { - Button(action: viewModel.onEditToggleSelectTapped) { - Label("select_title".localized, systemImage: "checkmark.circle") - } - Button(action: viewModel.onDownloadFolderTapped) { - Label("download_title".localized, systemImage: "arrow.down.to.line") - } - } - - layoutPreferences - } label: { - Label("more_title".localized, systemImage: "ellipsis.circle") - } - } else { - Button(action: viewModel.onEditToggleSelectTapped) { - Text("done_title".localized).bold() - } - } + private var jellyfinConnectionService: JellyfinConnectionService { + (viewModel as? JellyfinLibraryViewModel)?.connectionService + ?? (viewModel as? JellyfinAuthorBooksViewModel)?.connectionService + ?? (viewModel as? JellyfinNarratorBooksViewModel)?.connectionService + ?? (viewModel as? JellyfinAuthorsListViewModel)?.connectionService + ?? (viewModel as? JellyfinNarratorsListViewModel)?.connectionService + ?? .init() } @ViewBuilder - var layoutPreferences: some View { - ThemedSection { - Picker(selection: $viewModel.layout, label: Text("Layout options".localized)) { - Label("Grid".localized, systemImage: "square.grid.2x2").tag(JellyfinLayout.Options.grid) - Label("List".localized, systemImage: "list.bullet").tag(JellyfinLayout.Options.list) - } - } - ThemedSection { - Picker(selection: $viewModel.sortBy, label: Text("Sort by".localized)) { + private var sortPickerContent: some View { + if let vm = viewModel as? JellyfinLibraryViewModel { + Picker(selection: Binding( + get: { vm.sortBy }, + set: { vm.sortBy = $0 } + ), label: Text("Sort by".localized)) { Text("Default".localized).tag(JellyfinLayout.SortBy.smart) Label("sort_most_recent_button", systemImage: "clock").tag(JellyfinLayout.SortBy.recent) Label("Name".localized, systemImage: "textformat.abc").tag(JellyfinLayout.SortBy.name) } } } - - @ViewBuilder - var bottomBar: some View { - Button(action: viewModel.onSelectAllTapped) { - Image(systemName: viewModel.selectedItems.isEmpty ? "checklist.checked" : "checklist.unchecked") - } - - Spacer() - - Button(action: viewModel.onDownloadTapped) { - Image(systemName: "arrow.down.to.line") - } - .disabled(viewModel.selectedItems.isEmpty) - } -} - -private struct JellyfinSearchableModifier: ViewModifier { - let isSearchable: Bool - @Binding var text: String - - func body(content: Content) -> some View { - if isSearchable { - content.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) - } else { - content - } - } } diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift index 7924c9a53..3f4bbbe1f 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift @@ -16,60 +16,18 @@ import SwiftUI enum JellyfinLibraryLevelData: Equatable, Hashable { case topLevel(libraryName: String) case folder(data: JellyfinLibraryItem) + case authorBooks(authorID: String, authorName: String, parentID: String?) + case narratorBooks(personID: String, personName: String, parentID: String?) case details(data: JellyfinLibraryItem) } -protocol JellyfinLibraryViewModelProtocol: ObservableObject { - var navigation: BPNavigation { get set } - var navigationTitle: String { get } - var layout: JellyfinLayout.Options { get set } - var sortBy: JellyfinLayout.SortBy { get set } - - var items: [JellyfinLibraryItem] { get set } - var totalItems: Int { get } - var error: Error? { get set } - - var editMode: EditMode { get set } - var selectedItems: Set { get set } - var showingDownloadConfirmation: Bool { get set } - - var searchQuery: String { get set } - var isSearchable: Bool { get } - - var connectionService: JellyfinConnectionService { get } - - func fetchInitialItems() - func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) - func cancelFetchItems() - - @MainActor - func handleDoneAction() - - @MainActor - func onEditToggleSelectTapped() - @MainActor - func onSelectTapped(for item: JellyfinLibraryItem) - @MainActor - func onSelectAllTapped() - @MainActor - func onDownloadTapped() - @MainActor - func onDownloadFolderTapped() - @MainActor - func confirmDownloadFolder() -} - enum JellyfinLayout { - enum Options: String { - case grid, list - } - enum SortBy: String { case recent, name, smart } } -final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger { +final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLogger { enum Routes { case done } @@ -78,7 +36,7 @@ final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger let navigationTitle: String @AppStorage(Constants.UserDefaults.jellyfinLibraryLayout) - var layout: JellyfinLayout.Options = .grid + var layout: IntegrationLayout.Options = .grid @AppStorage(Constants.UserDefaults.jellyfinLibraryLayoutSortBy) var sortBy: JellyfinLayout.SortBy = .smart { @@ -104,6 +62,7 @@ final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger var onTransition: BPTransition? let folderID: String? + let recursive: Bool let connectionService: JellyfinConnectionService private let singleFileDownloadService: SingleFileDownloadService @@ -121,12 +80,14 @@ final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger init( folderID: String?, + recursive: Bool = false, connectionService: JellyfinConnectionService, singleFileDownloadService: SingleFileDownloadService, navigation: BPNavigation, navigationTitle: String ) { self.folderID = folderID + self.recursive = recursive self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService self.navigation = navigation @@ -143,6 +104,8 @@ final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger } func fetchInitialItems() { + // Don't fetch if no folder is set (library not yet resolved) + guard folderID != nil || !searchQuery.isEmpty else { return } fetchMoreItems() } @@ -159,6 +122,19 @@ final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger fetchTask = nil } + func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { + switch item.kind { + case .audiobook: + return .details(data: item) + case .userView, .folder: + return .folder(data: item) + case .author: + return .authorBooks(authorID: item.id, authorName: item.name, parentID: folderID) + case .narrator: + return .narratorBooks(personID: item.id, personName: item.name, parentID: folderID) + } + } + private func fetchMoreItems() { guard fetchTask == nil && canFetchMoreItems else { return @@ -249,7 +225,8 @@ final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger startIndex: nextStartItemIndex, limit: Self.itemBatchSize, sortBy: sortBy, - searchTerm: searchParam + searchTerm: searchParam, + recursive: recursive ) guard searchQuery == capturedQuery, !Task.isCancelled else { return } @@ -356,3 +333,478 @@ final class JellyfinLibraryViewModel: JellyfinLibraryViewModelProtocol, BPLogger } } } + +// MARK: - Author Books ViewModel + +final class JellyfinAuthorBooksViewModel: IntegrationLibraryViewModelProtocol, BPLogger { + let authorID: String + let parentID: String? + + var navigation: BPNavigation + let navigationTitle: String + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayout) + var layout: IntegrationLayout.Options = .grid + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayoutSortBy) + var sortBy: JellyfinLayout.SortBy = .smart + + @Published var searchQuery = "" + @Published var items: [JellyfinLibraryItem] = [] + @Published var totalItems = Int.max + @Published var error: Error? + + @Published var editMode: EditMode = .inactive + @Published var selectedItems: Set = [] + @Published var showingDownloadConfirmation = false + + var isSearchable: Bool { true } + + let connectionService: JellyfinConnectionService + private let singleFileDownloadService: SingleFileDownloadService + private var fetchTask: Task<(), any Error>? + private var allItems: [JellyfinLibraryItem] = [] + + init( + authorID: String, + parentID: String?, + connectionService: JellyfinConnectionService, + singleFileDownloadService: SingleFileDownloadService, + navigation: BPNavigation, + navigationTitle: String + ) { + self.authorID = authorID + self.parentID = parentID + self.connectionService = connectionService + self.singleFileDownloadService = singleFileDownloadService + self.navigation = navigation + self.navigationTitle = navigationTitle + } + + func fetchInitialItems() { + guard items.isEmpty, fetchTask == nil else { return } + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } + do { + let (items, _, _) = try await connectionService.fetchItemsByArtist( + artistID: authorID, + parentID: parentID, + startIndex: 0, + limit: nil, + sortBy: sortBy + ) + self.allItems = items + applySearch() + } catch is CancellationError { + // ignore + } catch { + self.error = error + } + } + } + + func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) {} + + func cancelFetchItems() { + fetchTask?.cancel() + fetchTask = nil + } + + func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { + switch item.kind { + case .audiobook: .details(data: item) + case .folder: .folder(data: item) + default: nil + } + } + + @MainActor func handleDoneAction() {} + + @MainActor + func onEditToggleSelectTapped() { + withAnimation { + editMode = editMode.isEditing ? .inactive : .active + } + if !editMode.isEditing { selectedItems.removeAll() } + } + + @MainActor + func onSelectTapped(for item: JellyfinLibraryItem) { + guard item.isDownloadable else { return } + if selectedItems.contains(item.id) { + selectedItems.remove(item.id) + } else { + selectedItems.insert(item.id) + } + } + + @MainActor + func onSelectAllTapped() { + if selectedItems.isEmpty { + selectedItems = Set(items.compactMap { $0.isDownloadable ? $0.id : nil }) + } else { + selectedItems.removeAll() + } + } + + @MainActor + func onDownloadTapped() { + let downloadItems = selectedItems.compactMap { id in + items.first(where: { $0.id == id && $0.isDownloadable }) + } + guard !downloadItems.isEmpty else { return } + var urls = [URL]() + for item in downloadItems { + do { + let url = try connectionService.createItemDownloadUrl(item) + urls.append(url) + } catch { + self.error = error + } + } + guard !urls.isEmpty else { return } + singleFileDownloadService.handleDownload(urls) + navigation.dismiss?() + } + + @MainActor func onDownloadFolderTapped() {} + @MainActor func confirmDownloadFolder() {} + + private func applySearch() { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if query.isEmpty { + items = allItems + } else { + items = allItems.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + totalItems = items.count + } +} + +// MARK: - Narrator Books ViewModel + +final class JellyfinNarratorBooksViewModel: IntegrationLibraryViewModelProtocol, BPLogger { + let personID: String + let parentID: String? + + var navigation: BPNavigation + let navigationTitle: String + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayout) + var layout: IntegrationLayout.Options = .grid + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayoutSortBy) + var sortBy: JellyfinLayout.SortBy = .smart + + @Published var searchQuery = "" + @Published var items: [JellyfinLibraryItem] = [] + @Published var totalItems = Int.max + @Published var error: Error? + + @Published var editMode: EditMode = .inactive + @Published var selectedItems: Set = [] + @Published var showingDownloadConfirmation = false + + var isSearchable: Bool { true } + + let connectionService: JellyfinConnectionService + private let singleFileDownloadService: SingleFileDownloadService + private var fetchTask: Task<(), any Error>? + private var allItems: [JellyfinLibraryItem] = [] + + init( + personID: String, + parentID: String?, + connectionService: JellyfinConnectionService, + singleFileDownloadService: SingleFileDownloadService, + navigation: BPNavigation, + navigationTitle: String + ) { + self.personID = personID + self.parentID = parentID + self.connectionService = connectionService + self.singleFileDownloadService = singleFileDownloadService + self.navigation = navigation + self.navigationTitle = navigationTitle + } + + func fetchInitialItems() { + guard items.isEmpty, fetchTask == nil else { return } + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } + do { + let (items, _, _) = try await connectionService.fetchItemsByPerson( + personID: personID, + personName: navigationTitle, + parentID: parentID, + startIndex: 0, + limit: nil, + sortBy: sortBy + ) + self.allItems = items + applySearch() + } catch is CancellationError { + // ignore + } catch { + self.error = error + } + } + } + + func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) {} + + func cancelFetchItems() { + fetchTask?.cancel() + fetchTask = nil + } + + func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { + switch item.kind { + case .audiobook: .details(data: item) + case .folder: .folder(data: item) + default: nil + } + } + + @MainActor func handleDoneAction() {} + + @MainActor + func onEditToggleSelectTapped() { + withAnimation { + editMode = editMode.isEditing ? .inactive : .active + } + if !editMode.isEditing { selectedItems.removeAll() } + } + + @MainActor + func onSelectTapped(for item: JellyfinLibraryItem) { + guard item.isDownloadable else { return } + if selectedItems.contains(item.id) { + selectedItems.remove(item.id) + } else { + selectedItems.insert(item.id) + } + } + + @MainActor + func onSelectAllTapped() { + if selectedItems.isEmpty { + selectedItems = Set(items.compactMap { $0.isDownloadable ? $0.id : nil }) + } else { + selectedItems.removeAll() + } + } + + @MainActor + func onDownloadTapped() { + let downloadItems = selectedItems.compactMap { id in + items.first(where: { $0.id == id && $0.isDownloadable }) + } + guard !downloadItems.isEmpty else { return } + var urls = [URL]() + for item in downloadItems { + do { + let url = try connectionService.createItemDownloadUrl(item) + urls.append(url) + } catch { + self.error = error + } + } + guard !urls.isEmpty else { return } + singleFileDownloadService.handleDownload(urls) + navigation.dismiss?() + } + + @MainActor func onDownloadFolderTapped() {} + @MainActor func confirmDownloadFolder() {} + + private func applySearch() { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if query.isEmpty { + items = allItems + } else { + items = allItems.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + totalItems = items.count + } +} + +// MARK: - Authors List ViewModel + +final class JellyfinAuthorsListViewModel: IntegrationLibraryViewModelProtocol, BPLogger { + let parentID: String? + + var navigation: BPNavigation + let navigationTitle: String + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayout) + var layout: IntegrationLayout.Options = .list + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayoutSortBy) + var sortBy: JellyfinLayout.SortBy = .name + + @Published var searchQuery = "" + @Published var items: [JellyfinLibraryItem] = [] + @Published var totalItems = Int.max + @Published var error: Error? + + @Published var editMode: EditMode = .inactive + @Published var selectedItems: Set = [] + @Published var showingDownloadConfirmation = false + + var isSearchable: Bool { true } + + let connectionService: JellyfinConnectionService + private let singleFileDownloadService: SingleFileDownloadService + private var fetchTask: Task<(), any Error>? + private var allItems: [JellyfinLibraryItem] = [] + private var disposeBag = Set() + + init( + parentID: String?, + connectionService: JellyfinConnectionService, + singleFileDownloadService: SingleFileDownloadService, + navigation: BPNavigation, + navigationTitle: String + ) { + self.parentID = parentID + self.connectionService = connectionService + self.singleFileDownloadService = singleFileDownloadService + self.navigation = navigation + self.navigationTitle = navigationTitle + + $searchQuery + .debounce(for: .milliseconds(350), scheduler: RunLoop.main) + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in self?.applyLocalSearch() } + .store(in: &disposeBag) + } + + func fetchInitialItems() { + guard items.isEmpty, fetchTask == nil else { return } + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } + do { + let (items, _) = try await connectionService.fetchAlbumArtists(parentID: parentID) + self.allItems = items + applyLocalSearch() + } catch is CancellationError { + } catch { + self.error = error + } + } + } + + func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) {} + func cancelFetchItems() { fetchTask?.cancel(); fetchTask = nil } + + func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { + guard item.kind == .author else { return nil } + return .authorBooks(authorID: item.id, authorName: item.name, parentID: parentID) + } + + @MainActor func handleDoneAction() {} + @MainActor func onEditToggleSelectTapped() {} + @MainActor func onSelectTapped(for item: JellyfinLibraryItem) {} + @MainActor func onSelectAllTapped() {} + @MainActor func onDownloadTapped() {} + @MainActor func onDownloadFolderTapped() {} + @MainActor func confirmDownloadFolder() {} + + private func applyLocalSearch() { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + items = query.isEmpty ? allItems : allItems.filter { $0.name.localizedCaseInsensitiveContains(query) } + totalItems = items.count + } +} + +// MARK: - Narrators List ViewModel + +final class JellyfinNarratorsListViewModel: IntegrationLibraryViewModelProtocol, BPLogger { + let parentID: String? + + var navigation: BPNavigation + let navigationTitle: String + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayout) + var layout: IntegrationLayout.Options = .list + + @AppStorage(Constants.UserDefaults.jellyfinLibraryLayoutSortBy) + var sortBy: JellyfinLayout.SortBy = .name + + @Published var searchQuery = "" + @Published var items: [JellyfinLibraryItem] = [] + @Published var totalItems = Int.max + @Published var error: Error? + + @Published var editMode: EditMode = .inactive + @Published var selectedItems: Set = [] + @Published var showingDownloadConfirmation = false + + var isSearchable: Bool { true } + + let connectionService: JellyfinConnectionService + private let singleFileDownloadService: SingleFileDownloadService + private var fetchTask: Task<(), any Error>? + private var allItems: [JellyfinLibraryItem] = [] + private var disposeBag = Set() + + init( + parentID: String?, + connectionService: JellyfinConnectionService, + singleFileDownloadService: SingleFileDownloadService, + navigation: BPNavigation, + navigationTitle: String + ) { + self.parentID = parentID + self.connectionService = connectionService + self.singleFileDownloadService = singleFileDownloadService + self.navigation = navigation + self.navigationTitle = navigationTitle + + $searchQuery + .debounce(for: .milliseconds(350), scheduler: RunLoop.main) + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in self?.applyLocalSearch() } + .store(in: &disposeBag) + } + + func fetchInitialItems() { + guard items.isEmpty, fetchTask == nil else { return } + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } + do { + let (items, _) = try await connectionService.fetchNarrators(parentID: parentID) + self.allItems = items + applyLocalSearch() + } catch is CancellationError { + } catch { + self.error = error + } + } + } + + func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) {} + func cancelFetchItems() { fetchTask?.cancel(); fetchTask = nil } + + func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { + guard item.kind == .narrator else { return nil } + return .narratorBooks(personID: item.id, personName: item.name, parentID: parentID) + } + + @MainActor func handleDoneAction() {} + @MainActor func onEditToggleSelectTapped() {} + @MainActor func onSelectTapped(for item: JellyfinLibraryItem) {} + @MainActor func onSelectAllTapped() {} + @MainActor func onDownloadTapped() {} + @MainActor func onDownloadFolderTapped() {} + @MainActor func confirmDownloadFolder() {} + + private func applyLocalSearch() { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + items = query.isEmpty ? allItems : allItems.filter { $0.name.localizedCaseInsensitiveContains(query) } + totalItems = items.count + } +} diff --git a/BookPlayer/Jellyfin/Library Screen/ListLayout/JellyfinLibraryListView.swift b/BookPlayer/Jellyfin/Library Screen/ListLayout/JellyfinLibraryListView.swift deleted file mode 100644 index 44ff03cf1..000000000 --- a/BookPlayer/Jellyfin/Library Screen/ListLayout/JellyfinLibraryListView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// JellyfinLibraryListView.swift -// BookPlayer -// -// Created by Gianni Carlo on 9/6/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import BookPlayerKit -import SwiftUI - -struct JellyfinLibraryListView: View { - @ObservedObject var viewModel: Model - @EnvironmentObject var theme: ThemeViewModel - - var body: some View { - List(viewModel.items, selection: $viewModel.selectedItems) { item in - row(item: item) - .selectionDisabled(item.kind != .audiobook) - .listRowBackground(theme.tertiarySystemBackgroundColor) - } - } - - func row(item: JellyfinLibraryItem) -> some View { - JellyfinLibraryListItemView(item: item) - .accessibilityAddTraits(.isButton) - .contentShape(Rectangle()) - .onTapGesture { - if viewModel.editMode.isEditing { - guard case .audiobook = item.kind else { return } - viewModel.onSelectTapped(for: item) - } else { - switch item.kind { - case .audiobook: - viewModel.navigation.path.append(JellyfinLibraryLevelData.details(data: item)) - case .userView, .folder: - viewModel.navigation.path.append(JellyfinLibraryLevelData.folder(data: item)) - } - } - } - .onAppear { - viewModel.fetchMoreItemsIfNeeded(currentItem: item) - } - .id("\(item.id)-\(viewModel.selectedItems.contains(item.id))") - } -} diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift index ac8a2f432..90e66d844 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift @@ -14,6 +14,7 @@ struct JellyfinConnectionData: Codable { let userID: String let userName: String let accessToken: String + var selectedLibraryId: String? } extension JellyfinConnectionData: CustomDebugStringConvertible { diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift index fb3a270ba..ac8f9cbab 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift +++ b/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift @@ -17,6 +17,7 @@ class JellyfinConnectionService: BPLogger { var connection: JellyfinConnectionData? var client: JellyfinClient? + init(keychainService: KeychainServiceProtocol = KeychainService()) { self.keychainService = keychainService } @@ -28,7 +29,7 @@ class JellyfinConnectionService: BPLogger { /// Finds and creates the api-client for the specified server public func findServer(at absolutePath: String) async throws -> String { guard let client = createClient(serverUrlString: absolutePath) else { - throw JellyfinError.noClient + throw IntegrationError.noClient("Jellyfin") } let publicSystemInfo = try await client.send(Paths.getPublicSystemInfo) @@ -45,7 +46,7 @@ class JellyfinConnectionService: BPLogger { serverName: String ) async throws { guard let client else { - fatalError("Client not initialized when attempting to sign in") + throw IntegrationError.noClient("Jellyfin") } let result = try await client.signIn(username: username, password: password) @@ -54,7 +55,7 @@ class JellyfinConnectionService: BPLogger { let accessToken = result.accessToken, let userID = result.user?.id else { - throw JellyfinError.unexpectedResponse(code: nil).localizedDescription + throw IntegrationError.unexpectedResponse(code: nil) } let data = JellyfinConnectionData( @@ -74,6 +75,13 @@ class JellyfinConnectionService: BPLogger { self.client = client } + func saveSelectedLibrary(id: String?) { + guard var data = connection else { return } + data.selectedLibraryId = id + connection = data + try? keychainService.set(data, key: .jellyfinConnection) + } + func deleteConnection() { if let client { Task { @@ -96,7 +104,7 @@ class JellyfinConnectionService: BPLogger { guard let connection else { - throw JellyfinError.noClient + throw IntegrationError.noClient("Jellyfin") } let parameters = Paths.GetUserViewsParameters(userID: connection.userID) @@ -116,7 +124,8 @@ class JellyfinConnectionService: BPLogger { startIndex: Int?, limit: Int?, sortBy: JellyfinLayout.SortBy, - searchTerm: String? = nil + searchTerm: String? = nil, + recursive: Bool = false ) async throws -> (items: [JellyfinLibraryItem], nextStartIndex: Int, maxCountItems: Int) { // Require a search term when no folder is scoped, to avoid accidental expensive server-wide fetches let effectiveSearchTerm = searchTerm.flatMap { $0.isEmpty ? nil : $0 } @@ -138,16 +147,18 @@ class JellyfinConnectionService: BPLogger { sortOrder = [.ascending] } - // When no parentID is given, search recursively across the whole server + let isRecursive = recursive || searchTerm != nil || folderID == nil + let itemTypes: [JellyfinAPI.BaseItemKind] = (recursive || searchTerm != nil) ? [.audioBook] : [.audioBook, .folder] + let parameters = Paths.GetItemsParameters( startIndex: startIndex, limit: limit, - isRecursive: searchTerm != nil || folderID == nil, + isRecursive: isRecursive, searchTerm: searchTerm, sortOrder: sortOrder, parentID: folderID, fields: [.sortName], - includeItemTypes: [.audioBook, .folder], + includeItemTypes: itemTypes, sortBy: orderBy, imageTypeLimit: 1 ) @@ -172,6 +183,186 @@ class JellyfinConnectionService: BPLogger { return (items, nextStartItemIndex, maxNumItems) } + /// Fetch audiobooks filtered by album artist ID. + public func fetchItemsByArtist( + artistID: String, + parentID: String?, + startIndex: Int?, + limit: Int?, + sortBy: JellyfinLayout.SortBy + ) async throws -> (items: [JellyfinLibraryItem], nextStartIndex: Int, maxCountItems: Int) { + let orderBy: [JellyfinAPI.ItemSortBy] + let sortOrder: [JellyfinAPI.SortOrder] + switch sortBy { + case .recent: + orderBy = [.dateCreated] + sortOrder = [.descending] + case .name: + orderBy = [.name] + sortOrder = [.ascending] + case .smart: + orderBy = [.sortName] + sortOrder = [.ascending] + } + + let parameters = Paths.GetItemsParameters( + startIndex: startIndex, + limit: limit, + isRecursive: true, + sortOrder: sortOrder, + parentID: parentID, + fields: [.sortName], + includeItemTypes: [.audioBook], + sortBy: orderBy, + imageTypeLimit: 1, + albumArtistIDs: [artistID] + ) + + let response = try await send(Paths.getItems(parameters: parameters)) + try Task.checkCancellation() + + let nextStartItemIndex = + if let startIndex = response.value.startIndex, let numItems = response.value.items?.count { + startIndex + numItems + } else { + -1 + } + let maxNumItems = response.value.totalRecordCount ?? 0 + + let items = (response.value.items ?? []) + .compactMap { JellyfinLibraryItem(apiItem: $0) } + + return (items, nextStartItemIndex, maxNumItems) + } + + /// Fetch album artists in a library. + public func fetchAlbumArtists( + parentID: String?, + startIndex: Int? = nil, + limit: Int? = nil, + searchTerm: String? = nil + ) async throws -> (items: [JellyfinLibraryItem], total: Int) { + let parameters = Paths.GetAlbumArtistsParameters( + startIndex: startIndex, + limit: limit, + searchTerm: searchTerm, + parentID: parentID, + fields: [.sortName], + imageTypeLimit: 1, + sortBy: [.sortName], + sortOrder: [.ascending] + ) + + let response = try await send(Paths.getAlbumArtists(parameters: parameters)) + try Task.checkCancellation() + + let items = (response.value.items ?? []) + .compactMap { JellyfinLibraryItem(authorApiItem: $0) } + let total = response.value.totalRecordCount ?? items.count + + return (items, total) + } + + /// Fetch narrators by scanning audiobook items for People with "Narrator" role or type. + /// Jellyfin doesn't have a native "Narrator" person kind, so we extract them from item metadata. + public func fetchNarrators( + parentID: String? = nil, + searchTerm: String? = nil, + limit: Int? = nil + ) async throws -> (items: [JellyfinLibraryItem], total: Int) { + // Fetch all audiobooks with People metadata + let parameters = Paths.GetItemsParameters( + isRecursive: true, + parentID: parentID, + fields: [.people], + includeItemTypes: [.audioBook], + imageTypeLimit: 0 + ) + + let response = try await send(Paths.getItems(parameters: parameters)) + try Task.checkCancellation() + + // Extract unique narrators from People arrays + var seenNames = Set() + var narratorItems = [JellyfinLibraryItem]() + + for apiItem in response.value.items ?? [] { + for person in apiItem.people ?? [] { + guard let name = person.name, !name.isEmpty else { continue } + + let isNarrator = + person.role?.localizedCaseInsensitiveContains("narrator") == true + || person.type?.rawValue.localizedCaseInsensitiveContains("narrator") == true + + guard isNarrator, !seenNames.contains(name) else { continue } + seenNames.insert(name) + + let id = person.id ?? name + narratorItems.append( + JellyfinLibraryItem(id: id, name: name, kind: .narrator) + ) + } + } + + narratorItems.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + return (narratorItems, narratorItems.count) + } + + /// Fetch audiobooks by a specific narrator (person name or ID). + public func fetchItemsByPerson( + personID: String, + personName: String?, + parentID: String?, + startIndex: Int?, + limit: Int?, + sortBy: JellyfinLayout.SortBy + ) async throws -> (items: [JellyfinLibraryItem], nextStartIndex: Int, maxCountItems: Int) { + let orderBy: [JellyfinAPI.ItemSortBy] + let sortOrder: [JellyfinAPI.SortOrder] + switch sortBy { + case .recent: + orderBy = [.dateCreated] + sortOrder = [.descending] + case .name: + orderBy = [.name] + sortOrder = [.ascending] + case .smart: + orderBy = [.sortName] + sortOrder = [.ascending] + } + + // Use person name for matching (more reliable for narrators extracted from metadata) + let parameters = Paths.GetItemsParameters( + startIndex: startIndex, + limit: limit, + isRecursive: true, + sortOrder: sortOrder, + parentID: parentID, + fields: [.sortName], + includeItemTypes: [.audioBook], + sortBy: orderBy, + imageTypeLimit: 1, + person: personName ?? personID + ) + + let response = try await send(Paths.getItems(parameters: parameters)) + try Task.checkCancellation() + + let nextStartItemIndex = + if let startIndex = response.value.startIndex, let numItems = response.value.items?.count { + startIndex + numItems + } else { + -1 + } + let maxNumItems = response.value.totalRecordCount ?? 0 + + let items = (response.value.items ?? []) + .compactMap { JellyfinLibraryItem(apiItem: $0) } + + return (items, nextStartItemIndex, maxNumItems) + } + public func fetchItemDetails(for id: String) async throws -> JellyfinAudiobookDetailsData { let response = try await send(Paths.getItem(itemID: id)) try Task.checkCancellation() @@ -226,7 +417,7 @@ class JellyfinConnectionService: BPLogger { _ request: Request ) async throws -> Response where T: Decodable { guard let client else { - throw JellyfinError.noClient + throw IntegrationError.noClient("Jellyfin") } return try await client.send(request) @@ -275,7 +466,7 @@ class JellyfinConnectionService: BPLogger { func createItemDownloadUrl(_ item: JellyfinLibraryItem) throws -> URL { guard let client else { - throw JellyfinError.noClient + throw IntegrationError.noClient("Jellyfin") } let request = Paths.getDownload(itemID: item.id) @@ -286,7 +477,7 @@ class JellyfinConnectionService: BPLogger { components.queryItems = queryItems guard let url = components.url else { - throw JellyfinError.urlFromComponents(components) + throw IntegrationError.urlFromComponents(components) } return url @@ -304,7 +495,7 @@ class JellyfinConnectionService: BPLogger { let components = try createUrlComponentsForApiRequest(request) guard let url = components.url else { - throw JellyfinError.urlFromComponents(components) + throw IntegrationError.urlFromComponents(components) } return url @@ -314,11 +505,11 @@ class JellyfinConnectionService: BPLogger { _ request: Request ) throws -> URLComponents { guard let client else { - throw JellyfinError.noClient + throw IntegrationError.noClient("Jellyfin") } guard let requestUrl = request.url else { - throw JellyfinError.urlMalformed(nil) + throw IntegrationError.urlMalformed(nil) } let requestAbsoluteUrl = @@ -327,7 +518,7 @@ class JellyfinConnectionService: BPLogger { : requestUrl guard var components = URLComponents(url: requestAbsoluteUrl, resolvingAgainstBaseURL: false) else { - throw JellyfinError.urlMalformed(requestUrl) + throw IntegrationError.urlMalformed(requestUrl) } if let query = request.query, !query.isEmpty { diff --git a/BookPlayer/Library/ItemList/ItemListView+Sheets.swift b/BookPlayer/Library/ItemList/ItemListView+Sheets.swift index e1b6ddcbf..9dce0286a 100644 --- a/BookPlayer/Library/ItemList/ItemListView+Sheets.swift +++ b/BookPlayer/Library/ItemList/ItemListView+Sheets.swift @@ -18,10 +18,6 @@ extension ItemListView { itemDetailsSheet(for: item) case .queuedTasks: QueuedSyncTasksView() - case .jellyfin: - JellyfinRootView(connectionService: jellyfinService) - case .audiobookshelf: - AudiobookShelfRootView(connectionService: audiobookshelfService) case .foldersSelection: foldersSelectionSheet() } diff --git a/BookPlayer/Library/ItemList/ItemListView.swift b/BookPlayer/Library/ItemList/ItemListView.swift index e2cd510ad..e3f97ba51 100644 --- a/BookPlayer/Library/ItemList/ItemListView.swift +++ b/BookPlayer/Library/ItemList/ItemListView.swift @@ -70,6 +70,9 @@ struct ItemListView: View { .listStyle(.plain) .applyListStyle(with: theme, background: theme.systemBackgroundColor) .navigationTitle(model.navigationTitle) + .sheet(item: $activeSheet) { sheet in + sheetContent(for: sheet) + } } @ViewBuilder @@ -95,9 +98,6 @@ struct ItemListView: View { } } ) - .sheet(item: $activeSheet) { sheet in - sheetContent(for: sheet) - } .fileImporter( isPresented: $showDocumentPicker, allowedContentTypes: [ @@ -140,6 +140,12 @@ struct ItemListView: View { loadMoreView() } } + .macContextMenu(forSelectionType: SimpleLibraryItem.ID.self) { itemIDs in + let _ = { model.selectedSetItems = itemIDs }() + if !model.selectedItems.isEmpty { + contextMenuContent() + } + } .accessibilityElement(children: .contain) .accessibilityRotor("books_title") { customBookRotor(with: scrollView) @@ -360,7 +366,7 @@ struct ItemListView: View { ), image: .jellyfinIcon ) { - activeSheet = .jellyfin + listState.activeIntegrationSheet = .jellyfin } Button( String( @@ -370,7 +376,7 @@ struct ItemListView: View { ), image: .audiobookshelfIcon ) { - activeSheet = .audiobookshelf + listState.activeIntegrationSheet = .audiobookshelf } Button("create_playlist_button", systemImage: "folder.badge.plus") { /// Clean up just in case due to how List(selection:) works under the hood @@ -595,6 +601,21 @@ extension ItemListView { detailsOption(forMenu: true) } + // MARK: - Context Menu (macOS right-click) + + @ViewBuilder + func contextMenuContent() -> some View { + detailsOption(forMenu: false) + moveOption(forMenu: false) + shareOption(forMenu: false) + jumpToStartOption(forMenu: false) + markFinishedOption(forMenu: false) + boundBooksOption(forMenu: false) + downloadOption(forMenu: false) + Divider() + deleteOption(forMenu: false) + } + // MARK: - Individual Option Builders @ViewBuilder @@ -757,11 +778,7 @@ extension ItemListView { private func customBookRotor(with scrollView: ScrollViewProxy) -> some AccessibilityRotorContent { ForEach(model.filteredResults, id: \.id) { item in if item.type != .folder { - AccessibilityRotorEntry( - VoiceOverService.getAccessibilityLabel(for: item), - item.id, - in: customRotorNamespace - ) { + AccessibilityRotorEntry(item.title, item.id, in: customRotorNamespace) { scrollView.scrollTo(item.id) } } @@ -772,11 +789,7 @@ extension ItemListView { private func customFolderRotor(with scrollView: ScrollViewProxy) -> some AccessibilityRotorContent { ForEach(model.filteredResults, id: \.id) { item in if item.type == .folder { - AccessibilityRotorEntry( - VoiceOverService.getAccessibilityLabel(for: item), - item.id, - in: customRotorNamespace - ) { + AccessibilityRotorEntry(item.title, item.id, in: customRotorNamespace) { scrollView.scrollTo(item.id) } } diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 53e66b85b..9c01cfc6b 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -243,4 +243,4 @@ extension LibraryRootView { LibraryRootView { } showImport: { } -} +} \ No newline at end of file diff --git a/BookPlayer/Library/ItemList/Models/ItemListSheet.swift b/BookPlayer/Library/ItemList/Models/ItemListSheet.swift index 211b03b9b..17ae2b391 100644 --- a/BookPlayer/Library/ItemList/Models/ItemListSheet.swift +++ b/BookPlayer/Library/ItemList/Models/ItemListSheet.swift @@ -13,20 +13,14 @@ import Foundation enum ItemListSheet: Identifiable { case itemDetails(SimpleLibraryItem) case queuedTasks - case jellyfin - case audiobookshelf case foldersSelection - + var id: String { switch self { case .itemDetails(let item): return "itemDetails-\(item.id)" case .queuedTasks: return "queuedTasks" - case .jellyfin: - return "jellyfin" - case .audiobookshelf: - return "audiobookshelf" case .foldersSelection: return "foldersSelection" } diff --git a/BookPlayer/Library/ItemList/Models/ListStateManager.swift b/BookPlayer/Library/ItemList/Models/ListStateManager.swift index b4b3ee23c..689817640 100644 --- a/BookPlayer/Library/ItemList/Models/ListStateManager.swift +++ b/BookPlayer/Library/ItemList/Models/ListStateManager.swift @@ -22,6 +22,14 @@ final class ListStateManager { public var isSearching = false public var isEditing = false + /// Integration sheet presented at MainView level for state preservation + enum IntegrationSheet: String, Identifiable { + case jellyfin + case audiobookshelf + var id: String { rawValue } + } + var activeIntegrationSheet: IntegrationSheet? + func reloadAll(padding: Int = 0) { payloads[.all] = padding globalToken += 1 diff --git a/BookPlayer/Library/ItemList/Views/BookView.swift b/BookPlayer/Library/ItemList/Views/BookView.swift index ff6135db4..a87fb57b9 100644 --- a/BookPlayer/Library/ItemList/Views/BookView.swift +++ b/BookPlayer/Library/ItemList/Views/BookView.swift @@ -61,7 +61,7 @@ struct BookView: View { ) } .contentShape(Rectangle()) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .ignore) .dynamicAccessibilityLabel(for: item) } } diff --git a/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift b/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift index 80e56095b..60fde41fe 100644 --- a/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift +++ b/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift @@ -12,81 +12,62 @@ import SwiftUI /// A view modifier that provides dynamic accessibility labels for library items. /// Updates the label in real-time when the item is currently playing (for books) -/// or when child items are playing (for folders), ensuring VoiceOver users get -/// accurate remaining time and progress information. +/// or when metadata changes (mark finished, progress updates, etc). /// -/// For books: Subscribes to currentTime updates from PlayerManager -/// For folders: Subscribes to .folderProgressUpdated notifications +/// - Playing books: Subscribes to .bookPlaying notifications for live progress +/// - All items: Subscribes to immediateProgressUpdatePublisher for metadata changes struct DynamicAccessibilityLabelModifier: ViewModifier { let item: SimpleLibraryItem - + @EnvironmentObject private var playerManager: PlayerManager + @Environment(\.libraryService) private var libraryService @State private var accessibilityLabel: String @State private var cancellable: AnyCancellable? - + init(item: SimpleLibraryItem) { self.item = item self._accessibilityLabel = State(initialValue: VoiceOverService.getAccessibilityLabel(for: item)) } - + func body(content: Content) -> some View { content .accessibilityLabel(accessibilityLabel) .onAppear { - setupObserver() + setupPlaybackObserver() } .onDisappear { cancellable?.cancel() + cancellable = nil } - .onChange(of: playerManager.currentItem?.relativePath) { _, _ in - // Re-setup observer when the playing item changes - setupObserver() - } - } - - private func setupObserver() { - cancellable?.cancel() - - // Handle differently based on item type - if item.type == .book { - setupBookObserver() - } else { - setupFolderObserver() - } - } - - private func setupBookObserver() { - // Only observe if this book is currently playing - guard let currentItem = playerManager.currentItem, - currentItem.relativePath == item.relativePath else { - // Item is not playing, use static label - accessibilityLabel = VoiceOverService.getAccessibilityLabel(for: item) - return - } - - // Subscribe to currentTime updates via the currentProgressPublisher - // This is the same mechanism used by ItemProgressView - cancellable = playerManager.currentProgressPublisher() - .filter { [item] (relativePath, _) in - relativePath == item.relativePath - } - .throttle(for: .seconds(10), scheduler: DispatchQueue.main, latest: true) - .sink { [item] (_, _) in - // Get the current playing item to access updated currentTime - guard let playingItem = self.playerManager.currentItem, - playingItem.relativePath == item.relativePath else { - return + .onReceive( + libraryService.immediateProgressUpdatePublisher + .filter { item.relativePath == $0["relativePath"] as? String } + ) { params in + // Skip if this item is playing — the .bookPlaying subscription handles it + guard playerManager.currentItem?.relativePath != item.relativePath else { return } + + var updatedPercent = item.percentCompleted + var updatedFinished = item.isFinished + var updatedTime = item.currentTime + + if let percentCompleted = params["percentCompleted"] as? Double { + updatedPercent = percentCompleted + } + if let isFinished = params["isFinished"] as? Bool { + updatedFinished = isFinished + } + if let currentTime = params["currentTime"] as? Double { + updatedTime = currentTime } - - // Create an updated SimpleLibraryItem with the new currentTime + let updatedItem = SimpleLibraryItem( title: item.title, details: item.details, speed: item.speed, - currentTime: playingItem.currentTime, + currentTime: updatedTime, duration: item.duration, - percentCompleted: playingItem.percentCompleted, - isFinished: item.isFinished, + percentCompleted: updatedPercent, + isFinished: updatedFinished, relativePath: item.relativePath, remoteURL: item.remoteURL, artworkURL: item.artworkURL, @@ -96,37 +77,46 @@ struct DynamicAccessibilityLabelModifier: ViewModifier { lastPlayDate: item.lastPlayDate, type: item.type ) - + accessibilityLabel = VoiceOverService.getAccessibilityLabel(for: updatedItem) } + .onChange(of: playerManager.currentItem?.relativePath) { oldPath, newPath in + // Only books need the .bookPlaying subscription for live playback updates + guard item.type == .book else { return } + if newPath == item.relativePath || oldPath == item.relativePath { + setupPlaybackObserver() + } + } } - - private func setupFolderObserver() { - // For folders, subscribe to folder progress updates - // This is triggered when any book inside the folder is playing - cancellable = NotificationCenter.default.publisher(for: .folderProgressUpdated) + + /// Sets up a .bookPlaying notification subscription for the currently playing book. + /// Only activates when this item is the one being played. + private func setupPlaybackObserver() { + cancellable?.cancel() + + guard item.type == .book, + let currentItem = playerManager.currentItem, + currentItem.relativePath == item.relativePath else { + cancellable = nil + return + } + + cancellable = NotificationCenter.default.publisher(for: .bookPlaying) .throttle(for: .seconds(10), scheduler: DispatchQueue.main, latest: true) - .filter { [item] notification in - guard - let relativePath = notification.userInfo?["relativePath"] as? String, - item.relativePath == relativePath - else { - return false + .sink { [item] _ in + guard let playingItem = self.playerManager.currentItem, + playingItem.relativePath == item.relativePath else { + return } - return true - } - .sink { [item] notification in - guard let progress = notification.userInfo?["progress"] as? Double else { return } - - // Create an updated SimpleLibraryItem with the new progress + let updatedItem = SimpleLibraryItem( title: item.title, details: item.details, speed: item.speed, - currentTime: item.currentTime, + currentTime: playingItem.currentTime, duration: item.duration, - percentCompleted: progress, - isFinished: item.isFinished, + percentCompleted: playingItem.percentCompleted, + isFinished: playingItem.isFinished, relativePath: item.relativePath, remoteURL: item.remoteURL, artworkURL: item.artworkURL, @@ -136,7 +126,7 @@ struct DynamicAccessibilityLabelModifier: ViewModifier { lastPlayDate: item.lastPlayDate, type: item.type ) - + accessibilityLabel = VoiceOverService.getAccessibilityLabel(for: updatedItem) } } @@ -144,13 +134,13 @@ struct DynamicAccessibilityLabelModifier: ViewModifier { extension View { /// Applies a dynamic accessibility label that updates when the item is playing. - /// + /// /// This modifier ensures VoiceOver users receive accurate information by subscribing - /// to real-time updates from the PlayerManager (for books) or folder progress - /// notifications (for folders with playing child items). + /// to real-time updates via two mechanisms: /// - /// - Books: Updates remaining time every 10 seconds when playing - /// - Folders: Updates progress percentage when any child item is playing + /// - **Playing books**: .bookPlaying notification updates remaining time every 10 seconds + /// - **All items**: immediateProgressUpdatePublisher catches metadata changes + /// (mark finished, progress updates, folder progress) /// /// - Parameter item: The library item to generate the accessibility label for /// - Returns: A view with a dynamically updating accessibility label @@ -158,4 +148,3 @@ extension View { modifier(DynamicAccessibilityLabelModifier(item: item)) } } - diff --git a/BookPlayer/MainView.swift b/BookPlayer/MainView.swift index 0fbba9ead..5cae0f9dc 100644 --- a/BookPlayer/MainView.swift +++ b/BookPlayer/MainView.swift @@ -20,6 +20,8 @@ struct MainView: View { @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 @Environment(\.colorScheme) private var scheme @@ -83,6 +85,14 @@ struct MainView: View { MiniPlayerAccessoryView(relativePath: relativePath, showPlayer: showPlayer) } } + .sheet(item: $listState.activeIntegrationSheet) { sheet in + switch sheet { + case .jellyfin: + JellyfinRootView(connectionService: jellyfinService) + case .audiobookshelf: + AudiobookShelfRootView(connectionService: audiobookshelfService) + } + } .fullScreenCover(isPresented: playerState.isShowingPlayerBinding) { PlayerView { PlayerViewModel( diff --git a/BookPlayer/MediaServerIntegration/BPNavigation.swift b/BookPlayer/MediaServerIntegration/BPNavigation.swift new file mode 100644 index 000000000..5a4eccc8a --- /dev/null +++ b/BookPlayer/MediaServerIntegration/BPNavigation.swift @@ -0,0 +1,18 @@ +// +// BPNavigation.swift +// BookPlayer +// +// Created by Gianni Carlo on 7/6/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +@MainActor +final class BPNavigation: ObservableObject { + var dismiss: DismissAction? + + @Published var path = NavigationPath() + + nonisolated init() {} +} diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectedView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift similarity index 71% rename from BookPlayer/Jellyfin/Connection Screen/JellyfinConnectedView.swift rename to BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift index f378da9b3..4049acae0 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectedView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectedView.swift @@ -1,15 +1,15 @@ // -// JellyfinConnectedView.swift +// IntegrationConnectedView.swift // BookPlayer // -// Created by Gianni Carlo on 6/6/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. // import SwiftUI -struct JellyfinConnectedView: View { - @ObservedObject var viewModel: JellyfinConnectionViewModel +struct IntegrationConnectedView: View { + @ObservedObject var viewModel: VM @EnvironmentObject var theme: ThemeViewModel var body: some View { diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFormViewModel.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFormViewModel.swift new file mode 100644 index 000000000..127f9269d --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFormViewModel.swift @@ -0,0 +1,22 @@ +// +// IntegrationConnectionFormViewModel.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +class IntegrationConnectionFormViewModel: ObservableObject, IntegrationConnectionFormViewModelProtocol { + @Published var serverUrl: String = "" + @Published var serverName: String = "" + @Published var username: String = "" + @Published var password: String = "" + + func setValues(url: String, serverName: String, userName: String) { + self.serverUrl = url + self.serverName = serverName + self.username = userName + } +} diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift similarity index 54% rename from BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionView.swift rename to BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift index 3bc95f412..bdce2f870 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionView.swift @@ -1,55 +1,52 @@ // -// JellyfinConnectionView.swift +// IntegrationConnectionView.swift // BookPlayer // -// Created by Lysann Tranvouez on 2024-10-25. -// Copyright © 2024 BookPlayer LLC. All rights reserved. +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. // import BookPlayerKit import SwiftUI -enum JellyfinConnectionViewField: Focusable { - case none - case serverUrl, username, password -} +struct IntegrationConnectionView: View { + @ObservedObject var viewModel: VM -struct JellyfinConnectionView: View { - /// View model for the form - @ObservedObject var viewModel: JellyfinConnectionViewModel + let integrationName: String - @State private var firstAppear = true @State private var isLoading = false @State private var error: Error? @EnvironmentObject var theme: ThemeViewModel - @Environment(\.dismiss) var dismiss - var body: some View { Form { switch viewModel.connectionState { case .disconnected: - JellyfinDisconnectedView( + IntegrationDisconnectedView( serverUrl: $viewModel.form.serverUrl, + placeholderURL: integrationName == "Jellyfin" + ? "http://jellyfin.example.com:8096" + : "http://audiobookshelf.example.com", + integrationName: integrationName, onCommit: onConnect ) case .foundServer: - JellyfinServerInformationSectionView( + IntegrationServerInformationSectionView( serverName: viewModel.form.serverName, serverUrl: viewModel.form.serverUrl ) - JellyfinServerFoundView( + IntegrationServerFoundView( username: $viewModel.form.username, password: $viewModel.form.password, onCommit: onSignIn ) case .connected: - JellyfinServerInformationSectionView( + IntegrationServerInformationSectionView( serverName: viewModel.form.serverName, serverUrl: viewModel.form.serverUrl ) - JellyfinConnectedView(viewModel: viewModel) + IntegrationConnectedView(viewModel: viewModel) } } .scrollContentBackground(.hidden) @@ -77,14 +74,12 @@ struct JellyfinConnectionView: View { .foregroundStyle(theme.primaryColor) } ToolbarItemGroup(placement: .confirmationAction) { - switch (viewModel.viewMode, viewModel.connectionState) { - case (_, .disconnected): + switch viewModel.connectionState { + case .disconnected: connectToolbarButton - case (_, .foundServer): + case .foundServer: signInToolbarButton - case (.regular, .connected): - goToLibraryToolbarButton - case (.viewDetails, .connected): + case .connected: EmptyView() } } @@ -124,7 +119,7 @@ struct JellyfinConnectionView: View { private var localizedNavigationTitle: String { switch viewModel.connectionState { - case .disconnected, .foundServer: "Jellyfin" + case .disconnected, .foundServer: integrationName case .connected: "integration_connection_details_title".localized } } @@ -152,55 +147,4 @@ struct JellyfinConnectionView: View { viewModel.form.serverUrl.isEmpty || viewModel.form.username.isEmpty ) } - - @ViewBuilder - private var goToLibraryToolbarButton: some View { - Button( - "library_title", - systemImage: "chevron.forward", - action: viewModel.handleGoToLibraryAction - ) - .foregroundStyle(theme.linkColor) - } -} - -#Preview("disconnected") { - let viewModel = JellyfinConnectionViewModel( - connectionService: JellyfinConnectionService(), - navigation: BPNavigation() - ) - JellyfinConnectionView(viewModel: viewModel) - .environmentObject(ThemeViewModel()) -} - -#Preview("found server") { - let viewModel = { - let viewModel = JellyfinConnectionViewModel( - connectionService: JellyfinConnectionService(), - navigation: BPNavigation() - ) - viewModel.connectionState = .foundServer - viewModel.form.serverName = "Mock Server" - viewModel.form.serverUrl = "http://example.com" - return viewModel - }() - JellyfinConnectionView(viewModel: viewModel) - .environmentObject(ThemeViewModel()) -} - -#Preview("connected") { - let viewModel = { - let viewModel = JellyfinConnectionViewModel( - connectionService: JellyfinConnectionService(), - navigation: BPNavigation() - ) - viewModel.connectionState = .connected - viewModel.form.serverName = "Mock Server" - viewModel.form.serverUrl = "http://example.com" - viewModel.form.username = "Mock User" - viewModel.form.password = "secret" - return viewModel - }() - JellyfinConnectionView(viewModel: viewModel) - .environmentObject(ThemeViewModel()) } diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinDisconnectedView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationDisconnectedView.swift similarity index 67% rename from BookPlayer/Jellyfin/Connection Screen/JellyfinDisconnectedView.swift rename to BookPlayer/MediaServerIntegration/Connection Screen/IntegrationDisconnectedView.swift index abf1f091f..5bcbead4e 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinDisconnectedView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationDisconnectedView.swift @@ -1,21 +1,24 @@ // -// JellyfinDisconnectedView.swift +// IntegrationDisconnectedView.swift // BookPlayer // -// Created by Gianni Carlo on 6/6/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. // import SwiftUI -enum JellyfinDisconnectedViewFields: Focusable { +private enum DisconnectedViewFields: Focusable { case none, serverUrl } -struct JellyfinDisconnectedView: View { +struct IntegrationDisconnectedView: View { @Binding var serverUrl: String - @State var focusedField: JellyfinDisconnectedViewFields = .none + let placeholderURL: String + let integrationName: String + + @State private var focusedField: DisconnectedViewFields = .none @EnvironmentObject var theme: ThemeViewModel @@ -24,7 +27,7 @@ struct JellyfinDisconnectedView: View { var body: some View { ThemedSection { ClearableTextField( - "http://jellyfin.example.com:8096", + placeholderURL, text: $serverUrl, onCommit: { if !serverUrl.isEmpty { @@ -34,7 +37,7 @@ struct JellyfinDisconnectedView: View { ) .keyboardType(.URL) .textContentType(.URL) - .autocapitalization(.none) + .textInputAutocapitalization(.never) .focused($focusedField, selfKey: .serverUrl) } header: { Text("integration_section_server_url".localized) @@ -43,7 +46,7 @@ struct JellyfinDisconnectedView: View { Text( String( format: "integration_section_server_url_footer".localized, - "Jellyfin" + integrationName ) ) .foregroundStyle(theme.secondaryColor) diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinServerFoundView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerFoundView.swift similarity index 76% rename from BookPlayer/Jellyfin/Connection Screen/JellyfinServerFoundView.swift rename to BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerFoundView.swift index b1731376f..abc7da906 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinServerFoundView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerFoundView.swift @@ -1,22 +1,22 @@ // -// JellyfinServerFoundView.swift +// IntegrationServerFoundView.swift // BookPlayer // -// Created by Gianni Carlo on 6/6/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. // import SwiftUI -enum JellyfinServerFoundViewFields: Focusable { +private enum ServerFoundViewFields: Focusable { case none, username, password } -struct JellyfinServerFoundView: View { +struct IntegrationServerFoundView: View { @Binding var username: String @Binding var password: String - @State var focusedField: JellyfinServerFoundViewFields = .none + @State private var focusedField: ServerFoundViewFields = .none @EnvironmentObject var theme: ThemeViewModel @@ -32,7 +32,7 @@ struct JellyfinServerFoundView: View { } ) .textContentType(.username) - .autocapitalization(.none) + .textInputAutocapitalization(.never) .focused($focusedField, selfKey: .username) SecureField( diff --git a/BookPlayer/Jellyfin/Connection Screen/JellyfinServerInformationSectionView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerInformationSectionView.swift similarity index 76% rename from BookPlayer/Jellyfin/Connection Screen/JellyfinServerInformationSectionView.swift rename to BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerInformationSectionView.swift index 9a0e27c9f..cbf72fc9a 100644 --- a/BookPlayer/Jellyfin/Connection Screen/JellyfinServerInformationSectionView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationServerInformationSectionView.swift @@ -1,14 +1,14 @@ // -// JellyfinServerInformationSectionView.swift +// IntegrationServerInformationSectionView.swift // BookPlayer // -// Created by Gianni Carlo on 6/6/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. // import SwiftUI -struct JellyfinServerInformationSectionView: View { +struct IntegrationServerInformationSectionView: View { let serverName: String let serverUrl: String diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationSettingsView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationSettingsView.swift new file mode 100644 index 000000000..1763b5145 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationSettingsView.swift @@ -0,0 +1,20 @@ +// +// IntegrationSettingsView.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +struct IntegrationSettingsView: View { + @ObservedObject var viewModel: VM + + let integrationName: String + + var body: some View { + IntegrationConnectionView(viewModel: viewModel, integrationName: integrationName) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/BookPlayer/MediaServerIntegration/IntegrationConnectionFormViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationConnectionFormViewModelProtocol.swift new file mode 100644 index 000000000..68a7b5377 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/IntegrationConnectionFormViewModelProtocol.swift @@ -0,0 +1,16 @@ +// +// IntegrationConnectionFormViewModelProtocol.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +protocol IntegrationConnectionFormViewModelProtocol: ObservableObject { + var serverUrl: String { get set } + var serverName: String { get set } + var username: String { get set } + var password: String { get set } +} diff --git a/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift new file mode 100644 index 000000000..4f83e41db --- /dev/null +++ b/BookPlayer/MediaServerIntegration/IntegrationConnectionViewModelProtocol.swift @@ -0,0 +1,33 @@ +// +// IntegrationConnectionViewModelProtocol.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +enum IntegrationConnectionState { + case disconnected + case foundServer + case connected +} + +enum IntegrationViewMode { + case regular + case viewDetails +} + +@MainActor +protocol IntegrationConnectionViewModelProtocol: ObservableObject { + associatedtype FormVM: IntegrationConnectionFormViewModelProtocol + + var form: FormVM { get set } + var viewMode: IntegrationViewMode { get set } + var connectionState: IntegrationConnectionState { get set } + + func handleConnectAction() async throws + func handleSignInAction() async throws + func handleSignOutAction() +} diff --git a/BookPlayer/MediaServerIntegration/IntegrationDetailsViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationDetailsViewModelProtocol.swift new file mode 100644 index 000000000..88cfdf64a --- /dev/null +++ b/BookPlayer/MediaServerIntegration/IntegrationDetailsViewModelProtocol.swift @@ -0,0 +1,48 @@ +// +// IntegrationDetailsViewModelProtocol.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +protocol IntegrationDetailsDataProtocol { + var artist: String? { get } + var filePath: String? { get } + var overview: String? { get } + var runtimeString: String { get } + var fileSizeString: String { get } + var genres: [String]? { get } + var tags: [String]? { get } + + // Optional — AudiobookShelf-specific, with defaults + var narrator: String? { get } + var seriesEntries: [IntegrationSeriesEntry] { get } +} + +extension IntegrationDetailsDataProtocol { + var narrator: String? { nil } + var seriesEntries: [IntegrationSeriesEntry] { [] } +} + +struct IntegrationSeriesEntry: Identifiable, Hashable { + let id: String + let name: String + let sequence: String? +} + +@MainActor +protocol IntegrationDetailsViewModelProtocol: ObservableObject { + associatedtype Item: IntegrationLibraryItemProtocol + associatedtype Details: IntegrationDetailsDataProtocol + + var item: Item { get } + var details: Details? { get } + var error: Error? { get set } + + func fetchData() + func cancelFetchData() + func beginDownloadAudiobook(_ item: Item) throws +} diff --git a/BookPlayer/Jellyfin/Network/JellyfinError.swift b/BookPlayer/MediaServerIntegration/IntegrationError.swift similarity index 82% rename from BookPlayer/Jellyfin/Network/JellyfinError.swift rename to BookPlayer/MediaServerIntegration/IntegrationError.swift index 6b3a84c08..d6b097176 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinError.swift +++ b/BookPlayer/MediaServerIntegration/IntegrationError.swift @@ -1,17 +1,17 @@ // -// JellyfinError.swift +// IntegrationError.swift // BookPlayer // -// Created by Lysann Tranvouez on 2024-11-22. -// Copyright © 2024 BookPlayer LLC. All rights reserved. +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. // import Foundation -enum JellyfinError: Error, LocalizedError { +enum IntegrationError: Error, LocalizedError { case urlMalformed(_ url: URL?) case urlFromComponents(_ components: URLComponents) - case noClient + case noClient(_ integrationName: String) case unexpectedResponse(code: Int?) case clientError(code: Int) @@ -21,8 +21,8 @@ enum JellyfinError: Error, LocalizedError { String(format: "integration_internal_error_invalid_url".localized, String(reflecting: url)) case .urlFromComponents: "integration_internal_error_build_url".localized - case .noClient: - String(format: "integration_internal_error_no_client".localized, "Jellyfin") + case .noClient(let name): + String(format: "integration_internal_error_no_client".localized, name) case .unexpectedResponse(let code): if let code { String( diff --git a/BookPlayer/MediaServerIntegration/IntegrationLibraryItemProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationLibraryItemProtocol.swift new file mode 100644 index 000000000..11065f95a --- /dev/null +++ b/BookPlayer/MediaServerIntegration/IntegrationLibraryItemProtocol.swift @@ -0,0 +1,17 @@ +// +// IntegrationLibraryItemProtocol.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +protocol IntegrationLibraryItemProtocol: Identifiable, Hashable { + var id: String { get } + var displayName: String { get } + var isDownloadable: Bool { get } + var isNavigable: Bool { get } + var placeholderImageName: String { get } +} diff --git a/BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift new file mode 100644 index 000000000..9d0a6157b --- /dev/null +++ b/BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift @@ -0,0 +1,68 @@ +// +// IntegrationLibraryViewModelProtocol.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +enum IntegrationLayout { + enum Options: String { + case grid, list + } +} + +@MainActor +protocol IntegrationLibraryViewModelProtocol: ObservableObject { + associatedtype Item: IntegrationLibraryItemProtocol + associatedtype Destination: Hashable + + var navigation: BPNavigation { get set } + var navigationTitle: String { get } + var layout: IntegrationLayout.Options { get set } + + var items: [Item] { get set } + var totalItems: Int { get } + var error: Error? { get set } + + var editMode: EditMode { get set } + var selectedItems: Set { get set } + + var searchQuery: String { get set } + var isSearchable: Bool { get } + + // Feature flags (defaults provided) + var isGridEnabled: Bool { get } + var showsLayoutPreferences: Bool { get } + var showsSortPreferences: Bool { get } + var allowsEditing: Bool { get } + var showingDownloadConfirmation: Bool { get set } + + func fetchInitialItems() + func fetchMoreItemsIfNeeded(currentItem: Item) + func cancelFetchItems() + func destination(for item: Item) -> Destination? + + @MainActor func handleDoneAction() + @MainActor func onEditToggleSelectTapped() + @MainActor func onSelectTapped(for item: Item) + @MainActor func onSelectAllTapped() + @MainActor func onDownloadTapped() + @MainActor func onDownloadFolderTapped() + @MainActor func confirmDownloadFolder() +} + +extension IntegrationLibraryViewModelProtocol { + var isGridEnabled: Bool { true } + var showsLayoutPreferences: Bool { true } + var showsSortPreferences: Bool { true } + var allowsEditing: Bool { true } + var showingDownloadConfirmation: Bool { + get { false } + set {} + } + func onDownloadFolderTapped() {} + func confirmDownloadFolder() {} +} diff --git a/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationAudiobookDetailsView.swift b/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationAudiobookDetailsView.swift new file mode 100644 index 000000000..bfac49881 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationAudiobookDetailsView.swift @@ -0,0 +1,151 @@ +// +// IntegrationAudiobookDetailsView.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct IntegrationAudiobookDetailsView< + Model: IntegrationDetailsViewModelProtocol, + ImageContent: View +>: View { + + @State private var isFilePathExpanded: Bool = false + @State private var isGenresExpanded: Bool = false + @State private var isOverviewExpanded: Bool = true + @State private var isTagsExpanded: Bool = true + @ObservedObject var viewModel: Model + @EnvironmentObject private var theme: ThemeViewModel + + var onDownloadTap: () -> Void + @ViewBuilder let imageContent: () -> ImageContent + + var voiceOverBookInfo: String { + guard let details = viewModel.details else { + return viewModel.item.displayName + } + + return VoiceOverService.playerMetaText( + title: viewModel.item.displayName, + author: details.artist ?? "voiceover_unknown_author".localized + ) + } + + var body: some View { + ScrollView { + VStack { + imageContent() + .accessibilityHidden(true) + .padding(.horizontal, Spacing.L1) + + Text(viewModel.item.displayName) + .bpFont(.titleLarge) + .accessibilityLabel(voiceOverBookInfo) + .foregroundStyle(theme.primaryColor) + .multilineTextAlignment(.center) + + if let artist = viewModel.details?.artist { + Text(artist) + .bpFont(.title2) + .foregroundStyle(theme.secondaryColor) + .lineLimit(1) + .accessibilityHidden(true) + } + + if let narrator = viewModel.details?.narrator, !narrator.isEmpty { + Text("Narrated by \(narrator)") + .bpFont(.subheadline) + .foregroundStyle(theme.secondaryColor) + .lineLimit(1) + .accessibilityHidden(true) + } + + if let details = viewModel.details { + HStack(alignment: .center) { + Text(details.runtimeString) + .accessibilityLabel("book_duration_title".localized + details.runtimeString) + Text(" | ") + Text(details.fileSizeString) + } + .foregroundStyle(theme.primaryColor) + .bpFont(.caption) + } + + Button { + do { + try viewModel.beginDownloadAudiobook(viewModel.item) + onDownloadTap() + } catch { + viewModel.error = error + } + } label: { + HStack { + Image(systemName: "square.and.arrow.down") + Text("download_title".localized) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .foregroundStyle(theme.systemBackgroundColor) + .background(theme.linkColor) + .cornerRadius(10) + } + .padding(.horizontal) + + if let details = viewModel.details { + VStack { + if let filePath = details.filePath { + DisclosureGroup("File Path", isExpanded: $isFilePathExpanded) { + Text(filePath) + } + .accessibilityHidden(true) + } + + if let genres = details.genres, !genres.isEmpty { + DisclosureGroup("Genres", isExpanded: $isGenresExpanded) { + IntegrationTagsView(tags: genres) + } + } + + if let overview = details.overview { + DisclosureGroup("Overview", isExpanded: $isOverviewExpanded) { + Text(overview) + } + } + + if let tags = details.tags, !tags.isEmpty { + DisclosureGroup("Tags", isExpanded: $isTagsExpanded) { + IntegrationTagsView(tags: tags) + } + } + + if !details.seriesEntries.isEmpty { + DisclosureGroup("Series", isExpanded: .constant(true)) { + VStack(alignment: .leading, spacing: 8) { + ForEach(details.seriesEntries) { item in + Text(item.name) + } + } + } + } + } + .padding(.horizontal) + } + } + } + .applyListStyle(with: theme, background: theme.systemBackgroundColor) + .tint(theme.linkColor) + .errorAlert(error: $viewModel.error) + .onAppear { + viewModel.fetchData() + } + .onDisappear { + viewModel.cancelFetchData() + } + .scrollIndicators(.hidden) + } +} diff --git a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinTagsView.swift b/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationTagsView.swift similarity index 67% rename from BookPlayer/Jellyfin/Library Screen/Details/JellyfinTagsView.swift rename to BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationTagsView.swift index 1f4788445..1fb73e5ad 100644 --- a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinTagsView.swift +++ b/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationTagsView.swift @@ -1,15 +1,15 @@ // -// JellyfinTagsView.swift +// IntegrationTagsView.swift // BookPlayer // -// Created by Gianni Carlo on 31/7/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. // import BookPlayerKit import SwiftUI -struct JellyfinTagsView: View { +struct IntegrationTagsView: View { let tags: [String] @EnvironmentObject var theme: ThemeViewModel @@ -33,6 +33,6 @@ struct JellyfinTagsView: View { } #Preview { - JellyfinTagsView(tags: ["Sci-Fi", "Fantasy", "Dystopian", "Action", "Adventure", "Mystery", "Horror", "Thriller"]) + IntegrationTagsView(tags: ["Sci-Fi", "Fantasy", "Dystopian", "Action", "Adventure", "Mystery", "Horror", "Thriller"]) .environmentObject(ThemeViewModel()) } diff --git a/BookPlayer/MediaServerIntegration/Library Screen/GridLayout/IntegrationLibraryGridView.swift b/BookPlayer/MediaServerIntegration/Library Screen/GridLayout/IntegrationLibraryGridView.swift new file mode 100644 index 000000000..bc22889ce --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Library Screen/GridLayout/IntegrationLibraryGridView.swift @@ -0,0 +1,52 @@ +// +// IntegrationLibraryGridView.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +struct IntegrationLibraryGridView< + Model: IntegrationLibraryViewModelProtocol, + CellContent: View +>: View { + @ObservedObject var viewModel: Model + @ViewBuilder let cellContent: (Model.Item) -> CellContent + + @ScaledMetric var accessabilityScale: CGFloat = 1 + private let itemMinSizeBase = CGSize(width: 100, height: 100) + private let itemMaxSizeBase = CGSize(width: 250, height: 250) + private let itemSpacingBase = 20.0 + + private var columns: [GridItem] { + [GridItem( + .adaptive( + minimum: itemMinSizeBase.width, + maximum: itemMaxSizeBase.width + ), + spacing: itemSpacingBase * accessabilityScale + )] + } + + var body: some View { + LazyVGrid(columns: columns, spacing: itemSpacingBase * accessabilityScale) { + ForEach(viewModel.items, id: \.id) { item in + cellContent(item) + .accessibilityAddTraits(.isButton) + .onTapGesture { + if viewModel.editMode.isEditing { + guard item.isDownloadable else { return } + viewModel.onSelectTapped(for: item) + } else if let destination = viewModel.destination(for: item) { + viewModel.navigation.path.append(destination) + } + } + .onAppear { + viewModel.fetchMoreItemsIfNeeded(currentItem: item) + } + } + } + } +} diff --git a/BookPlayer/MediaServerIntegration/Library Screen/IntegrationLibraryView.swift b/BookPlayer/MediaServerIntegration/Library Screen/IntegrationLibraryView.swift new file mode 100644 index 000000000..929166fe6 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Library Screen/IntegrationLibraryView.swift @@ -0,0 +1,164 @@ +// +// IntegrationLibraryView.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct IntegrationLibraryView< + Model: IntegrationLibraryViewModelProtocol, + GridCell: View, + ListRow: View, + SortPicker: View +>: View { + @ObservedObject var viewModel: Model + @ViewBuilder let gridCell: (Model.Item) -> GridCell + @ViewBuilder let listRow: (Model.Item) -> ListRow + @ViewBuilder let sortPicker: () -> SortPicker + + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.tabEditing) private var tabEditing + + var navigationTitle: Text { + if viewModel.editMode.isEditing, !viewModel.selectedItems.isEmpty { + return Text( + String(format: "integration_selection_count".localized, viewModel.selectedItems.count, viewModel.totalItems) + ) + } else { + return Text(viewModel.navigationTitle) + } + } + + var body: some View { + Group { + if viewModel.isGridEnabled, viewModel.layout == .grid { + ScrollView { + IntegrationLibraryGridView(viewModel: viewModel, cellContent: gridCell) + .padding() + } + } else { + IntegrationLibraryListView(viewModel: viewModel, rowContent: listRow) + .scrollContentBackground(.hidden) + } + } + .scrollDismissesKeyboard(.interactively) + .background(theme.systemBackgroundColor) + .modifier(IntegrationSearchableModifier( + isSearchable: viewModel.isSearchable, + text: $viewModel.searchQuery + )) + .searchPresentationToolbarBehavior(.avoidHidingContent) + .onAppear { viewModel.fetchInitialItems() } + .onDisappear { viewModel.cancelFetchItems() } + .errorAlert(error: $viewModel.error) + .environment(\.editMode, $viewModel.editMode) + .onChange(of: viewModel.editMode) { _, newValue in + tabEditing.wrappedValue = newValue.isEditing + } + .confirmationDialog( + "download_folder_confirmation_title".localized, + isPresented: $viewModel.showingDownloadConfirmation + ) { + Button("download_title".localized) { + viewModel.confirmDownloadFolder() + } + Button("cancel_button".localized, role: .cancel) {} + } message: { + Text(String.localizedStringWithFormat("download_folder_confirmation_message".localized, viewModel.totalItems)) + } + .toolbar { + ToolbarItem(placement: .principal) { + navigationTitle + .bpFont(.headline) + .foregroundStyle(theme.primaryColor) + } + ToolbarItemGroup(placement: .topBarTrailing) { + toolbarTrailing + } + } + .toolbar { + if viewModel.allowsEditing, viewModel.editMode.isEditing { + ToolbarItemGroup(placement: .bottomBar) { + bottomBar + } + } + } + } + + @ViewBuilder + var toolbarTrailing: some View { + if !viewModel.editMode.isEditing, + viewModel.allowsEditing || viewModel.showsLayoutPreferences || viewModel.showsSortPreferences { + Menu { + if viewModel.allowsEditing { + ThemedSection { + Button(action: viewModel.onEditToggleSelectTapped) { + Label("select_title".localized, systemImage: "checkmark.circle") + } + Button(action: viewModel.onDownloadFolderTapped) { + Label("download_title".localized, systemImage: "arrow.down.to.line") + } + } + } + + layoutPreferences + } label: { + Label("more_title".localized, systemImage: "ellipsis.circle") + } + } else if viewModel.allowsEditing { + Button(action: viewModel.onEditToggleSelectTapped) { + Text("done_title".localized).bold() + } + } + } + + @ViewBuilder + var layoutPreferences: some View { + if viewModel.showsLayoutPreferences { + ThemedSection { + Picker(selection: $viewModel.layout, label: Text("Layout options".localized)) { + Label("Grid".localized, systemImage: "square.grid.2x2").tag(IntegrationLayout.Options.grid) + Label("List".localized, systemImage: "list.bullet").tag(IntegrationLayout.Options.list) + } + } + } + if viewModel.showsSortPreferences { + ThemedSection { + sortPicker() + } + } + } + + @ViewBuilder + var bottomBar: some View { + Button(action: viewModel.onSelectAllTapped) { + Image(systemName: viewModel.selectedItems.isEmpty ? "checklist.checked" : "checklist.unchecked") + } + + Spacer() + + Button(action: viewModel.onDownloadTapped) { + Image(systemName: "arrow.down.to.line") + } + .disabled(viewModel.selectedItems.isEmpty) + } +} + +// MARK: - Searchable Modifier + +struct IntegrationSearchableModifier: ViewModifier { + let isSearchable: Bool + @Binding var text: String + + func body(content: Content) -> some View { + if isSearchable { + content.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) + } else { + content + } + } +} diff --git a/BookPlayer/MediaServerIntegration/Library Screen/ListLayout/IntegrationLibraryListView.swift b/BookPlayer/MediaServerIntegration/Library Screen/ListLayout/IntegrationLibraryListView.swift new file mode 100644 index 000000000..e6436ab63 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/Library Screen/ListLayout/IntegrationLibraryListView.swift @@ -0,0 +1,44 @@ +// +// IntegrationLibraryListView.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +struct IntegrationLibraryListView< + Model: IntegrationLibraryViewModelProtocol, + RowContent: View +>: View { + @ObservedObject var viewModel: Model + @ViewBuilder let rowContent: (Model.Item) -> RowContent + + @EnvironmentObject var theme: ThemeViewModel + + var body: some View { + List(viewModel.items, selection: $viewModel.selectedItems) { item in + row(item: item) + .selectionDisabled(!item.isDownloadable) + .listRowBackground(theme.tertiarySystemBackgroundColor) + } + } + + func row(item: Model.Item) -> some View { + rowContent(item) + .accessibilityAddTraits(.isButton) + .contentShape(Rectangle()) + .onTapGesture { + if viewModel.editMode.isEditing { + guard item.isDownloadable else { return } + viewModel.onSelectTapped(for: item) + } else if let destination = viewModel.destination(for: item) { + viewModel.navigation.path.append(destination) + } + } + .onAppear { + viewModel.fetchMoreItemsIfNeeded(currentItem: item) + } + } +} diff --git a/BookPlayer/MediaServerIntegration/TabEditingEnvironmentKey.swift b/BookPlayer/MediaServerIntegration/TabEditingEnvironmentKey.swift new file mode 100644 index 000000000..dae6038e1 --- /dev/null +++ b/BookPlayer/MediaServerIntegration/TabEditingEnvironmentKey.swift @@ -0,0 +1,20 @@ +// +// TabEditingEnvironmentKey.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftUI + +private struct TabEditingKey: EnvironmentKey { + static let defaultValue: Binding = .constant(false) +} + +extension EnvironmentValues { + var tabEditing: Binding { + get { self[TabEditingKey.self] } + set { self[TabEditingKey.self] = newValue } + } +} diff --git a/BookPlayer/Jellyfin/Library Screen/Details/TagsFlowLayout.swift b/BookPlayer/MediaServerIntegration/TagsFlowLayout.swift similarity index 100% rename from BookPlayer/Jellyfin/Library Screen/Details/TagsFlowLayout.swift rename to BookPlayer/MediaServerIntegration/TagsFlowLayout.swift diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index 20fb2a1b8..021f6c25f 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -602,7 +602,13 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.rewindInterval) - MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.isEnabled = true + center.skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } @@ -618,10 +624,24 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.forwardInterval) - MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } + static var isRewindChapterSkip: Bool { + rewindInterval == Constants.SkipInterval.chapterSkipValue + } + + static var isForwardChapterSkip: Bool { + forwardInterval == Constants.SkipInterval.chapterSkipValue + } + func setNowPlayingBookTitle(chapter: PlayableChapter) { guard let currentItem = self.currentItem else { return } @@ -735,11 +755,41 @@ extension PlayerManager { } func forward() { - skip(PlayerManager.forwardInterval) + if PlayerManager.isForwardChapterSkip { + skipToNextChapter() + } else { + skip(PlayerManager.forwardInterval) + } } func rewind() { - skip(-PlayerManager.rewindInterval) + if PlayerManager.isRewindChapterSkip { + skipToPreviousChapter() + } else { + skip(-PlayerManager.rewindInterval) + } + } + + func skipToNextChapter() { + if let currentChapter = currentItem?.currentChapter, + let nextChapter = currentItem?.nextChapter(after: currentChapter) + { + jumpToChapter(nextChapter) + } else { + playNextItem(autoPlayed: false, shouldAutoplay: true) + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + } + + func skipToPreviousChapter() { + if let currentChapter = currentItem?.currentChapter, + let previousChapter = currentItem?.previousChapter(before: currentChapter) + { + jumpToChapter(previousChapter) + } else { + playPreviousItem() + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } func skip(_ interval: TimeInterval) { diff --git a/BookPlayer/Player/PlayerManagerProtocol.swift b/BookPlayer/Player/PlayerManagerProtocol.swift index d72b92681..e75b25ad8 100644 --- a/BookPlayer/Player/PlayerManagerProtocol.swift +++ b/BookPlayer/Player/PlayerManagerProtocol.swift @@ -33,6 +33,8 @@ public protocol PlayerManagerProtocol: AnyObject { func stop() func rewind() func forward() + func skipToNextChapter() + func skipToPreviousChapter() func skip(_ interval: TimeInterval) /// Bypass checks on chapter limits func directSkip(_ interval: TimeInterval) diff --git a/BookPlayer/Player/ViewModels/PlayerViewModel.swift b/BookPlayer/Player/ViewModels/PlayerViewModel.swift index 6b0d25889..57c98da09 100644 --- a/BookPlayer/Player/ViewModels/PlayerViewModel.swift +++ b/BookPlayer/Player/ViewModels/PlayerViewModel.swift @@ -400,25 +400,11 @@ final class PlayerViewModel: ObservableObject { } func handleNextTap() { - if let currentChapter = self.playerManager.currentItem?.currentChapter, - let nextChapter = self.playerManager.currentItem?.nextChapter(after: currentChapter) - { - self.playerManager.jumpToChapter(nextChapter) - } else { - self.playerManager.playNextItem(autoPlayed: false, shouldAutoplay: true) - } - NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + self.playerManager.skipToNextChapter() } - + func handlePreviousTap() { - if let currentChapter = self.playerManager.currentItem?.currentChapter, - let previousChapter = self.playerManager.currentItem?.previousChapter(before: currentChapter) - { - self.playerManager.jumpToChapter(previousChapter) - } else { - self.playerManager.playPreviousItem() - } - NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + self.playerManager.skipToPreviousChapter() } func hasLoadedBook() -> Bool { diff --git a/BookPlayer/Player/Views/Bookmarks/BookmarksView.swift b/BookPlayer/Player/Views/Bookmarks/BookmarksView.swift index a733b284c..0a98d504c 100644 --- a/BookPlayer/Player/Views/Bookmarks/BookmarksView.swift +++ b/BookPlayer/Player/Views/Bookmarks/BookmarksView.swift @@ -71,6 +71,20 @@ struct BookmarksView: View { } .accessibilityLabel("bookmark_note_edit_title") } + .macContextMenu { + Button { + noteText = bookmark.note ?? "" + showingNoteAlert = bookmark + } label: { + Label("bookmark_note_edit_title", systemImage: "pencil") + } + + Button(role: .destructive) { + bookmarkToDelete = bookmark + } label: { + Label("delete_button", systemImage: "trash") + } + } } } header: { Text("bookmark_type_user_title") diff --git a/BookPlayer/Player/Views/PlayControlsRowView.swift b/BookPlayer/Player/Views/PlayControlsRowView.swift index 28ae89d49..32061658b 100644 --- a/BookPlayer/Player/Views/PlayControlsRowView.swift +++ b/BookPlayer/Player/Views/PlayControlsRowView.swift @@ -16,10 +16,34 @@ struct PlayControlsRowView: View { @EnvironmentObject private var theme: ThemeViewModel @EnvironmentObject private var playerManager: PlayerManager + private var rewindImage: Image { + rewindInterval == Constants.SkipInterval.chapterSkipValue + ? Image(systemName: "backward.end.fill") + : Image(.playerIconRewind) + } + + private var rewindLabelText: String { + rewindInterval == Constants.SkipInterval.chapterSkipValue + ? "" + : "-\(String(Int(rewindInterval.rounded())))" + } + + private var forwardImage: Image { + forwardInterval == Constants.SkipInterval.chapterSkipValue + ? Image(systemName: "forward.end.fill") + : Image(.playerIconForward) + } + + private var forwardLabelText: String { + forwardInterval == Constants.SkipInterval.chapterSkipValue + ? "" + : "+\(String(Int(forwardInterval.rounded())))" + } + var body: some View { HStack(spacing: 0) { Spacer() - PlayerJumpView(backgroundImage: Image(.playerIconRewind), text: "-\(String(Int(rewindInterval.rounded())))", tintColor: Color(theme.linkColor)) { + PlayerJumpView(backgroundImage: rewindImage, text: rewindLabelText, tintColor: Color(theme.linkColor)) { UIImpactFeedbackGenerator(style: .medium).impactOccurred() playerManager.rewind() } @@ -33,7 +57,7 @@ struct PlayControlsRowView: View { .accessibilityLabel(isPlaying ? "pause_title".localized : "play_title".localized) Spacer() Spacer() - PlayerJumpView(backgroundImage: Image(.playerIconForward), text: "+\(String(Int(forwardInterval.rounded())))", tintColor: Color(theme.linkColor)) { + PlayerJumpView(backgroundImage: forwardImage, text: forwardLabelText, tintColor: Color(theme.linkColor)) { UIImpactFeedbackGenerator(style: .medium).impactOccurred() playerManager.forward() } diff --git a/BookPlayer/Services/VoiceOverService.swift b/BookPlayer/Services/VoiceOverService.swift index 5f95eba82..436cfd911 100644 --- a/BookPlayer/Services/VoiceOverService.swift +++ b/BookPlayer/Services/VoiceOverService.swift @@ -9,6 +9,7 @@ class VoiceOverService { // MARK: - BookCellView public static func getAccessibilityLabel(for item: SimpleLibraryItem) -> String { + let displayPercent = item.isFinished ? 100.0 : item.percentCompleted let remainingTime = item.duration - item.currentTime var remainingTimeLabel = "book_time_remaining_title".localized if remainingTime > 0 && remainingTime.isFinite { @@ -21,20 +22,20 @@ class VoiceOverService { "voiceover_book_progress".localized, item.title, item.details, - item.percentCompleted, + displayPercent, item.durationFormatted ) + ", \(remainingTimeLabel)" case .folder: return String.localizedStringWithFormat( "voiceover_playlist_progress".localized, item.title, - item.percentCompleted + displayPercent ) case .bound: return String.localizedStringWithFormat( "voiceover_bound_books_progress".localized, item.title, - item.percentCompleted, + displayPercent, item.durationFormatted ) + ", \(remainingTimeLabel)" } @@ -52,6 +53,9 @@ class VoiceOverService { // MARK: - ArtworkControl public static func rewindText() -> String { + if PlayerManager.isRewindChapterSkip { + return "chapters_previous_title".localized + } return String( describing: String.localizedStringWithFormat( "voiceover_rewind_time".localized, @@ -61,6 +65,9 @@ class VoiceOverService { } public static func fastForwardText() -> String { + if PlayerManager.isForwardChapterSkip { + return "chapters_next_title".localized + } return String( describing: String.localizedStringWithFormat( "voiceover_forward_time".localized, diff --git a/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift b/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift index de05a693a..c849d1e57 100644 --- a/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift +++ b/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift @@ -40,13 +40,23 @@ struct SkipIntervalsSectionView: View { .tag(interval) .foregroundStyle(theme.linkColor) } + Text("chapters_previous_title") + .bpFont(.body) + .tag(Constants.SkipInterval.chapterSkipValue) + .foregroundStyle(theme.linkColor) } label: { Text("settings_skip_rewind_title") .bpFont(.body) } .pickerStyle(.menu) .onChange(of: rewindInterval) { - MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [rewindInterval] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if rewindInterval == Constants.SkipInterval.chapterSkipValue { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.isEnabled = true + center.skipBackwardCommand.preferredIntervals = [rewindInterval] as [NSNumber] + } } Picker(selection: $forwardInterval) { @@ -56,13 +66,23 @@ struct SkipIntervalsSectionView: View { .tag(interval) .foregroundStyle(theme.linkColor) } + Text("chapters_next_title") + .bpFont(.body) + .tag(Constants.SkipInterval.chapterSkipValue) + .foregroundStyle(theme.linkColor) } label: { Text("settings_skip_forward_title") .bpFont(.body) } .pickerStyle(.menu) .onChange(of: forwardInterval) { - MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [forwardInterval] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if forwardInterval == Constants.SkipInterval.chapterSkipValue { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [forwardInterval] as [NSNumber] + } } } header: { Text("settings_skip_title".localized.capitalized) diff --git a/BookPlayer/Settings/Sections/SettingsAppearanceSectionView.swift b/BookPlayer/Settings/Sections/SettingsAppearanceSectionView.swift index 522cb9746..6ce8c7313 100644 --- a/BookPlayer/Settings/Sections/SettingsAppearanceSectionView.swift +++ b/BookPlayer/Settings/Sections/SettingsAppearanceSectionView.swift @@ -55,7 +55,7 @@ struct SettingsAppearanceSectionView: View { Slider( value: $textScaleIndex, - in: 0...6, + in: 0...20, step: 1 ) { Text("settings_text_size_title") diff --git a/BookPlayer/Settings/SettingsView.swift b/BookPlayer/Settings/SettingsView.swift index edc7dae35..07a3bc7f4 100644 --- a/BookPlayer/Settings/SettingsView.swift +++ b/BookPlayer/Settings/SettingsView.swift @@ -114,22 +114,22 @@ struct SettingsView: View { ) case .jellyfin: view = AnyView( - JellyfinSettingsView( + IntegrationSettingsView( viewModel: JellyfinConnectionViewModel( connectionService: jellyfinService, - navigation: BPNavigation(), mode: .viewDetails - ) + ), + integrationName: "Jellyfin" ) ) case .audiobookshelf: view = AnyView( - AudiobookShelfSettingsView( + IntegrationSettingsView( viewModel: AudiobookShelfConnectionViewModel( connectionService: audiobookshelfService, - navigation: BPNavigation(), mode: .viewDetails - ) + ), + integrationName: "AudiobookShelf" ) ) case .hardcover: diff --git a/BookPlayer/Utils/Extensions/View+BookPlayer.swift b/BookPlayer/Utils/Extensions/View+BookPlayer.swift index 534d6abff..b94ab01e0 100644 --- a/BookPlayer/Utils/Extensions/View+BookPlayer.swift +++ b/BookPlayer/Utils/Extensions/View+BookPlayer.swift @@ -305,6 +305,33 @@ extension View { } } +extension View { + /// Applies a context menu only when running as an iOS app on macOS ("Designed for iPad"). + /// Use this to add right-click support on Mac without affecting iOS long-press behavior. + @ViewBuilder + func macContextMenu(@ViewBuilder menuItems: () -> MenuItems) -> some View { + if ProcessInfo.processInfo.isiOSAppOnMac { + self.contextMenu { menuItems() } + } else { + self + } + } + + /// Selection-based variant for Lists. The menu closure receives the set of item IDs + /// that were right-clicked, without mutating any state during view evaluation. + @ViewBuilder + func macContextMenu( + forSelectionType type: ID.Type, + @ViewBuilder menu: @escaping (Set) -> MenuItems + ) -> some View { + if ProcessInfo.processInfo.isiOSAppOnMac { + self.contextMenu(forSelectionType: type, menu: menu) + } else { + self + } + } +} + extension View { @ViewBuilder func liquidGlassBackground() -> some View { diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index d1721af6d..878826ad8 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "الحساب موجود"; "passkey_email_exists_message" = "يوجد حساب بهذا البريد الإلكتروني بالفعل. يرجى تسجيل الدخول باستخدام مفتاح المرور الحالي أو معرف Apple بدلاً من ذلك."; "passkey_signin_existing" = "تسجيل الدخول بمفتاح المرور الحالي"; +"Switch Library" = "تبديل المكتبة"; +"Authors" = "المؤلفون"; +"Series" = "سلاسل"; +"Collections" = "مجموعات"; +"Narrators" = "الرواة"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 2181a0ea0..a1d09993c 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -436,3 +436,8 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "passkey_email_exists_title" = "El compte ja existeix"; "passkey_email_exists_message" = "Ja existeix un compte amb aquest correu electrònic. Si us plau, inicia sessió amb la teva clau d'accés o ID d'Apple existent."; "passkey_signin_existing" = "Iniciar sessió amb clau d'accés existent"; +"Switch Library" = "Canviar de biblioteca"; +"Authors" = "Autors"; +"Series" = "Sèries"; +"Collections" = "Col·leccions"; +"Narrators" = "Narradors"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index af363b24d..a2c4aaa72 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Účet existuje"; "passkey_email_exists_message" = "Účet s tímto e-mailem již existuje. Přihlaste se prosím pomocí stávajícího přístupového klíče nebo Apple ID."; "passkey_signin_existing" = "Přihlásit se stávajícím přístupovým klíčem"; +"Switch Library" = "Přepnout knihovnu"; +"Authors" = "Autoři"; +"Series" = "Série"; +"Collections" = "Kolekce"; +"Narrators" = "Vypravěči"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index b13d25643..79dfea027 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Konto findes"; "passkey_email_exists_message" = "En konto med denne e-mail findes allerede. Log venligst ind med din eksisterende adgangsnøgle eller Apple-id i stedet."; "passkey_signin_existing" = "Log ind med eksisterende adgangsnøgle"; +"Switch Library" = "Skift bibliotek"; +"Authors" = "Forfattere"; +"Series" = "Serier"; +"Collections" = "Samlinger"; +"Narrators" = "Fortællere"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index a922e2fa1..716e7bafe 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Konto existiert bereits"; "passkey_email_exists_message" = "Ein Konto mit dieser E-Mail-Adresse existiert bereits. Bitte melde dich stattdessen mit deinem bestehenden Passkey oder deiner Apple-ID an."; "passkey_signin_existing" = "Mit bestehendem Passkey anmelden"; +"Switch Library" = "Bibliothek wechseln"; +"Authors" = "Autoren"; +"Series" = "Serien"; +"Collections" = "Sammlungen"; +"Narrators" = "Erzähler"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 97641e28a..9abce12bc 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Ο λογαριασμός υπάρχει"; "passkey_email_exists_message" = "Υπάρχει ήδη λογαριασμός με αυτό το email. Παρακαλώ συνδεθείτε με το υπάρχον κλειδί πρόσβασης ή Apple ID σας."; "passkey_signin_existing" = "Σύνδεση με υπάρχον κλειδί πρόσβασης"; +"Switch Library" = "Αλλαγή βιβλιοθήκης"; +"Authors" = "Συγγραφείς"; +"Series" = "Σειρές"; +"Collections" = "Συλλογές"; +"Narrators" = "Αφηγητές"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index 77d88d9db..f00d4a5d1 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -436,3 +436,8 @@ We're working hard on providing a seamless experience, if possible, please conta "passkey_email_exists_title" = "Account Exists"; "passkey_email_exists_message" = "An account with this email already exists. Please sign in with your existing passkey or Apple ID instead."; "passkey_signin_existing" = "Sign in with existing passkey"; +"Switch Library" = "Switch Library"; +"Authors" = "Authors"; +"Series" = "Series"; +"Collections" = "Collections"; +"Narrators" = "Narrators"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index f360917b0..62ada2312 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "La cuenta ya existe"; "passkey_email_exists_message" = "Ya existe una cuenta con este correo electrónico. Por favor, inicia sesión con tu llave de acceso o Apple ID existente."; "passkey_signin_existing" = "Iniciar sesión con llave existente"; +"Switch Library" = "Cambiar biblioteca"; +"Authors" = "Autores"; +"Series" = "Series"; +"Collections" = "Colecciones"; +"Narrators" = "Narradores"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index 442ea4ad9..02d4b902d 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Tili on olemassa"; "passkey_email_exists_message" = "Tili tällä sähköpostilla on jo olemassa. Kirjaudu sisään olemassa olevalla pääsyavaimellasi tai Apple ID:lläsi."; "passkey_signin_existing" = "Kirjaudu olemassa olevalla pääsyavaimella"; +"Switch Library" = "Vaihda kirjastoa"; +"Authors" = "Tekijät"; +"Series" = "Sarjat"; +"Collections" = "Kokoelmat"; +"Narrators" = "Lukijat"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 53e17d8c8..4b4244379 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Le compte existe déjà"; "passkey_email_exists_message" = "Un compte avec cet e-mail existe déjà. Veuillez vous connecter avec votre clé d'accès ou identifiant Apple existant."; "passkey_signin_existing" = "Se connecter avec une clé existante"; +"Switch Library" = "Changer de bibliothèque"; +"Authors" = "Auteurs"; +"Series" = "Séries"; +"Collections" = "Collections"; +"Narrators" = "Narrateurs"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index a9a065c92..0190ae622 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "A fiók létezik"; "passkey_email_exists_message" = "Ezzel az e-mail címmel már létezik fiók. Kérjük, jelentkezz be a meglévő jelszókulcsoddal vagy Apple ID-ddal."; "passkey_signin_existing" = "Bejelentkezés meglévő jelszókulccsal"; +"Switch Library" = "Könyvtár váltása"; +"Authors" = "Szerzők"; +"Series" = "Sorozatok"; +"Collections" = "Gyűjtemények"; +"Narrators" = "Felolvasók"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index 70de3af9b..05709211a 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Account esistente"; "passkey_email_exists_message" = "Esiste già un account con questa e-mail. Accedi con la tua passkey o ID Apple esistente."; "passkey_signin_existing" = "Accedi con passkey esistente"; +"Switch Library" = "Cambia libreria"; +"Authors" = "Autori"; +"Series" = "Serie"; +"Collections" = "Collezioni"; +"Narrators" = "Narratori"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index d56e94fb0..d38cd9303 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "アカウントが存在します"; "passkey_email_exists_message" = "このメールアドレスのアカウントは既に存在します。既存のパスキーまたはApple IDでサインインしてください。"; "passkey_signin_existing" = "既存のパスキーでサインイン"; +"Switch Library" = "ライブラリを切り替え"; +"Authors" = "著者"; +"Series" = "シリーズ"; +"Collections" = "コレクション"; +"Narrators" = "ナレーター"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index c91311b71..bd342bf3c 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -436,3 +436,8 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "passkey_email_exists_title" = "Konto finnes"; "passkey_email_exists_message" = "En konto med denne e-posten finnes allerede. Vennligst logg inn med din eksisterende passordnøkkel eller Apple-ID i stedet."; "passkey_signin_existing" = "Logg inn med eksisterende passordnøkkel"; +"Switch Library" = "Bytt bibliotek"; +"Authors" = "Forfattere"; +"Series" = "Serier"; +"Collections" = "Samlinger"; +"Narrators" = "Fortellere"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index e4aefe7e2..e3c845e19 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Account bestaat al"; "passkey_email_exists_message" = "Er bestaat al een account met dit e-mailadres. Log in met je bestaande passkey of Apple ID."; "passkey_signin_existing" = "Inloggen met bestaande passkey"; +"Switch Library" = "Wissel van bibliotheek"; +"Authors" = "Auteurs"; +"Series" = "Series"; +"Collections" = "Collecties"; +"Narrators" = "Vertellers"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 9a3645c8d..766d64fad 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Konto istnieje"; "passkey_email_exists_message" = "Konto z tym adresem e-mail już istnieje. Zaloguj się za pomocą istniejącego klucza dostępu lub Apple ID."; "passkey_signin_existing" = "Zaloguj się istniejącym kluczem dostępu"; +"Switch Library" = "Zmień bibliotekę"; +"Authors" = "Autorzy"; +"Series" = "Serie"; +"Collections" = "Kolekcje"; +"Narrators" = "Lektorzy"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index 56cc09ca3..3aba37f1d 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Conta já existe"; "passkey_email_exists_message" = "Já existe uma conta com este e-mail. Entre com sua chave-senha ou ID Apple existente."; "passkey_signin_existing" = "Entrar com chave-senha existente"; +"Switch Library" = "Trocar biblioteca"; +"Authors" = "Autores"; +"Series" = "Séries"; +"Collections" = "Coleções"; +"Narrators" = "Narradores"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index eae001b80..1ed04bdbd 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "A conta já existe"; "passkey_email_exists_message" = "Já existe uma conta com este e-mail. Inicie sessão com a sua chave de acesso ou ID Apple existente."; "passkey_signin_existing" = "Iniciar sessão com chave existente"; +"Switch Library" = "Mudar biblioteca"; +"Authors" = "Autores"; +"Series" = "Séries"; +"Collections" = "Coleções"; +"Narrators" = "Narradores"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index 5c80516c1..3486cb9e9 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Contul există"; "passkey_email_exists_message" = "Un cont cu acest e-mail există deja. Te rugăm să te autentifici cu cheia de acces sau ID-ul Apple existent."; "passkey_signin_existing" = "Autentificare cu cheia de acces existentă"; +"Switch Library" = "Schimbă biblioteca"; +"Authors" = "Autori"; +"Series" = "Serii"; +"Collections" = "Colecții"; +"Narrators" = "Naratori"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index 977a9acd1..7c6e7c55d 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Учётная запись существует"; "passkey_email_exists_message" = "Учётная запись с таким адресом эл. почты уже существует. Войдите с помощью существующего ключа доступа или Apple ID."; "passkey_signin_existing" = "Войти с существующим ключом доступа"; +"Switch Library" = "Сменить библиотеку"; +"Authors" = "Авторы"; +"Series" = "Серии"; +"Collections" = "Коллекции"; +"Narrators" = "Чтецы"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 0475c2344..20e8bb112 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -436,3 +436,8 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "passkey_email_exists_title" = "Účet existuje"; "passkey_email_exists_message" = "Účet s týmto e-mailom už existuje. Prihláste sa pomocou existujúceho prístupového kľúča alebo Apple ID."; "passkey_signin_existing" = "Prihlásiť sa existujúcim prístupovým kľúčom"; +"Switch Library" = "Prepnúť knižnicu"; +"Authors" = "Autori"; +"Series" = "Série"; +"Collections" = "Kolekcie"; +"Narrators" = "Rozprávači"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index 09aab66fb..ad2af9bea 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Kontot finns"; "passkey_email_exists_message" = "Ett konto med denna e-postadress finns redan. Logga in med din befintliga lösenordsnyckel eller Apple-ID istället."; "passkey_signin_existing" = "Logga in med befintlig lösenordsnyckel"; +"Switch Library" = "Byt bibliotek"; +"Authors" = "Författare"; +"Series" = "Serier"; +"Collections" = "Samlingar"; +"Narrators" = "Berättare"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 75c2e99f8..848be7414 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Hesap mevcut"; "passkey_email_exists_message" = "Bu e-posta adresine sahip bir hesap zaten var. Lütfen mevcut geçiş anahtarınız veya Apple Kimliğiniz ile giriş yapın."; "passkey_signin_existing" = "Mevcut geçiş anahtarıyla giriş yap"; +"Switch Library" = "Kütüphane değiştir"; +"Authors" = "Yazarlar"; +"Series" = "Seriler"; +"Collections" = "Koleksiyonlar"; +"Narrators" = "Anlatıcılar"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index c7b576da5..debda2a68 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "Обліковий запис існує"; "passkey_email_exists_message" = "Обліковий запис з цією адресою ел. пошти вже існує. Увійдіть за допомогою наявного ключа доступу або Apple ID."; "passkey_signin_existing" = "Увійти з наявним ключем доступу"; +"Switch Library" = "Змінити бібліотеку"; +"Authors" = "Автори"; +"Series" = "Серії"; +"Collections" = "Колекції"; +"Narrators" = "Оповідачі"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index 2007bacba..fa11937b0 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -436,3 +436,8 @@ "passkey_email_exists_title" = "账户已存在"; "passkey_email_exists_message" = "使用此电子邮件的账户已存在。请使用现有的通行密钥或 Apple ID 登录。"; "passkey_signin_existing" = "使用现有通行密钥登录"; +"Switch Library" = "切换资料库"; +"Authors" = "作者"; +"Series" = "系列"; +"Collections" = "合集"; +"Narrators" = "朗读者"; diff --git a/BookPlayerWatch/ExtensionDelegate.swift b/BookPlayerWatch/ExtensionDelegate.swift index 7ffc48013..b5783de30 100644 --- a/BookPlayerWatch/ExtensionDelegate.swift +++ b/BookPlayerWatch/ExtensionDelegate.swift @@ -214,7 +214,11 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { func setupMPSkipRemoteCommands() { let center = MPRemoteCommandCenter.shared() // Forward - center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + if PlayerManager.isForwardChapterSkip { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + } center.skipForwardCommand.addTarget { [weak self] (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = self?.coreServices?.playerManager else { return .commandFailed } @@ -244,7 +248,11 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { } // Rewind - center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + if PlayerManager.isRewindChapterSkip { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + } center.skipBackwardCommand.addTarget { [weak self] (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = self?.coreServices?.playerManager else { return .commandFailed } diff --git a/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift b/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift index 6595a3f64..c6c146571 100644 --- a/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift +++ b/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift @@ -126,7 +126,11 @@ struct PlaybackFullControlsView: View { HStack { Text("settings_skip_rewind_title") Spacer() - Text(TimeParser.formatDuration(rewindInterval)) + Text( + rewindInterval == Constants.SkipInterval.chapterSkipValue + ? "chapters_previous_title".localized + : TimeParser.formatDuration(rewindInterval) + ) Image(systemName: "chevron.forward") } } @@ -137,7 +141,11 @@ struct PlaybackFullControlsView: View { HStack { Text("settings_skip_forward_title") Spacer() - Text(TimeParser.formatDuration(forwardInterval)) + Text( + forwardInterval == Constants.SkipInterval.chapterSkipValue + ? "chapters_next_title".localized + : TimeParser.formatDuration(forwardInterval) + ) Image(systemName: "chevron.forward") } } diff --git a/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift b/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift index 901e4be5f..fc989e294 100644 --- a/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift +++ b/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift @@ -47,7 +47,7 @@ struct SkipDurationListView: View { ForEach(intervals, id: \.self) { interval in Button { switch skipDirection { - case .forward: + case .forward: selectedForwardInterval = interval case .back: selectedRewindInterval = interval @@ -65,6 +65,30 @@ struct SkipDurationListView: View { } } } + + Button { + switch skipDirection { + case .forward: + selectedForwardInterval = Constants.SkipInterval.chapterSkipValue + case .back: + selectedRewindInterval = Constants.SkipInterval.chapterSkipValue + } + dismiss() + } label: { + HStack { + Text( + skipDirection == .forward + ? "chapters_next_title".localized + : "chapters_previous_title".localized + ) + .font(.caption) + Spacer() + if selectedInterval == Constants.SkipInterval.chapterSkipValue { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } } .onAppear { proxy.scrollTo(selectedInterval, anchor: .center) diff --git a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift index b2220eba0..59fad3a94 100644 --- a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift +++ b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift @@ -579,7 +579,13 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.rewindInterval) - MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.isEnabled = true + center.skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } @@ -595,10 +601,24 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.forwardInterval) - MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } + static var isRewindChapterSkip: Bool { + rewindInterval == Constants.SkipInterval.chapterSkipValue + } + + static var isForwardChapterSkip: Bool { + forwardInterval == Constants.SkipInterval.chapterSkipValue + } + func setNowPlayingBookTitle(chapter: PlayableChapter) { guard let currentItem = self.currentItem else { return } @@ -713,11 +733,41 @@ extension PlayerManager { } func forward() { - skip(PlayerManager.forwardInterval) + if PlayerManager.isForwardChapterSkip { + skipToNextChapter() + } else { + skip(PlayerManager.forwardInterval) + } } func rewind() { - skip(-PlayerManager.rewindInterval) + if PlayerManager.isRewindChapterSkip { + skipToPreviousChapter() + } else { + skip(-PlayerManager.rewindInterval) + } + } + + func skipToNextChapter() { + if let currentChapter = currentItem?.currentChapter, + let nextChapter = currentItem?.nextChapter(after: currentChapter) + { + jumpToChapter(nextChapter) + } else { + playNextItem(autoPlayed: false, shouldAutoplay: true) + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + } + + func skipToPreviousChapter() { + if let currentChapter = currentItem?.currentChapter, + let previousChapter = currentItem?.previousChapter(before: currentChapter) + { + jumpToChapter(previousChapter) + } else { + playPreviousItem() + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } func skip(_ interval: TimeInterval) { diff --git a/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift b/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift index 731e35a66..ef55a5e1e 100644 --- a/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift +++ b/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift @@ -6,23 +6,37 @@ // Copyright © 2022 BookPlayer LLC. All rights reserved. // +import BookPlayerWatchKit import SwiftUI struct SkipIntervalView: View { let interval: Int? let skipDirection: SkipDirection + private var isChapterSkip: Bool { + guard let interval else { return false } + return interval == Int(Constants.SkipInterval.chapterSkipValue) + } + var body: some View { ZStack { - if let interval = interval { - Text("**\(interval)**") - .minimumScaleFactor(0.1) - .lineLimit(1) - .padding(5) - .offset(y: 1) - } + if isChapterSkip { + ResizeableImageView( + name: skipDirection == .forward + ? "forward.end.fill" + : "backward.end.fill" + ) + } else { + if let interval = interval { + Text("**\(interval)**") + .minimumScaleFactor(0.1) + .lineLimit(1) + .padding(5) + .offset(y: 1) + } - ResizeableImageView(name: skipDirection.systemImage) + ResizeableImageView(name: skipDirection.systemImage) + } } } } diff --git a/Shared/Constants.swift b/Shared/Constants.swift index a9daf46b6..60a92a4de 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -90,6 +90,11 @@ public enum Constants { public static let macOSTextScale = "userSettingsMacOSTextScale" } + public enum SkipInterval { + /// Sentinel value indicating "skip to next/previous chapter" mode + public static let chapterSkipValue: TimeInterval = -1.0 + } + public enum SmartRewind { public static let threshold: TimeInterval = 60 * 60 // 60 minutes public static let minRewind: TimeInterval = 2