diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index b8d56e7e..46280b8b 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 145E7E3E2E36DE6D00822CB0 /* ReadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */; }; + 145E7E3F2E36DE6D00822CB0 /* GestureCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */; }; + 145E7E412E36DE6D00822CB0 /* PageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */; }; + 145E7E422E36DE6D00822CB0 /* ReadingViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */; }; + 145E7E432E36DE6D00822CB0 /* ImageStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */; }; AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */; }; AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingReducer.swift */; }; AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginReducer.swift */; }; @@ -214,14 +219,10 @@ ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C7762593699A00E0C11B /* ViewModifiers.swift */; }; ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ABC4A0782751B40E00968A4F /* Kingfisher */; }; ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = ABC681F126898D46007BBD69 /* Model.xcdatamodeld */; }; - ABC732C127B8962000D47DA9 /* LiveTextHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C027B8962000D47DA9 /* LiveTextHandler.swift */; }; ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C427B9024500D47DA9 /* LiveText.swift */; }; ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C627B90F0900D47DA9 /* LiveTextView.swift */; }; ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355C27B118330091DCDB /* DetailSearchView.swift */; }; ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */; }; - ABC8356127B357C50091DCDB /* GestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356027B357C50091DCDB /* GestureHandler.swift */; }; - ABC8356327B366760091DCDB /* PageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356227B366760091DCDB /* PageHandler.swift */; }; - ABC8356527B36E550091DCDB /* AutoPlayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */; }; ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BD26918DE100A98BC6 /* Persistence.swift */; }; ABCA93C02691925900A98BC6 /* GalleryMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */; }; ABCA93C22691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */; }; @@ -316,6 +317,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureCoordinator.swift; sourceTree = ""; }; + 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStackView.swift; sourceTree = ""; }; + 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageCoordinator.swift; sourceTree = ""; }; + 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingViewExtensions.swift; sourceTree = ""; }; + 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingViewModel.swift; sourceTree = ""; }; AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingReducer.swift; sourceTree = ""; }; AB0929BD2780032400F107CA /* EhSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingReducer.swift; sourceTree = ""; }; AB0929BF27805A8200F107CA /* LoginReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReducer.swift; sourceTree = ""; }; @@ -526,14 +532,10 @@ ABC3C7762593699A00E0C11B /* ViewModifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewModifiers.swift; sourceTree = ""; }; ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 5.xcdatamodel"; sourceTree = ""; }; ABC681F226898D46007BBD69 /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = ""; }; - ABC732C027B8962000D47DA9 /* LiveTextHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTextHandler.swift; sourceTree = ""; }; ABC732C427B9024500D47DA9 /* LiveText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveText.swift; sourceTree = ""; }; ABC732C627B90F0900D47DA9 /* LiveTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextView.swift; sourceTree = ""; }; ABC8355C27B118330091DCDB /* DetailSearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchView.swift; sourceTree = ""; }; ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchReducer.swift; sourceTree = ""; }; - ABC8356027B357C50091DCDB /* GestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureHandler.swift; sourceTree = ""; }; - ABC8356227B366760091DCDB /* PageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHandler.swift; sourceTree = ""; }; - ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayHandler.swift; sourceTree = ""; }; ABCA93BD26918DE100A98BC6 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryMO+CoreDataClass.swift"; sourceTree = ""; }; ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryDetailMO+CoreDataClass.swift"; sourceTree = ""; }; @@ -708,13 +710,14 @@ AB24C561276757A30085C33A /* Support */ = { isa = PBXGroup; children = ( + 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */, + 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */, + 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */, + 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */, + 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */, ABC732C627B90F0900D47DA9 /* LiveTextView.swift */, AB69CB8126B3DAF400699359 /* ControlPanel.swift */, AB69CB7F26B3DABC00699359 /* AdvancedList.swift */, - ABC8356227B366760091DCDB /* PageHandler.swift */, - ABC8356027B357C50091DCDB /* GestureHandler.swift */, - ABC732C027B8962000D47DA9 /* LiveTextHandler.swift */, - ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */, ); path = Support; sourceTree = ""; @@ -1851,7 +1854,6 @@ AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */, AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */, AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */, - ABC8356127B357C50091DCDB /* GestureHandler.swift in Sources */, AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */, AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */, ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */, @@ -1896,7 +1898,6 @@ AB7BF2AB27A642FB001865A3 /* BrowsingCountry.swift in Sources */, ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */, AB26F59027ABF21000AB3468 /* Model5toModel6.xcmappingmodel in Sources */, - ABC8356327B366760091DCDB /* PageHandler.swift in Sources */, AB706FA5278C3DDE0025A48A /* PreviewsView.swift in Sources */, ABF45AE725F3313D00ECB568 /* RatingView.swift in Sources */, AB2CED64268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift in Sources */, @@ -1906,6 +1907,11 @@ AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */, AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */, ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */, + 145E7E3E2E36DE6D00822CB0 /* ReadingViewModel.swift in Sources */, + 145E7E3F2E36DE6D00822CB0 /* GestureCoordinator.swift in Sources */, + 145E7E412E36DE6D00822CB0 /* PageCoordinator.swift in Sources */, + 145E7E422E36DE6D00822CB0 /* ReadingViewExtensions.swift in Sources */, + 145E7E432E36DE6D00822CB0 /* ImageStackView.swift in Sources */, ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */, ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */, ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */, @@ -1936,7 +1942,6 @@ AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */, ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */, ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */, - ABC8356527B36E550091DCDB /* AutoPlayHandler.swift in Sources */, ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */, AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */, ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */, @@ -1953,7 +1958,6 @@ EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */, AB0CFBD727C3B2D0004BD372 /* TagDetailView.swift in Sources */, AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */, - ABC732C127B8962000D47DA9 /* LiveTextHandler.swift in Sources */, ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */, ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */, AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */, @@ -2084,10 +2088,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2098,9 +2102,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2112,10 +2116,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2126,9 +2130,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2263,11 +2267,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2276,9 +2280,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2292,11 +2296,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2305,9 +2309,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 66fbb20b..65143cfc 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", - "version" : "2.2.2" + "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", + "version" : "2.3.2" } }, { diff --git a/EhPanda/App/Info.plist b/EhPanda/App/Info.plist index 925f724b..97c0ccff 100644 --- a/EhPanda/App/Info.plist +++ b/EhPanda/App/Info.plist @@ -26,25 +26,25 @@ AppIcon_Developer - AppIcon_StandWithUkraine2022 + AppIcon_NotMyPresident CFBundleIconFiles - AppIcon_StandWithUkraine2022 + AppIcon_NotMyPresident - AppIcon_Ukiyoe + AppIcon_StandWithUkraine2022 CFBundleIconFiles - AppIcon_Ukiyoe + AppIcon_StandWithUkraine2022 - AppIcon_NotMyPresident + AppIcon_Ukiyoe CFBundleIconFiles - AppIcon_NotMyPresident + AppIcon_Ukiyoe @@ -76,6 +76,14 @@ AppIcon_Developer_iPad_Pro + AppIcon_NotMyPresident + + CFBundleIconFiles + + AppIcon_NotMyPresident_iPad + AppIcon_NotMyPresident_iPad_Pro + + AppIcon_StandWithUkraine2022 CFBundleIconFiles @@ -92,14 +100,6 @@ AppIcon_Ukiyoe_iPad_Pro - AppIcon_NotMyPresident - - CFBundleIconFiles - - AppIcon_NotMyPresident_iPad - AppIcon_NotMyPresident_iPad_Pro - - CFBundlePrimaryIcon diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index b747f3d9..6371be89 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -3,40 +3,46 @@ // EhPanda // // Created by 荒木辰造 on R 4/01/22. +// Refactored for improved maintainability and modularity by zackie on 2025-07-28. // import SwiftUI import TTProgressHUD import ComposableArchitecture +// MARK: - Reading Reducer @Reducer struct ReadingReducer { + + // MARK: - Route @CasePathable enum Route: Equatable { case hud case share(IdentifiableBox) case readingSetting(EquatableVoid = .init()) } - + + // MARK: - Share Item enum ShareItem: Equatable { + case data(Data) + case image(UIImage) + var associatedValue: Any { switch self { - case .data(let data): - return data - case .image(let image): - return image + case .data(let data): return data + case .image(let image): return image } } - case data(Data) - case image(UIImage) } - + + // MARK: - Image Action enum ImageAction { case copy(Bool) case save(Bool) case share(Bool) } - + + // MARK: - Cancel IDs private enum CancelID: CaseIterable { case fetchImage case fetchDatabaseInfos @@ -47,137 +53,108 @@ struct ReadingReducer { case fetchMPVKeys case fetchMPVImageURL } - + + // MARK: - State @ObservableState struct State: Equatable { + // MARK: - Navigation & UI var route: Route? + var showsPanel = false + var showsSliderPreview = false + var hudConfig: TTProgressHUDConfig = .loading + var forceRefreshID: UUID = .init() + + // MARK: - Gallery Data var gallery: Gallery = .empty var galleryDetail: GalleryDetail? - var readingProgress: Int = .zero - var forceRefreshID: UUID = .init() - var hudConfig: TTProgressHUDConfig = .loading - + + // MARK: - Loading States var webImageLoadSuccessIndices = Set() var imageURLLoadingStates = [Int: LoadingState]() var previewLoadingStates = [Int: LoadingState]() var databaseLoadingState: LoadingState = .loading + + // MARK: - Preview Configuration var previewConfig: PreviewConfig = .normal(rows: 4) - + + // MARK: - URL Storage var previewURLs = [Int: URL]() - var thumbnailURLs = [Int: URL]() var imageURLs = [Int: URL]() var originalImageURLs = [Int: URL]() - + + // MARK: - MPV Support var mpvKey: String? var mpvImageKeys = [Int: String]() var mpvSkipServerIdentifiers = [Int: String]() - - var showsPanel = false - var showsSliderPreview = false - - // Update - func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { - guard !new.isEmpty else { return } - stored = stored.merging(new, uniquingKeysWith: { stored, new in replaceExisting ? new : stored }) - } - mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { - update(stored: &self.previewURLs, new: previewURLs) - } - mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { - update(stored: &self.thumbnailURLs, new: thumbnailURLs) - } - mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { - update(stored: &self.imageURLs, new: imageURLs) - update(stored: &self.originalImageURLs, new: originalImageURLs) - } - - // Image - func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { - let defaultData = Array(1...gallery.pageCount) - guard isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return defaultData } - - let data = setting.exceptCover - ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) - : Array(stride(from: 1, through: gallery.pageCount, by: 2)) - - return data - } - func imageContainerConfigs( - index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape - ) -> ImageStackConfig { - let direction = setting.readingDirection - let isReversed = direction == .rightToLeft - let isFirstSingle = setting.exceptCover - let isFirstPageAndSingle = index == 1 && isFirstSingle - let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical - let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index - let secondIndex = firstIndex + (isReversed ? -1 : 1) - let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount - let isValidSecondRange = isFirstSingle - ? secondIndex >= 2 && secondIndex <= gallery.pageCount - : secondIndex >= 1 && secondIndex <= gallery.pageCount - return .init( - firstIndex: firstIndex, secondIndex: secondIndex, isFirstAvailable: isValidFirstRange, - isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage - ) - } } - + + // MARK: - Action enum Action: BindableAction { + // MARK: - Binding & Navigation case binding(BindingAction) case setNavigation(Route?) - case toggleShowsPanel - case setOrientationPortrait(Bool) case onPerformDismiss case onAppear(String, Bool) - + case teardown + + // MARK: - Orientation + case setOrientationPortrait(Bool) + + // MARK: - Web Image Actions case onWebImageRetry(Int) case onWebImageSucceeded(Int) case onWebImageFailed(Int) case reloadAllWebImages case retryAllFailedWebImages - + + // MARK: - Image Actions case copyImage(URL) case saveImage(URL) case saveImageDone(Bool) case shareImage(URL) case fetchImage(ImageAction, URL) case fetchImageDone(ImageAction, Result) - + + // MARK: - Data Synchronization case syncReadingProgress(Int) case syncPreviewURLs([Int: URL]) case syncThumbnailURLs([Int: URL]) case syncImageURLs([Int: URL], [Int: URL]) - - case teardown + + // MARK: - Database Operations case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) - + + // MARK: - Preview Operations case fetchPreviewURLs(Int) case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) - + + // MARK: - Image URL Operations case fetchImageURLs(Int) case refetchImageURLs(Int) case prefetchImages(Int, Int) - + + // MARK: - Thumbnail Operations case fetchThumbnailURLs(Int) case fetchThumbnailURLsDone(Int, Result<[Int: URL], AppError>) + + // MARK: - Normal Image Operations case fetchNormalImageURLs(Int, [Int: URL]) case fetchNormalImageURLsDone(Int, Result<([Int: URL], [Int: URL]), AppError>) case refetchNormalImageURLs(Int) case refetchNormalImageURLsDone(Int, Result<([Int: URL], HTTPURLResponse?), AppError>) - + + // MARK: - MPV Operations case fetchMPVKeys(Int, URL) case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) case fetchMPVImageURL(Int, Bool) case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) } - + + // MARK: - Dependencies @Dependency(\.appDelegateClient) private var appDelegateClient @Dependency(\.clipboardClient) private var clipboardClient @Dependency(\.databaseClient) private var databaseClient @@ -186,462 +163,871 @@ struct ReadingReducer { @Dependency(\.deviceClient) private var deviceClient @Dependency(\.imageClient) private var imageClient @Dependency(\.urlClient) private var urlClient - + + // MARK: - Body var body: some Reducer { BindingReducer() .onChange(of: \.showsSliderPreview) { _, _ in - Reduce({ _, _ in .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) }) + Reduce({ _, _ in + .run(operation: { _ in + hapticsClient.generateFeedback(.soft) + }) + }) } - + Reduce { state, action in switch action { + // MARK: - Basic Actions case .binding: return .none - + case .setNavigation(let route): - state.route = route - return .none - + return handleSetNavigation(&state, route: route) + case .toggleShowsPanel: - state.showsPanel.toggle() - return .none - - case .setOrientationPortrait(let isPortrait): - var effects = [Effect]() - if isPortrait { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) - effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) - } else { - effects.append(.run(operation: { _ in appDelegateClient.setAllOrientationMask() })) - } - return .merge(effects) - + return handleToggleShowsPanel(&state) + case .onPerformDismiss: - return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) - + return handlePerformDismiss() + case .onAppear(let gid, let enablesLandscape): - var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)) - ] - if enablesLandscape { - effects.append(.send(.setOrientationPortrait(false))) - } - return .merge(effects) - + return handleOnAppear(&state, gid: gid, enablesLandscape: enablesLandscape) + + case .teardown: + return handleTeardown(&state) + + // MARK: - Orientation Actions + case .setOrientationPortrait(let isPortrait): + return handleSetOrientationPortrait(isPortrait: isPortrait) + + // MARK: - Web Image Actions case .onWebImageRetry(let index): - state.imageURLLoadingStates[index] = .idle - return .none - + return handleWebImageRetry(&state, index: index) + case .onWebImageSucceeded(let index): - state.imageURLLoadingStates[index] = .idle - state.webImageLoadSuccessIndices.insert(index) - return .none - + return handleWebImageSucceeded(&state, index: index) + case .onWebImageFailed(let index): - state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none - + return handleWebImageFailed(&state, index: index) + case .reloadAllWebImages: - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.forceRefreshID = .init() - return .run { [state] _ in - await databaseClient.removeImageURLs(gid: state.gallery.id) - } - + return handleReloadAllWebImages(&state) + case .retryAllFailedWebImages: - state.imageURLLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.imageURLLoadingStates[index] = .idle - } - } - state.previewLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.previewLoadingStates[index] = .idle - } - } - return .none - + return handleRetryAllFailedWebImages(&state) + + // MARK: - Image Actions case .copyImage(let imageURL): - return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) - + return handleCopyImage(imageURL: imageURL) + case .saveImage(let imageURL): - return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) - + return handleSaveImage(imageURL: imageURL) + case .saveImageDone(let isSucceeded): - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error - return .send(.setNavigation(.hud)) - + return handleSaveImageDone(&state, isSucceeded: isSucceeded) + case .shareImage(let imageURL): - return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) - + return handleShareImage(imageURL: imageURL) + case .fetchImage(let action, let imageURL): - return .run { send in - let result = await imageClient.fetchImage(url: imageURL) - await send(.fetchImageDone(action, result)) - } - .cancellable(id: CancelID.fetchImage) - + return handleFetchImage(action: action, imageURL: imageURL) + case .fetchImageDone(let action, let result): - if case .success(let image) = result { - switch action { - case .copy(let isAnimated): - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) - ) - case .save(let isAnimated): - return .run { send in - let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) - await send(.saveImageDone(success)) - } - case .share(let isAnimated): - if isAnimated, let data = image.kf.data(format: .GIF) { - return .send(.setNavigation(.share(.init(value: .data(data))))) - } else { - return .send(.setNavigation(.share(.init(value: .image(image))))) - } - } - } else { - state.hudConfig = .error - return .send(.setNavigation(.hud)) - } - + return handleFetchImageDone(&state, action: action, result: result) + + // MARK: - Synchronization Actions case .syncReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - + return handleSyncReadingProgress(state: state, progress: progress) + case .syncPreviewURLs(let previewURLs): - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - + return handleSyncPreviewURLs(state: state, previewURLs: previewURLs) + case .syncThumbnailURLs(let thumbnailURLs): - return .run { [state] _ in - await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) - } - + return handleSyncThumbnailURLs(state: state, thumbnailURLs: thumbnailURLs) + case .syncImageURLs(let imageURLs, let originalImageURLs): - return .run { [state] _ in - await databaseClient.updateImageURLs( - gid: state.gallery.id, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - } - - case .teardown: - var effects: [Effect] = [ - .merge(CancelID.allCases.map(Effect.cancel(id:))) - ] - if !deviceClient.isPad() { - effects.append(.send(.setOrientationPortrait(true))) - } - return .merge(effects) - + return handleSyncImageURLs( + state: state, + imageURLs: imageURLs, + originalImageURLs: originalImageURLs + ) + + // MARK: - Database Actions case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) - return .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - + return handleFetchDatabaseInfos(&state, gid: gid) + case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs - state.readingProgress = galleryState.readingProgress - state.databaseLoadingState = .idle - return .none - + return handleFetchDatabaseInfosDone(&state, galleryState: galleryState) + + // MARK: - Preview Actions case .fetchPreviewURLs(let index): - guard state.previewLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchPreviewURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchPreviewURLs) - + return handleFetchPreviewURLs(&state, index: index) + case .fetchPreviewURLsDone(let index, let result): - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.previewLoadingStates[index] = .failed(.notFound) - return .none - } - state.previewLoadingStates[index] = .idle - state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) - case .failure(let error): - state.previewLoadingStates[index] = .failed(error) - } - return .none - + return handleFetchPreviewURLsDone(&state, index: index, result: result) + + // MARK: - Image URL Actions case .fetchImageURLs(let index): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, false)) - } else { - return .send(.fetchThumbnailURLs(index)) - } - + return handleFetchImageURLs(&state, index: index) + case .refetchImageURLs(let index): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, true)) - } else { - return .send(.refetchNormalImageURLs(index)) - } - + return handleRefetchImageURLs(&state, index: index) + case .prefetchImages(let index, let prefetchLimit): - func getPrefetchImageURLs(range: ClosedRange) -> [URL] { - (range.lowerBound...range.upperBound).compactMap { index in - if let url = state.imageURLs[index] { - return url - } - return nil - } - } - func getFetchImageURLIndices(range: ClosedRange) -> [Int] { - (range.lowerBound...range.upperBound).compactMap { index in - if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { - return index - } - return nil - } - } - var prefetchImageURLs = [URL]() - var fetchImageURLIndices = [Int]() - var effects = [Effect]() - let previousUpperBound = max(index - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) - if previousUpperBound - previousLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) - } - let nextLowerBound = min(index + 2, state.gallery.pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) - if nextUpperBound - nextLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) - } - fetchImageURLIndices.forEach { - effects.append(.send(.fetchImageURLs($0))) - } - effects.append( - .run { [prefetchImageURLs] _ in - imageClient.prefetchImages(prefetchImageURLs) - } - ) - return .merge(effects) - + return handlePrefetchImages(&state, index: index, prefetchLimit: prefetchLimit) + + // MARK: - Thumbnail Actions case .fetchThumbnailURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewConfig.batchRange(index: index).forEach { - state.imageURLLoadingStates[$0] = .loading - } - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchThumbnailURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchThumbnailURLs) - + return handleFetchThumbnailURLs(&state, index: index) + case .fetchThumbnailURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let thumbnailURLs): - guard !thumbnailURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { - return .send(.fetchMPVKeys(index, url)) - } else { - state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .send(.syncThumbnailURLs(thumbnailURLs)), - .send(.fetchNormalImageURLs(index, thumbnailURLs)) - ) - } - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - + return handleFetchThumbnailURLsDone(&state, index: index, result: result) + + // MARK: - Normal Image Actions case .fetchNormalImageURLs(let index, let thumbnailURLs): - return .run { send in - let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() - await send(.fetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchNormalImageURLs) - + return handleFetchNormalImageURLs(index: index, thumbnailURLs: thumbnailURLs) + case .fetchNormalImageURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (imageURLs, originalImageURLs)): - guard !imageURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - + return handleFetchNormalImageURLsDone(&state, index: index, result: result) + case .refetchNormalImageURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL, - let imageURL = state.imageURLs[index] - else { return .none } - state.imageURLLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { [thumbnailURL = state.thumbnailURLs[index]] send in - let response = await GalleryNormalImageURLRefetchRequest( - index: index, - pageNum: pageNum, - galleryURL: galleryURL, - thumbnailURL: thumbnailURL, - storedImageURL: imageURL - ) - .response() - await send(.refetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.refetchNormalImageURLs) - + return handleRefetchNormalImageURLs(&state, index: index) + case .refetchNormalImageURLsDone(let index, let result): - switch result { - case .success(let (imageURLs, response)): - var effects = [Effect]() - if let response = response { - effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) - } - guard !imageURLs.isEmpty else { - state.imageURLLoadingStates[index] = .failed(.notFound) - return effects.isEmpty ? .none : .merge(effects) - } - state.imageURLLoadingStates[index] = .idle - state.updateImageURLs(imageURLs, [:]) - effects.append(.send(.syncImageURLs(imageURLs, [:]))) - return .merge(effects) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - + return handleRefetchNormalImageURLsDone(&state, index: index, result: result) + + // MARK: - MPV Actions case .fetchMPVKeys(let index, let mpvURL): - return .run { send in - let response = await MPVKeysRequest(mpvURL: mpvURL).response() - await send(.fetchMPVKeysDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVKeys) - + return handleFetchMPVKeys(index: index, mpvURL: mpvURL) + case .fetchMPVKeysDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (mpvKey, mpvImageKeys)): - let pageCount = state.gallery.pageCount - guard mpvImageKeys.count == pageCount else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.mpvKey = mpvKey - state.mpvImageKeys = mpvImageKeys - return .merge( - Array(1...min(3, max(1, pageCount))).map { - .send(.fetchMPVImageURL($0, false)) - } - ) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } + return handleFetchMPVKeysDone(&state, index: index, result: result) + + case .fetchMPVImageURL(let index, let isRefresh): + return handleFetchMPVImageURL(&state, index: index, isRefresh: isRefresh) + + case .fetchMPVImageURLDone(let index, let result): + return handleFetchMPVImageURLDone(&state, index: index, result: result) + } + } + .haptics(unwrapping: \.route, case: \.readingSetting, hapticsClient: hapticsClient) + .haptics(unwrapping: \.route, case: \.share, hapticsClient: hapticsClient) + } + + // MARK: - Handler Methods + + /// Basic Action Handlers + func handleSetNavigation(_ state: inout State, route: Route?) -> Effect { + state.route = route + return .none + } + + func handleToggleShowsPanel(_ state: inout State) -> Effect { + state.showsPanel.toggle() + return .none + } + + func handlePerformDismiss() -> Effect { + return .run(operation: { _ in + hapticsClient.generateFeedback(.light) + }) + } + + func handleOnAppear(_ state: inout State, gid: String, enablesLandscape: Bool) -> Effect { + var effects: [Effect] = [ + .send(.fetchDatabaseInfos(gid)) + ] + if enablesLandscape { + effects.append(.send(.setOrientationPortrait(false))) + } + return .merge(effects) + } + + func handleTeardown(_ state: inout State) -> Effect { + var effects: [Effect] = [ + .merge(CancelID.allCases.map(Effect.cancel(id:))) + ] + if !deviceClient.isPad() { + effects.append(.send(.setOrientationPortrait(true))) + } + return .merge(effects) + } + + /// Orientation Handlers + func handleSetOrientationPortrait(isPortrait: Bool) -> Effect { + var effects = [Effect]() + if isPortrait { + effects.append(.run(operation: { _ in + appDelegateClient.setPortraitOrientationMask() + })) + effects.append(.run(operation: { _ in + await appDelegateClient.setPortraitOrientation() + })) + } else { + effects.append(.run(operation: { _ in + appDelegateClient.setAllOrientationMask() + })) + } + return .merge(effects) + } + + /// Web Image Handlers + func handleWebImageRetry(_ state: inout State, index: Int) -> Effect { + state.imageURLLoadingStates[index] = .idle + return .none + } + + func handleWebImageSucceeded(_ state: inout State, index: Int) -> Effect { + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + return .none + } + + func handleWebImageFailed(_ state: inout State, index: Int) -> Effect { + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + return .none + } + + func handleReloadAllWebImages(_ state: inout State) -> Effect { + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.forceRefreshID = .init() + + return .run { [galleryId = state.gallery.id] _ in + await databaseClient.removeImageURLs(gid: galleryId) + } + } + + func handleRetryAllFailedWebImages(_ state: inout State) -> Effect { + state.imageURLLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.imageURLLoadingStates[index] = .idle + } + } + state.previewLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.previewLoadingStates[index] = .idle + } + } + return .none + } + + /// Image Action Handlers + func handleCopyImage(imageURL: URL) -> Effect { + return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) + } + + func handleSaveImage(imageURL: URL) -> Effect { + return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) + } + + func handleSaveImageDone(_ state: inout State, isSucceeded: Bool) -> Effect { + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + return .send(.setNavigation(.hud)) + } + + func handleShareImage(imageURL: URL) -> Effect { + return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) + } + + func handleFetchImage(action: ImageAction, imageURL: URL) -> Effect { + return .run { send in + let result = await imageClient.fetchImage(url: imageURL) + await send(.fetchImageDone(action, result)) + } + .cancellable(id: CancelID.fetchImage) + } + + func handleFetchImageDone( + _ state: inout State, + action: ImageAction, + result: Result + ) -> Effect { + switch result { + case .success(let image): + return handleSuccessfulImageFetch(state: &state, action: action, image: image) + case .failure: + state.hudConfig = .error + return .send(.setNavigation(.hud)) + } + } + + private func handleSuccessfulImageFetch( + state: inout State, + action: ImageAction, + image: UIImage + ) -> Effect { + switch action { + case .copy(let isAnimated): + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .send(.setNavigation(.hud)), + .run(operation: { _ in + clipboardClient.saveImage(image, isAnimated) + }) + ) + case .save(let isAnimated): + return .run { send in + let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) + await send(.saveImageDone(success)) + } + case .share(let isAnimated): + if isAnimated, let data = image.kf.data(format: .GIF) { + return .send(.setNavigation(.share(.init(value: .data(data))))) + } else { + return .send(.setNavigation(.share(.init(value: .image(image))))) + } + } + } + + /// Synchronization Handlers + func handleSyncReadingProgress(state: State, progress: Int) -> Effect { + return .run { _ in + await databaseClient.updateReadingProgress( + gid: state.gallery.id, + progress: progress + ) + } + } + + func handleSyncPreviewURLs(state: State, previewURLs: [Int: URL]) -> Effect { + return .run { _ in + await databaseClient.updatePreviewURLs( + gid: state.gallery.id, + previewURLs: previewURLs + ) + } + } + + func handleSyncThumbnailURLs(state: State, thumbnailURLs: [Int: URL]) -> Effect { + return .run { _ in + await databaseClient.updateThumbnailURLs( + gid: state.gallery.id, + thumbnailURLs: thumbnailURLs + ) + } + } + + func handleSyncImageURLs( + state: State, + imageURLs: [Int: URL], + originalImageURLs: [Int: URL] + ) -> Effect { + return .run { _ in + await databaseClient.updateImageURLs( + gid: state.gallery.id, + imageURLs: imageURLs, + originalImageURLs: originalImageURLs + ) + } + } + + /// Database Handlers + func handleFetchDatabaseInfos(_ state: inout State, gid: String) -> Effect { + guard let gallery = databaseClient.fetchGallery(gid: gid) else { + return .none + } + + state.gallery = gallery + state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + + return .run { [galleryId = state.gallery.id] send in + guard let dbState = await databaseClient.fetchGalleryState(gid: galleryId) else { + return + } + await send(.fetchDatabaseInfosDone(dbState)) + } + .cancellable(id: CancelID.fetchDatabaseInfos) + } + + func handleFetchDatabaseInfosDone( + _ state: inout State, + galleryState: GalleryState + ) -> Effect { + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.imageURLs = galleryState.imageURLs + state.thumbnailURLs = galleryState.thumbnailURLs + state.originalImageURLs = galleryState.originalImageURLs + state.readingProgress = galleryState.readingProgress + state.databaseLoadingState = .idle + return .none + } + + /// Preview Handlers + func handleFetchPreviewURLs(_ state: inout State, index: Int) -> Effect { + guard state.previewLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { + return .none + } + + state.previewLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + + return .run { send in + let response = await GalleryPreviewURLsRequest( + galleryURL: galleryURL, + pageNum: pageNum + ).response() + await send(.fetchPreviewURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchPreviewURLs) + } + + func handleFetchPreviewURLsDone( + _ state: inout State, + index: Int, + result: Result<[Int: URL], AppError> + ) -> Effect { + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.previewLoadingStates[index] = .failed(.notFound) + return .none + } + state.previewLoadingStates[index] = .idle + state.updatePreviewURLs(previewURLs) + return .send(.syncPreviewURLs(previewURLs)) + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + return .none + } + } + + /// Image URL Handlers + func handleFetchImageURLs(_ state: inout State, index: Int) -> Effect { + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, false)) + } else { + return .send(.fetchThumbnailURLs(index)) + } + } + + func handleRefetchImageURLs(_ state: inout State, index: Int) -> Effect { + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, true)) + } else { + return .send(.refetchNormalImageURLs(index)) + } + } + + func handlePrefetchImages( + _ state: inout State, + index: Int, + prefetchLimit: Int + ) -> Effect { + let prefetchHelper = PrefetchHelper(state: state, imageClient: imageClient) + return prefetchHelper.createPrefetchEffects( + currentIndex: index, + prefetchLimit: prefetchLimit + ) + } + + /// Thumbnail Handlers + func handleFetchThumbnailURLs(_ state: inout State, index: Int) -> Effect { + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { + return .none + } + + state.previewConfig.batchRange(index: index).forEach { + state.imageURLLoadingStates[$0] = .loading + } + + let pageNum = state.previewConfig.pageNumber(index: index) + + return .run { send in + let response = await ThumbnailURLsRequest( + galleryURL: galleryURL, + pageNum: pageNum + ).response() + await send(.fetchThumbnailURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchThumbnailURLs) + } + + func handleFetchThumbnailURLsDone( + _ state: inout State, + index: Int, + result: Result<[Int: URL], AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + + switch result { + case .success(let thumbnailURLs): + guard !thumbnailURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) } return .none - - case .fetchMPVImageURL(let index, let isRefresh): - guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, - let mpvImageKey = state.mpvImageKeys[index], - state.imageURLLoadingStates[index] != .loading - else { return .none } - state.imageURLLoadingStates[index] = .loading - let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - return .run { send in - let response = await GalleryMPVImageURLRequest( - gid: gidInteger, - index: index, - mpvKey: mpvKey, - mpvImageKey: mpvImageKey, - skipServerIdentifier: skipServerIdentifier - ) - .response() - await send(.fetchMPVImageURLDone(index, response)) + } + + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { + return .send(.fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .merge( + .send(.syncThumbnailURLs(thumbnailURLs)), + .send(.fetchNormalImageURLs(index, thumbnailURLs)) + ) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + return .none + } + } + + /// Normal Image Handlers + func handleFetchNormalImageURLs( + index: Int, + thumbnailURLs: [Int: URL] + ) -> Effect { + return .run { send in + let response = await GalleryNormalImageURLsRequest( + thumbnailURLs: thumbnailURLs + ).response() + await send(.fetchNormalImageURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchNormalImageURLs) + } + + func handleFetchNormalImageURLsDone( + _ state: inout State, + index: Int, + result: Result<([Int: URL], [Int: URL]), AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + + switch result { + case .success(let (imageURLs, originalImageURLs)): + guard !imageURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) } - .cancellable(id: CancelID.fetchMPVImageURL) - - case .fetchMPVImageURLDone(let index, let result): - switch result { - case .success(let (imageURL, originalImageURL, skipServerIdentifier)): - let imageURLs: [Int: URL] = [index: imageURL] - var originalImageURLs = [Int: URL]() - if let originalImageURL = originalImageURL { - originalImageURLs[index] = originalImageURL - } - state.imageURLLoadingStates[index] = .idle - state.mpvSkipServerIdentifiers[index] = skipServerIdentifier - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) + return .none + } + + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + return .none + } + } + + func handleRefetchNormalImageURLs(_ state: inout State, index: Int) -> Effect { + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL, + let imageURL = state.imageURLs[index] + else { + return .none + } + + state.imageURLLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + + return .run { [thumbnailURL = state.thumbnailURLs[index]] send in + let response = await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: thumbnailURL, + storedImageURL: imageURL + ).response() + await send(.refetchNormalImageURLsDone(index, response)) + } + .cancellable(id: CancelID.refetchNormalImageURLs) + } + + func handleRefetchNormalImageURLsDone( + _ state: inout State, + index: Int, + result: Result<([Int: URL], HTTPURLResponse?), AppError> + ) -> Effect { + switch result { + case .success(let (imageURLs, response)): + var effects = [Effect]() + + if let response = response { + effects.append(.run(operation: { _ in + cookieClient.setSkipServer(response: response) + })) + } + + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + effects.append(.send(.syncImageURLs(imageURLs, [:]))) + return .merge(effects) + + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + return .none + } + } + + /// MPV Handlers + func handleFetchMPVKeys(index: Int, mpvURL: URL) -> Effect { + return .run { send in + let response = await MPVKeysRequest(mpvURL: mpvURL).response() + await send(.fetchMPVKeysDone(index, response)) + } + .cancellable(id: CancelID.fetchMPVKeys) + } + + func handleFetchMPVKeysDone( + _ state: inout State, + index: Int, + result: Result<(String, [Int: String]), AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + + switch result { + case .success(let (mpvKey, mpvImageKeys)): + let pageCount = state.gallery.pageCount + guard mpvImageKeys.count == pageCount else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) } return .none } + + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.mpvKey = mpvKey + state.mpvImageKeys = mpvImageKeys + + return .merge( + Array(1...min(3, max(1, pageCount))).map { + .send(.fetchMPVImageURL($0, false)) + } + ) + + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + return .none } - .haptics( - unwrapping: \.route, - case: \.readingSetting, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient + } + + func handleFetchMPVImageURL( + _ state: inout State, + index: Int, + isRefresh: Bool + ) -> Effect { + guard let gidInteger = Int(state.gallery.id), + let mpvKey = state.mpvKey, + let mpvImageKey = state.mpvImageKeys[index], + state.imageURLLoadingStates[index] != .loading + else { + return .none + } + + state.imageURLLoadingStates[index] = .loading + let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil + + return .run { send in + let response = await GalleryMPVImageURLRequest( + gid: gidInteger, + index: index, + mpvKey: mpvKey, + mpvImageKey: mpvImageKey, + skipServerIdentifier: skipServerIdentifier + ).response() + await send(.fetchMPVImageURLDone(index, response)) + } + .cancellable(id: CancelID.fetchMPVImageURL) + } + + func handleFetchMPVImageURLDone( + _ state: inout State, + index: Int, + result: Result<(URL, URL?, String), AppError> + ) -> Effect { + switch result { + case .success(let (imageURL, originalImageURL, skipServerIdentifier)): + let imageURLs: [Int: URL] = [index: imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = originalImageURL { + originalImageURLs[index] = originalImageURL + } + + state.imageURLLoadingStates[index] = .idle + state.mpvSkipServerIdentifiers[index] = skipServerIdentifier + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + return .none + } + } +} + +// MARK: - State Extensions +extension ReadingReducer.State { + /// Updates preview URLs + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + guard !previewURLs.isEmpty else { return } + self.previewURLs = self.previewURLs.merging(previewURLs) { _, new in new } + } + + /// Updates thumbnail URLs + mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { + guard !thumbnailURLs.isEmpty else { return } + self.thumbnailURLs = self.thumbnailURLs.merging(thumbnailURLs) { _, new in new } + } + + /// Updates image URLs and original image URLs + mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { + if !imageURLs.isEmpty { + self.imageURLs = self.imageURLs.merging(imageURLs) { _, new in new } + } + if !originalImageURLs.isEmpty { + self.originalImageURLs = self.originalImageURLs.merging(originalImageURLs) { _, new in new } + } + } + + /// Gets container data source for the current configuration + func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { + let defaultData = Array(1...gallery.pageCount) + + guard isLandscape && + setting.enablesDualPageMode && + setting.readingDirection != .vertical + else { + return defaultData + } + + let data = setting.exceptCover + ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) + : Array(stride(from: 1, through: gallery.pageCount, by: 2)) + + return data + } + + /// Gets image container configurations for dual page mode + func imageContainerConfigs( + index: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> ImageStackConfig { + let direction = setting.readingDirection + let isReversed = direction == .rightToLeft + let isFirstSingle = setting.exceptCover + let isFirstPageAndSingle = index == 1 && isFirstSingle + let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical + + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index + let secondIndex = firstIndex + (isReversed ? -1 : 1) + + let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= gallery.pageCount + : secondIndex >= 1 && secondIndex <= gallery.pageCount + + let dualPageConfig = DualPageConfiguration( + firstIndex: firstIndex, + secondIndex: secondIndex, + isFirstAvailable: isValidFirstRange, + isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage, + isDualPage: isDualPage ) + + return ImageStackConfig(from: dualPageConfig) } } + +// MARK: - Helper Classes + +/// Helper class for managing prefetch operations +private struct PrefetchHelper { + let state: ReadingReducer.State + let imageClient: ImageClient + + func createPrefetchEffects(currentIndex: Int, prefetchLimit: Int) -> Effect { + let (prefetchURLs, fetchIndices) = calculatePrefetchData( + currentIndex: currentIndex, + prefetchLimit: prefetchLimit + ) + + var effects = fetchIndices.map { index in + Effect.send(.fetchImageURLs(index)) + } + + effects.append( + .run { _ in + imageClient.prefetchImages(prefetchURLs) + } + ) + + return .merge(effects) + } + + private func calculatePrefetchData( + currentIndex: Int, + prefetchLimit: Int + ) -> (urls: [URL], indices: [Int]) { + var prefetchURLs = [URL]() + var fetchIndices = [Int]() + + // Previous pages + let previousUpperBound = max(currentIndex - 2, 1) + let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) + if previousUpperBound - previousLowerBound > 0 { + let previousRange = previousLowerBound...previousUpperBound + prefetchURLs += getURLsForRange(previousRange) + fetchIndices += getIndicesNeedingFetch(previousRange) + } + + // Next pages + let nextLowerBound = min(currentIndex + 2, state.gallery.pageCount) + let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) + if nextUpperBound - nextLowerBound > 0 { + let nextRange = nextLowerBound...nextUpperBound + prefetchURLs += getURLsForRange(nextRange) + fetchIndices += getIndicesNeedingFetch(nextRange) + } + + return (prefetchURLs, fetchIndices) + } + + private func getURLsForRange(_ range: ClosedRange) -> [URL] { + return range.compactMap { index in + state.imageURLs[index] + } + } + + private func getIndicesNeedingFetch(_ range: ClosedRange) -> [Int] { + return range.compactMap { index in + if state.imageURLs[index] == nil && + state.imageURLLoadingStates[index] != .loading { + return index + } + return nil + } + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index bfc301fb..0b2d6da3 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -3,6 +3,7 @@ // EhPanda // // Created by 荒木辰造 on R 4/01/22. +// Refactored for improved maintainability by zackie on 2025-07-28. // import SwiftUI @@ -10,624 +11,467 @@ import Kingfisher import SwiftUIPager import ComposableArchitecture +// MARK: - Main Reading View struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme - @Bindable var store: StoreOf + + // MARK: - Configuration private let gid: String @Binding private var setting: Setting private let blurRadius: Double - @StateObject private var liveTextHandler = LiveTextHandler() - @StateObject private var autoPlayHandler = AutoPlayHandler() - @StateObject private var gestureHandler = GestureHandler() - @StateObject private var pageHandler = PageHandler() + // MARK: - View Models + @StateObject private var viewModel: ReadingViewModel + @StateObject private var gestureCoordinator: GestureCoordinator + @StateObject private var pageCoordinator: PageCoordinator @StateObject private var page: Page = .first() + // MARK: - Initialization init( store: StoreOf, - gid: String, setting: Binding, blurRadius: Double + gid: String, + setting: Binding, + blurRadius: Double ) { self.store = store self.gid = gid _setting = setting self.blurRadius = blurRadius + + // Initialize view models with dependencies + _viewModel = StateObject(wrappedValue: ReadingViewModel()) + _gestureCoordinator = StateObject(wrappedValue: GestureCoordinator()) + _pageCoordinator = StateObject(wrappedValue: PageCoordinator()) } - - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) - } - + + // MARK: - Body var body: some View { - changeTriggers(content: { content }) - .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in - NavigationView { - ReadingSettingView( - readingDirection: $setting.readingDirection, - prefetchLimit: $setting.prefetchLimit, - enablesLandscape: $setting.enablesLandscape, - contentDividerHeight: $setting.contentDividerHeight, - maximumScaleFactor: $setting.maximumScaleFactor, - doubleTapScaleFactor: $setting.doubleTapScaleFactor - ) - .toolbar { - CustomToolbarItem(placement: .cancellationAction) { - if !DeviceUtil.isPad && DeviceUtil.isLandscape { - Button { - store.send(.setNavigation(nil)) - } label: { - Image(systemSymbol: .chevronDown) - } - } - } - } - } - .accentColor(setting.accentColor) - .tint(setting.accentColor) - .autoBlur(radius: blurRadius) - .navigationViewStyle(.stack) - } - .sheet(item: $store.route.sending(\.setNavigation).share) { shareItemBox in - ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) - - .animation(.linear(duration: 0.1), value: gestureHandler.offset) - .animation(.default, value: liveTextHandler.enablesLiveText) - .animation(.default, value: liveTextHandler.liveTextGroups) - .animation(.default, value: gestureHandler.scale) - .animation(.default, value: store.showsPanel) - .statusBar(hidden: !store.showsPanel) - .onDisappear { - liveTextHandler.cancelRequests() - setAutoPlayPolocy(.off) - } - .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } - } - - var content: some View { ZStack { backgroundColor.ignoresSafeArea() - ZStack { - if setting.readingDirection == .vertical { - AdvancedList( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self, - spacing: setting.contentDividerHeight, - gesture: SimultaneousGesture(magnificationGesture, tapGesture), - content: imageStack - ) - .scrollDisabled(gestureHandler.scale != 1) - } else { - Pager( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self, - content: imageStack - ) - .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) - .swipeInteractionArea(.allAvailable) - .allowsDragging(gestureHandler.scale == 1) - } - } - .scaleEffect(gestureHandler.scale, anchor: gestureHandler.scaleAnchor) - .offset(gestureHandler.offset) - .highPriorityGesture( - dragGesture.simultaneously(with: tapGesture), - isEnabled: gestureHandler.scale > 1 + ReadingContentView( + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + page: page ) - .gesture(tapGesture, isEnabled: gestureHandler.scale == 1) - .gesture(magnificationGesture) - .ignoresSafeArea() - .id(store.databaseLoadingState) - .id(store.forceRefreshID) - - ControlPanel( - showsPanel: $store.showsPanel, - showsSliderPreview: $store.showsSliderPreview, - sliderValue: $pageHandler.sliderValue, setting: $setting, - enablesLiveText: $liveTextHandler.enablesLiveText, - autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), - range: 1...Float(store.gallery.pageCount), - previewURLs: store.previewURLs, - dismissGesture: controlPanelDismissGesture, - dismissAction: { store.send(.onPerformDismiss) }, - navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, - reloadAllImagesAction: { store.send(.reloadAllWebImages) }, - retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, - fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } + + ReadingControlsOverlay( + store: store, + setting: $setting, + viewModel: viewModel, + pageCoordinator: pageCoordinator, + gestureCoordinator: gestureCoordinator, + page: page ) } - } - - @ViewBuilder - private func changeTriggers(@ViewBuilder content: () -> Content) -> some View { - content() - // Page - .onChange(of: page.index) { _, newValue in - Logger.info("page.index changed", context: ["pageIndex": newValue]) - let newValue = pageHandler.mapFromPager( - index: newValue, pageCount: store.gallery.pageCount, setting: setting - ) - pageHandler.sliderValue = .init(newValue) - if store.databaseLoadingState == .idle { - store.send(.syncReadingProgress(.init(newValue))) - } - } - .onChange(of: pageHandler.sliderValue) { _, newValue in - Logger.info("pageHandler.sliderValue changed", context: ["sliderValue": newValue]) - if !store.showsSliderPreview { - setPageIndex(sliderValue: newValue) - } - } - .onChange(of: store.showsSliderPreview) { _, newValue in - Logger.info("store.showsSliderPreview changed", context: ["isShown": newValue]) - if !newValue { setPageIndex(sliderValue: pageHandler.sliderValue) } - setAutoPlayPolocy(.off) - } - .onChange(of: store.readingProgress) { _, newValue in - Logger.info("store.readingProgress changed", context: ["readingProgress": newValue]) - pageHandler.sliderValue = .init(newValue) - } - - // AutoPlay - .onChange(of: store.route) { _, newValue in - Logger.info("store.route changed", context: ["route": newValue]) - if ![.hud, .none].contains(newValue) { - setAutoPlayPolocy(.off) - } - } - - // LiveText - .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in - Logger.info("liveTextHandler.enablesLiveText changed", context: ["isEnabled": newValue]) - if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } - } - .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in - Logger.info("store.webImageLoadSuccessIndices changed", context: [ - "count": store.webImageLoadSuccessIndices.count - ]) - if liveTextHandler.enablesLiveText { - newValue.forEach(analyzeImageForLiveText) - } - } - - // Orientation - .onChange(of: setting.enablesLandscape) { _, newValue in - Logger.info("setting.enablesLandscape changed", context: ["newValue": newValue]) - store.send(.setOrientationPortrait(!newValue)) - } - } - - @ViewBuilder private func imageStack(index: Int) -> some View { - let imageStackConfig = store.state.imageContainerConfigs(index: index, setting: setting) - let isDualPage = setting.enablesDualPageMode && setting.readingDirection != .vertical && DeviceUtil.isLandscape - HorizontalImageStack( - index: index, - isDualPage: isDualPage, - isDatabaseLoading: store.databaseLoadingState != .idle, - backgroundColor: backgroundColor, - config: imageStackConfig, - imageURLs: store.imageURLs, - originalImageURLs: store.originalImageURLs, - loadingStates: store.imageURLLoadingStates, - enablesLiveText: liveTextHandler.enablesLiveText, - liveTextGroups: liveTextHandler.liveTextGroups, - focusedLiveTextGroup: liveTextHandler.focusedLiveTextGroup, - liveTextTapAction: liveTextHandler.setFocusedLiveTextGroup, - fetchAction: { store.send(.fetchImageURLs($0)) }, - refetchAction: { store.send(.refetchImageURLs($0)) }, - prefetchAction: { store.send(.prefetchImages($0, setting.prefetchLimit)) }, - loadRetryAction: { store.send(.onWebImageRetry($0)) }, - loadSucceededAction: { store.send(.onWebImageSucceeded($0)) }, - loadFailedAction: { store.send(.onWebImageFailed($0)) }, - copyImageAction: { store.send(.copyImage($0)) }, - saveImageAction: { store.send(.saveImage($0)) }, - shareImageAction: { store.send(.shareImage($0)) } - ) - } -} - -// MARK: Handler methods -extension ReadingView { - func setPageIndex(sliderValue: Float) { - let newValue = pageHandler.mapToPager( - index: .init(sliderValue), setting: setting + .readingViewModifiers( + store: store, + setting: $setting, + blurRadius: blurRadius ) - if page.index != newValue { - page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) + .onAppear { + store.send(.onAppear(gid, setting.enablesLandscape)) + setupViewModels() } + .onDisappear { + cleanup() + } + .observeReadingChanges( + store: store, + setting: $setting, + viewModel: viewModel, + pageCoordinator: pageCoordinator, + page: page + ) } - func setAutoPlayPolocy(_ policy: AutoPlayPolicy) { - autoPlayHandler.setPolicy(policy, updatePageAction: { - page.update(.next) - Logger.info("Pager.update", context: ["update": "next"]) - }) + + // MARK: - Computed Properties + private var backgroundColor: Color { + colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) } - func analyzeImageForLiveText(index: Int) { - Logger.info("analyzeImageForLiveText", context: ["index": index]) - guard liveTextHandler.liveTextGroups[index] == nil else { - Logger.info("analyzeImageForLiveText duplicated", context: ["index": index]) - return - } - guard let key = store.imageURLs[index]?.absoluteString else { - Logger.info("analyzeImageForLiveText URL not found", context: ["index": index]) - return + + // MARK: - Helper Methods + private func setupViewModels() { + viewModel.setup(with: store.state, setting: setting) + gestureCoordinator.setup(setting: setting) + + // Setup page coordinator with initial reading progress if available + if store.readingProgress > 0 { + pageCoordinator.setup( + pageCount: store.gallery.pageCount, + setting: setting, + initialPage: store.readingProgress + ) + + // Also update the pager to the correct initial position + let pagerIndex = pageCoordinator.mapToPager(index: store.readingProgress, setting: setting) + page.update(.new(index: pagerIndex)) + } else { + pageCoordinator.setup( + pageCount: store.gallery.pageCount, + setting: setting + ) } - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let result): - if let image = result.image, let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes + } + + private func cleanup() { + viewModel.cleanup() + gestureCoordinator.cleanup() + pageCoordinator.cleanup() + } +} + +// MARK: - Reading Content View +private struct ReadingContentView: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var gestureCoordinator: GestureCoordinator + @ObservedObject var pageCoordinator: PageCoordinator + let page: Page + + var body: some View { + Group { + if setting.readingDirection == .vertical { + VerticalReadingView( + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + page: page ) } else { - Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) - } - case .failure(let error): - Logger.info( - "analyzeImageForLiveText failed", - context: [ - "index": index, - "error": error - ] - as [String: Any] + HorizontalReadingView( + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + page: page, + onTogglePanel: { store.send(.toggleShowsPanel) } ) } } + .scaleEffect(gestureCoordinator.scale, anchor: gestureCoordinator.scaleAnchor) + .offset(gestureCoordinator.offset) + .ignoresSafeArea() + .id(store.databaseLoadingState) + .id(store.forceRefreshID) } } -// MARK: Gesture -extension ReadingView { - var tapGesture: some Gesture { - let singleTap = TapGesture(count: 1) - .onEnded { - gestureHandler.onSingleTapGestureEnded( - readingDirection: setting.readingDirection, - setPageIndexOffsetAction: { - let newValue = page.index + $0 - page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) - }, - toggleShowsPanelAction: { store.send(.toggleShowsPanel) } - ) - } - let doubleTap = TapGesture(count: 2) - .onEnded { - gestureHandler.onDoubleTapGestureEnded( - scaleMaximum: setting.maximumScaleFactor, - doubleTapScale: setting.doubleTapScaleFactor - ) - } - return ExclusiveGesture(doubleTap, singleTap) - } - var magnificationGesture: some Gesture { - MagnificationGesture() - .onChanged { - gestureHandler.onMagnificationGestureChanged( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - .onEnded { - gestureHandler.onMagnificationGestureEnded( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - } - var dragGesture: some Gesture { - DragGesture(minimumDistance: .zero, coordinateSpace: .local) - .onChanged(gestureHandler.onDragGestureChanged) - .onEnded(gestureHandler.onDragGestureEnded) - } - var controlPanelDismissGesture: some Gesture { - DragGesture().onEnded { - gestureHandler.onControlPanelDismissGestureEnded( - value: $0, dismissAction: { store.send(.onPerformDismiss) } +// MARK: - Vertical Reading View (Fixed for iOS 26) +private struct VerticalReadingView: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var gestureCoordinator: GestureCoordinator + @ObservedObject var pageCoordinator: PageCoordinator + let page: Page + + var body: some View { + // Fixed vertical scroll implementation for iOS 26 compatibility + ImprovedScrollView( + isScrollEnabled: gestureCoordinator.scale <= 1.0, + page: page, + data: store.state.containerDataSource(setting: setting), + spacing: setting.contentDividerHeight, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + onTogglePanel: { store.send(.toggleShowsPanel) } + ) { index in + ImageStackView( + index: index, + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator ) } } } -// MARK: HorizontalImageStack -private struct HorizontalImageStack: View { - private let index: Int - private let isDualPage: Bool - private let isDatabaseLoading: Bool - private let backgroundColor: Color - private let config: ImageStackConfig - private let imageURLs: [Int: URL] - private let originalImageURLs: [Int: URL] - private let loadingStates: [Int: LoadingState] - private let enablesLiveText: Bool - private let liveTextGroups: [Int: [LiveTextGroup]] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let fetchAction: (Int) -> Void - private let refetchAction: (Int) -> Void - private let prefetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void - private let copyImageAction: (URL) -> Void - private let saveImageAction: (URL) -> Void - private let shareImageAction: (URL) -> Void - - init( - index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, - config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], - loadingStates: [Int: LoadingState], enablesLiveText: Bool, - liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - fetchAction: @escaping (Int) -> Void, - refetchAction: @escaping (Int) -> Void, prefetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void, copyImageAction: @escaping (URL) -> Void, - saveImageAction: @escaping (URL) -> Void, shareImageAction: @escaping (URL) -> Void - ) { - self.index = index - self.isDualPage = isDualPage - self.isDatabaseLoading = isDatabaseLoading - self.backgroundColor = backgroundColor - self.config = config - self.imageURLs = imageURLs - self.originalImageURLs = originalImageURLs - self.loadingStates = loadingStates - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.fetchAction = fetchAction - self.refetchAction = refetchAction - self.prefetchAction = prefetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - self.copyImageAction = copyImageAction - self.saveImageAction = saveImageAction - self.shareImageAction = shareImageAction - } +// MARK: - Horizontal Reading View +private struct HorizontalReadingView: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var gestureCoordinator: GestureCoordinator + @ObservedObject var pageCoordinator: PageCoordinator + let page: Page + let onTogglePanel: () -> Void var body: some View { - HStack(spacing: 0) { - if config.isFirstAvailable { - imageContainer(index: config.firstIndex) - } - if config.isSecondAvailable { - imageContainer(index: config.secondIndex) - } + Pager( + page: page, + data: store.state.containerDataSource(setting: setting), + id: \.self + ) { index in + ImageStackView( + index: index, + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator + ) } - } - - func imageContainer(index: Int) -> some View { - ImageContainer( - index: index, - imageURL: imageURLs[index], - loadingState: loadingStates[index] ?? .idle, - isDualPage: isDualPage, - backgroundColor: backgroundColor, - enablesLiveText: enablesLiveText, - liveTextGroups: liveTextGroups[index] ?? [], - focusedLiveTextGroup: focusedLiveTextGroup, - liveTextTapAction: liveTextTapAction, - refetchAction: refetchAction, - loadRetryAction: loadRetryAction, - loadSucceededAction: loadSucceededAction, - loadFailedAction: loadFailedAction + .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) + .swipeInteractionArea(.allAvailable) + .allowsDragging(gestureCoordinator.scale == 1) + .readingGestures( + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + page: page, + onTogglePanel: onTogglePanel ) - .onAppear { - if !isDatabaseLoading { - if imageURLs[index] == nil { - fetchAction(index) - } - prefetchAction(index) - } - } - .contextMenu { contextMenuItems(index: index) } - } - @ViewBuilder private func contextMenuItems(index: Int) -> some View { - Button { - refetchAction(index) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.reload, systemSymbol: .arrowCounterclockwise) - } - if let imageURL = imageURLs[index] { - Button { - copyImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare) - } - Button { - saveImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) - } - if let originalImageURL = originalImageURLs[index] { - Button { - saveImageAction(originalImageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, - systemSymbol: .squareAndArrowDownOnSquare - ) - } - } - Button { - shareImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp) - } - } } } -// MARK: ImageContainer -private struct ImageContainer: View { - private var width: CGFloat { - DeviceUtil.windowW / (isDualPage ? 2 : 1) - } - private var height: CGFloat { - width / Defaults.ImageSize.contentAspect - } - - private let index: Int - private let imageURL: URL? - private let loadingState: LoadingState - private let isDualPage: Bool - private let backgroundColor: Color - private let enablesLiveText: Bool - private let liveTextGroups: [LiveTextGroup] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let refetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void +// MARK: - Improved Scroll View (Fixes iOS 26 bug) +private struct ImprovedScrollView: View { + let isScrollEnabled: Bool + let page: Page + let data: [Int] + let spacing: CGFloat + let gestureCoordinator: GestureCoordinator + let pageCoordinator: PageCoordinator + let setting: Setting + let onTogglePanel: () -> Void + let content: (Int) -> Content + + @State private var performingChanges = false + @State private var scrollTarget: Int? + @State private var currentVisibleIndex: Int = 0 init( - index: Int, imageURL: URL?, - loadingState: LoadingState, - isDualPage: Bool, - backgroundColor: Color, - enablesLiveText: Bool, - liveTextGroups: [LiveTextGroup], - focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - refetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, - loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void + isScrollEnabled: Bool, + page: Page, + data: [Int], + spacing: CGFloat, + gestureCoordinator: GestureCoordinator, + pageCoordinator: PageCoordinator, + setting: Setting, + onTogglePanel: @escaping () -> Void, + @ViewBuilder content: @escaping (Int) -> Content ) { - self.index = index - self.imageURL = imageURL - self.loadingState = loadingState - self.isDualPage = isDualPage - self.backgroundColor = backgroundColor - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.refetchAction = refetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - } - - private func placeholder(_ progress: Progress) -> some View { - Placeholder(style: .progress( - pageNumber: index, progress: progress, - isDualPage: isDualPage, backgroundColor: backgroundColor - )) - .frame(width: width, height: height) - } - @ViewBuilder private func image(url: URL?) -> some View { - if url?.isGIF != true { - KFImage(url) - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) - } else { - KFAnimatedImage(url) - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) - } + self.isScrollEnabled = isScrollEnabled + self.page = page + self.data = data + self.spacing = spacing + self.gestureCoordinator = gestureCoordinator + self.pageCoordinator = pageCoordinator + self.setting = setting + self.onTogglePanel = onTogglePanel + self.content = content } var body: some View { - if loadingState == .idle { - image(url: imageURL).scaledToFit().overlay( - LiveTextView( - liveTextGroups: liveTextGroups, - focusedLiveTextGroup: focusedLiveTextGroup, - tapAction: liveTextTapAction - ) - .opacity(enablesLiveText ? 1 : 0) + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: spacing) { + ForEach(data, id: \.self) { index in + content(index) + .id(index + 1) // Use 1-based indexing for scroll target + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: [index: ScrollOffsetData( + index: index, + frame: geometry.frame(in: .named("ScrollView")) + )] + ) + } + ) + } + } + .onAppear { + scrollToCurrentPage(proxy: proxy) + } + } + // Fixed scrollDisabled implementation for iOS 26 + .scrollDisabled(!isScrollEnabled) + .coordinateSpace(name: "ScrollView") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { preferences in + updateCurrentVisibleIndex(from: preferences) + } + .readingGestures( + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + page: page, + onTogglePanel: onTogglePanel ) - } else { - ZStack { - backgroundColor - VStack { - Text(String(index)).font(.largeTitle.bold()) - .foregroundColor(.gray).padding(.bottom, 30) - ZStack { - Button(action: reloadImage) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + .onChange(of: page.index) { _, newValue in + scrollToPage(newValue, proxy: proxy) + } + .onChange(of: isScrollEnabled) { _, newValue in + // Re-enable/disable scrolling based on zoom level + if newValue && scrollTarget != nil { + if let target = scrollTarget { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(target, anchor: .center) } - .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) - .opacity(loadingState == .loading ? 0 : 1) - ProgressView().opacity(loadingState == .loading ? 1 : 0) + scrollTarget = nil } } } - .frame(width: width, height: height) } } - private func reloadImage() { - if let error = loadingState.failed { - if case .webImageFailed = error { - loadRetryAction(index) - } else { - refetchAction(index) + + private func updateCurrentVisibleIndex(from preferences: [Int: ScrollOffsetData]) { + guard !performingChanges else { return } + + // Find the most visible item (closest to center of screen) + let screenCenter = UIScreen.main.bounds.height / 2 + var mostVisibleIndex = 0 + var maxVisibility: CGFloat = 0 + + for (_, item) in preferences { + let itemCenter = item.frame.midY + let distanceFromCenter = abs(itemCenter - screenCenter) + let visibility = max(0, 1 - distanceFromCenter / screenCenter) + + if visibility > maxVisibility { + maxVisibility = visibility + mostVisibleIndex = item.index } } + + // Update page index if it changed significantly + if mostVisibleIndex != currentVisibleIndex && maxVisibility > 0.5 { + currentVisibleIndex = mostVisibleIndex + let newPageIndex = mostVisibleIndex + if page.index != newPageIndex { + performingChanges = true + page.update(.new(index: newPageIndex)) + + // Reset performing changes after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + performingChanges = false + } + + Logger.info("Updated page index from scroll", context: [ + "newPageIndex": newPageIndex, + "visibility": maxVisibility + ]) + } + } + } + + private func handleTap(index: Int) { + performingChanges = true + page.update(.new(index: index - 1)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + performingChanges = false + } } - private func onSuccess(_: RetrieveImageResult) { - loadSucceededAction(index) + + private func scrollToCurrentPage(proxy: ScrollViewProxy) { + let targetId = page.index + 1 + DispatchQueue.main.async { + proxy.scrollTo(targetId, anchor: .center) + } } - private func onFailure(_: KingfisherError) { - if imageURL != nil { - loadFailedAction(index) + + private func scrollToPage(_ pageIndex: Int, proxy: ScrollViewProxy) { + guard !performingChanges else { return } + + let targetId = pageIndex + 1 + if isScrollEnabled { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(targetId, anchor: .center) + } + } else { + // Store target for when scrolling is re-enabled + scrollTarget = targetId } } } -// MARK: Definition -struct ImageStackConfig { - let firstIndex: Int - let secondIndex: Int - let isFirstAvailable: Bool - let isSecondAvailable: Bool +// MARK: - Scroll Position Tracking +private struct ScrollOffsetData: Equatable { + let index: Int + let frame: CGRect } -enum AutoPlayPolicy: Int, CaseIterable, Identifiable { - var id: Int { rawValue } - - case off = -1 - case sec1 = 1 - case sec2 = 2 - case sec3 = 3 - case sec4 = 4 - case sec5 = 5 +private struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: [Int: ScrollOffsetData] = [:] + + static func reduce(value: inout [Int: ScrollOffsetData], nextValue: () -> [Int: ScrollOffsetData]) { + value.merge(nextValue()) { _, new in new } + } } -extension AutoPlayPolicy { - var value: String { - switch self { - case .off: - return L10n.Localizable.Enum.AutoPlayPolicy.Value.off - default: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") - } +// MARK: - Reading Controls Overlay +private struct ReadingControlsOverlay: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var pageCoordinator: PageCoordinator + @ObservedObject var gestureCoordinator: GestureCoordinator + let page: Page + + var body: some View { + ReadingControlPanel( + showsPanel: Binding( + get: { store.showsPanel }, + set: { store.send(.binding(.set(\.showsPanel, $0))) } + ), + showsSliderPreview: Binding( + get: { store.showsSliderPreview }, + set: { store.send(.binding(.set(\.showsSliderPreview, $0))) } + ), + sliderValue: $pageCoordinator.sliderValue, + setting: $setting, + enablesLiveText: $viewModel.enablesLiveText, + autoPlayPolicy: .init( + get: { viewModel.autoPlayPolicy }, + set: { viewModel.setAutoPlayPolicy($0, pageUpdater: { + page.update(.next) + }) } + ), + range: 1...Float(store.gallery.pageCount), + previewURLs: store.previewURLs, + dismissGesture: createDismissGesture(), + dismissAction: { store.send(.onPerformDismiss) }, + navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, + reloadAllImagesAction: { store.send(.reloadAllWebImages) }, + retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, + fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } + ) + } + + private func createDismissGesture() -> some Gesture { + DragGesture() + .onEnded { value in + gestureCoordinator.handleControlPanelDismiss( + value: value, + dismissAction: { store.send(.onPerformDismiss) } + ) + } } } +// MARK: - Preview struct ReadingView_Previews: PreviewProvider { static var previews: some View { NavigationView { Text("") .fullScreenCover(isPresented: .constant(true)) { ReadingView( - store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), + store: .init( + initialState: .init(gallery: .empty), + reducer: ReadingReducer.init + ), gid: .init(), setting: .constant(.init()), blurRadius: 0 diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift index 4adb6f52..c2aec933 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -3,25 +3,35 @@ // EhPanda // // Created by 荒木辰造 on R 3/07/30. +// Improved architecture by zackie on 2025-07-28. // import SwiftUI import SwiftUIPager +/// Improved vertical list for reading view with iOS 26 scrolling fix struct AdvancedList: View where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { - @State var performingChanges = false - + + // MARK: - State + @State private var performingChanges = false + @State private var scrollTarget: Element? + + // MARK: - Properties private let pagerModel: Page private let data: [Element] private let id: KeyPath private let spacing: CGFloat private let gesture: G private let content: (Element) -> PageView - + + // MARK: - Initialization init( - page: Page, data: Data, - id: KeyPath, spacing: CGFloat, gesture: G, + page: Page, + data: Data, + id: KeyPath, + spacing: CGFloat, + gesture: G, @ViewBuilder content: @escaping (Element) -> PageView ) where Data.Index == Int, Data.Element == Element { self.pagerModel = page @@ -31,43 +41,172 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { self.gesture = gesture self.content = content } - + + // MARK: - Body var body: some View { ScrollViewReader { proxy in - ScrollView(showsIndicators: false) { + ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: spacing) { - ForEach(data, id: id) { index in - let longPress = longPressGesture(index: index) - let gestures = longPress.simultaneously(with: gesture) - content(index).gesture(gestures) + ForEach(data, id: id) { element in + contentWithGestures(for: element) + .id(element[keyPath: id]) } } - .onAppear { tryScrollTo(id: pagerModel.index + 1, proxy: proxy) } + .onAppear { + initialScrollToPage(proxy: proxy) + } } + // iOS 26 compatible scroll handling + .coordinateSpace(name: "ScrollView") .onChange(of: pagerModel.index) { _, newValue in - tryScrollTo(id: newValue + 1, proxy: proxy) + handlePageChange(newValue: newValue, proxy: proxy) + } + .onChange(of: scrollTarget) { _, newValue in + if let target = newValue { + scrollToTarget(target, proxy: proxy) + } } } } - - private func longPressGesture(index: Element) -> some Gesture { + + // MARK: - Content with Gestures + @ViewBuilder + private func contentWithGestures(for element: Element) -> some View { + let longPress = createLongPressGesture(for: element) + let combinedGestures = longPress.simultaneously(with: gesture) + + content(element) + .gesture(combinedGestures) + } + + // MARK: - Gesture Creation + private func createLongPressGesture(for element: Element) -> some Gesture { LongPressGesture(minimumDuration: 0, maximumDistance: .infinity) .onEnded { _ in - if let index = index as? Int { - performingChanges = true - pagerModel.update(.new(index: index - 1)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - performingChanges = false + handleLongPress(for: element) + } + } + + // MARK: - Event Handlers + private func handleLongPress(for element: Element) { + guard let index = element as? Int else { return } + + Logger.info("Long press detected", context: ["element": index]) + + performingChanges = true + pagerModel.update(.new(index: index - 1)) + + // Reset performing changes after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + performingChanges = false + } + } + + private func initialScrollToPage(proxy: ScrollViewProxy) { + guard !data.isEmpty else { return } + + let targetElement = getElementForPageIndex(pagerModel.index) + scrollToElementSafely(targetElement, proxy: proxy, animated: false) + } + + private func handlePageChange(newValue: Int, proxy: ScrollViewProxy) { + guard !performingChanges else { return } + + Logger.info("Page changed in AdvancedList", context: [ + "newPageIndex": newValue, + "dataCount": data.count + ]) + + let targetElement = getElementForPageIndex(newValue) + scrollToElementSafely(targetElement, proxy: proxy, animated: true) + } + + private func scrollToTarget(_ target: Element, proxy: ScrollViewProxy) { + scrollToElementSafely(target, proxy: proxy, animated: true) + scrollTarget = nil + } + + // MARK: - Helper Methods + private func getElementForPageIndex(_ pageIndex: Int) -> Element? { + let safeIndex = max(0, min(pageIndex, data.count - 1)) + guard safeIndex < data.count else { return nil } + return data[safeIndex] + } + + private func scrollToElementSafely( + _ element: Element?, + proxy: ScrollViewProxy, + animated: Bool + ) { + guard let element = element else { return } + + let elementId = element[keyPath: id] + + if animated { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(elementId, anchor: .center) + } + } else { + // Use dispatchMainSync for immediate scrolling without animation + AppUtil.dispatchMainSync { + proxy.scrollTo(elementId, anchor: .center) + } + } + + Logger.info("Scrolled to element", context: [ + "elementId": "\(elementId)", + "animated": animated + ]) + } +} + +// MARK: - iOS 26 Compatibility Extensions + +extension AdvancedList { + /// Creates a version with enhanced scroll compatibility + func withEnhancedScrolling() -> some View { + self + .scrollContentBackground(.hidden) + .scrollIndicators(.hidden) + } + + /// Handles scroll position restoration for iOS 26 + func withScrollRestoration() -> some View { + self + .onAppear { + // Ensure proper scroll position on appear + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if let currentElement = getElementForPageIndex(pagerModel.index) { + scrollTarget = currentElement } } } } +} - private func tryScrollTo(id: Int, proxy: ScrollViewProxy) { - if !performingChanges { - AppUtil.dispatchMainSync { - proxy.scrollTo(id, anchor: .center) - } +// MARK: - Preview +struct AdvancedList_Previews: PreviewProvider { + static var previews: some View { + let page = Page.first() + let sampleData = Array(1...10) + + AdvancedList( + page: page, + data: sampleData, + id: \.self, + spacing: 10, + gesture: TapGesture() + ) { item in + Rectangle() + .fill(Color.blue.opacity(0.3)) + .frame(height: 200) + .overlay( + Text("\(item)") + .font(.title) + .foregroundColor(.primary) + ) } + .previewLayout(.sizeThatFits) } } + diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift deleted file mode 100644 index c45f6027..00000000 --- a/EhPanda/View/Reading/Support/AutoPlayHandler.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AutoPlayHandler.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/02/09. -// - -import SwiftUI - -final class AutoPlayHandler: ObservableObject { - @Published var policy: AutoPlayPolicy = .off - private var timer: Timer? - - deinit { - invalidate() - } - - func invalidate() { - Logger.info("invalidate") - timer?.invalidate() - } - - func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @escaping () -> Void) { - Logger.info("setPolicy", context: ["policy": policy]) - self.policy = policy - timer?.invalidate() - let timeInterval = TimeInterval(policy.rawValue) - if timeInterval > 0 { - timer = .scheduledTimer( - withTimeInterval: timeInterval, repeats: true, - block: { _ in updatePageAction() } - ) - } - } -} diff --git a/EhPanda/View/Reading/Support/GestureCoordinator.swift b/EhPanda/View/Reading/Support/GestureCoordinator.swift new file mode 100644 index 00000000..521c5603 --- /dev/null +++ b/EhPanda/View/Reading/Support/GestureCoordinator.swift @@ -0,0 +1,390 @@ +// +// GestureCoordinator.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import SwiftUIPager + +// MARK: - Gesture Coordinator +final class GestureCoordinator: ObservableObject { + // MARK: - Published Properties + @Published var scaleAnchor: UnitPoint = .center + @Published var scale: Double = 1.0 + @Published var offset: CGSize = .zero + @Published var dragStartOffset: CGSize = .zero + + // MARK: - Private Properties + private var baseScale: Double = 1.0 + private var baseOffset: CGSize = .zero + private var currentPanOffset: CGSize = .zero + private var setting: Setting = .init() + + // MARK: - Configuration + private var gestureConfig: GestureConfiguration = .init() + + // MARK: - Setup + func setup(setting: Setting) { + self.setting = setting + gestureConfig = GestureConfiguration(setting: setting) + } + + func cleanup() { + resetToDefaults() + } + + private func resetToDefaults() { + scale = 1.0 + offset = .zero + scaleAnchor = .center + baseScale = 1.0 + baseOffset = .zero + } + + // MARK: - Gesture Handlers + + /// Handles single tap gestures for page navigation or panel toggling + func handleSingleTap( + readingDirection: ReadingDirection, + onPageNavigation: @escaping (Int) -> Void, + onTogglePanel: @escaping () -> Void + ) { + Logger.info("Handle single tap", context: ["readingDirection": readingDirection]) + + // For vertical reading, always toggle panel + guard readingDirection != .vertical, + let touchPoint = TouchHandler.shared.currentPoint + else { + onTogglePanel() + return + } + + let tapRegion = determineTapRegion(point: touchPoint) + handleTapRegion(tapRegion, readingDirection: readingDirection, onPageNavigation: onPageNavigation, onTogglePanel: onTogglePanel) + } + + /// Handles double tap gestures for zoom + func handleDoubleTap() { + Logger.info("Handle double tap", context: [ + "currentScale": scale, + "doubleTapScale": setting.doubleTapScaleFactor + ]) + + let targetScale = scale == 1.0 ? setting.doubleTapScaleFactor : 1.0 + + if let touchPoint = TouchHandler.shared.currentPoint { + updateScaleAnchor(for: touchPoint) + } + + withAnimation(.easeInOut(duration: 0.25)) { + scale = targetScale + if targetScale == 1.0 { + offset = .zero + scaleAnchor = .center + } + } + + baseScale = scale + baseOffset = offset + } + + /// Handles magnification (pinch) gestures + func handleMagnificationChanged(value: Double) { + Logger.info("Handle magnification changed", context: ["value": value]) + + if value == 1.0 { + baseScale = scale + } + + if let touchPoint = TouchHandler.shared.currentPoint { + updateScaleAnchor(for: touchPoint) + } + + let newScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) + scale = newScale + constrainOffset() + } + + func handleMagnificationEnded(value: Double) { + Logger.info("Handle magnification ended", context: ["value": value]) + + let finalScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) + + // Snap to 1.0 if very close + if abs(finalScale - 1.0) < 0.05 { + withAnimation(.easeOut(duration: 0.2)) { + scale = 1.0 + offset = .zero + scaleAnchor = .center + } + } else { + scale = finalScale + constrainOffset() + } + + baseScale = scale + baseOffset = offset + } + + /// Handles drag gestures for panning when zoomed + func handleDragChanged(value: DragGesture.Value) { + guard scale > 1.0 else { return } + + Logger.info("Handle drag changed", context: [ + "translation": value.translation, + "scale": scale, + "currentPanOffset": currentPanOffset + ]) + + // Add high sensitivity multiplier for more responsive movement + let sensitivity: CGFloat = 2.0 + let adjustedTranslation = CGSize( + width: value.translation.width * sensitivity, + height: value.translation.height * sensitivity + ) + + // Update current pan offset + currentPanOffset = adjustedTranslation + + // Calculate total offset (base + current pan) + let totalOffset = CGSize( + width: baseOffset.width + currentPanOffset.width, + height: baseOffset.height + currentPanOffset.height + ) + + // Temporarily remove constraints for testing + offset = totalOffset + + Logger.info("Offset updated", context: [ + "adjustedTranslation": adjustedTranslation, + "currentPanOffset": currentPanOffset, + "totalOffset": totalOffset, + "offset": offset + ]) + } + + func handleDragStarted() { + guard scale > 1.0 else { return } + Logger.info("Handle drag started") + currentPanOffset = .zero + } + + func handleDragEnded(value: DragGesture.Value) { + guard scale > 1.0 else { return } + Logger.info("Handle drag ended") + + // Update base offset with final position + baseOffset = offset + currentPanOffset = .zero + } + + /// Handles control panel dismiss gesture + func handleControlPanelDismiss(value: DragGesture.Value, dismissAction: @escaping () -> Void) { + Logger.info("Handle control panel dismiss", context: ["translation": value.translation]) + + if value.predictedEndTranslation.height > 30 { + dismissAction() + } + } + + // MARK: - Private Helper Methods + + private func determineTapRegion(point: CGPoint) -> TapRegion { + let screenWidth = DeviceUtil.absWindowW + let leftThreshold = screenWidth * 0.2 + let rightThreshold = screenWidth * 0.8 + + if point.x < leftThreshold { + return .left + } else if point.x > rightThreshold { + return .right + } else { + return .center + } + } + + private func handleTapRegion( + _ region: TapRegion, + readingDirection: ReadingDirection, + onPageNavigation: @escaping (Int) -> Void, + onTogglePanel: @escaping () -> Void + ) { + let isRightToLeft = readingDirection == .rightToLeft + + switch region { + case .left: + onPageNavigation(isRightToLeft ? 1 : -1) + case .right: + onPageNavigation(isRightToLeft ? -1 : 1) + case .center: + onTogglePanel() + } + } + + private func updateScaleAnchor(for point: CGPoint) { + let normalizedX = min(1, max(0, point.x / DeviceUtil.absWindowW)) + let normalizedY = min(1, max(0, point.y / DeviceUtil.absWindowH)) + scaleAnchor = UnitPoint(x: normalizedX, y: normalizedY) + } + + @discardableResult + private func constrainOffset(_ newOffset: CGSize? = nil) -> CGSize { + let targetOffset = newOffset ?? offset + + let constrainedWidth = constrainOffsetDimension( + value: targetOffset.width, + anchor: scaleAnchor.x, + screenSize: DeviceUtil.absWindowW + ) + + let constrainedHeight = constrainOffsetDimension( + value: targetOffset.height, + anchor: scaleAnchor.y, + screenSize: DeviceUtil.absWindowH + ) + + let constrained = CGSize(width: constrainedWidth, height: constrainedHeight) + + if newOffset == nil { + offset = constrained + } + + return constrained + } + + private func constrainOffsetDimension( + value: Double, + anchor: Double, + screenSize: Double + ) -> Double { + let margin = screenSize * (scale - 1) / 2 + let leadingMargin = (anchor / 0.5) * margin + let trailingMargin = ((1 - anchor) / 0.5) * margin + + return min(max(value, -trailingMargin), leadingMargin) + } + + private func constrainOffsetSimple(_ newOffset: CGSize) -> CGSize { + let screenWidth = DeviceUtil.absWindowW + let screenHeight = DeviceUtil.absWindowH + + // Calculate maximum allowed offset based on zoom level with more flexibility + let maxOffsetX = screenWidth * (scale - 1) * 0.8 // Allow 80% of theoretical max + let maxOffsetY = screenHeight * (scale - 1) * 0.8 + + // Apply bounds with more flexibility for natural panning + let constrainedWidth = min(max(newOffset.width, -maxOffsetX), maxOffsetX) + let constrainedHeight = min(max(newOffset.height, -maxOffsetY), maxOffsetY) + + return CGSize(width: constrainedWidth, height: constrainedHeight) + } +} + +// MARK: - Supporting Types + +private enum TapRegion { + case left, center, right +} + +private struct GestureConfiguration { + let tapRegionThreshold: Double + let snapToOneThreshold: Double + let panVelocityThreshold: Double + + init(setting: Setting? = nil) { + self.tapRegionThreshold = 0.2 + self.snapToOneThreshold = 0.05 + self.panVelocityThreshold = 100.0 + } +} + +// MARK: - View Extensions for Gesture Support + +extension View { + func readingGestures( + gestureCoordinator: GestureCoordinator, + pageCoordinator: PageCoordinator, + setting: Setting, + page: Page, + onTogglePanel: @escaping () -> Void + ) -> some View { + let tapGesture = createTapGesture( + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + page: page, + onTogglePanel: onTogglePanel + ) + + let magnificationGesture = createMagnificationGesture( + gestureCoordinator: gestureCoordinator + ) + + let dragGesture = createDragGesture( + gestureCoordinator: gestureCoordinator + ) + + return self + .gesture(dragGesture, isEnabled: gestureCoordinator.scale > 1) + .simultaneousGesture( + tapGesture, + isEnabled: gestureCoordinator.scale > 1 + ) + .gesture(tapGesture, isEnabled: gestureCoordinator.scale == 1) + .gesture(magnificationGesture) + } + + private func createTapGesture( + gestureCoordinator: GestureCoordinator, + pageCoordinator: PageCoordinator, + setting: Setting, + page: Page, + onTogglePanel: @escaping () -> Void + ) -> some Gesture { + let singleTap = TapGesture(count: 1) + .onEnded { + gestureCoordinator.handleSingleTap( + readingDirection: setting.readingDirection, + onPageNavigation: { offset in + let newIndex = page.index + offset + page.update(.new(index: newIndex)) + Logger.info("Page navigation", context: ["newIndex": newIndex]) + }, + onTogglePanel: onTogglePanel + ) + } + + let doubleTap = TapGesture(count: 2) + .onEnded { + gestureCoordinator.handleDoubleTap() + } + + return ExclusiveGesture(doubleTap, singleTap) + } + + private func createMagnificationGesture( + gestureCoordinator: GestureCoordinator + ) -> some Gesture { + MagnificationGesture() + .onChanged { value in + gestureCoordinator.handleMagnificationChanged(value: value) + } + .onEnded { value in + gestureCoordinator.handleMagnificationEnded(value: value) + } + } + + private func createDragGesture( + gestureCoordinator: GestureCoordinator + ) -> some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + gestureCoordinator.handleDragChanged(value: value) + } + .onEnded { value in + gestureCoordinator.handleDragEnded(value: value) + } + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/EhPanda/View/Reading/Support/GestureHandler.swift deleted file mode 100644 index af9d5916..00000000 --- a/EhPanda/View/Reading/Support/GestureHandler.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// GestureHandler.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/02/09. -// - -import SwiftUI - -final class GestureHandler: ObservableObject { - @Published var scaleAnchor: UnitPoint = .center - @Published var scale: Double = 1 - @Published var offset: CGSize = .zero - @Published private var baseScale: Double = 1 - @Published private var newOffset: CGSize = .zero - - private func edgeWidth(x: Double) -> Double { - let marginW = DeviceUtil.absWindowW * (scale - 1) / 2 - let leadingMargin = scaleAnchor.x / 0.5 * marginW - let trailingMargin = (1 - scaleAnchor.x) / 0.5 * marginW - return min(max(x, -trailingMargin), leadingMargin) - } - private func edgeHeight(y: Double) -> Double { - let marginH = DeviceUtil.absWindowH * (scale - 1) / 2 - let topMargin = scaleAnchor.y / 0.5 * marginH - let bottomMargin = (1 - scaleAnchor.y) / 0.5 * marginH - return min(max(y, -bottomMargin), topMargin) - } - private func correctOffset() { - offset.width = edgeWidth(x: offset.width) - offset.height = edgeHeight(y: offset.height) - } - private func correctScaleAnchor(point: CGPoint) { - let x = min(1, max(0, point.x / DeviceUtil.absWindowW)) - let y = min(1, max(0, point.y / DeviceUtil.absWindowH)) - scaleAnchor = .init(x: x, y: y) - } - private func setOffset(_ offset: CGSize) { - self.offset = offset - correctOffset() - } - private func setScale(scale: Double, maximum: Double) { - guard scale >= 1 && scale <= maximum else { return } - self.scale = scale - correctOffset() - } - - func onSingleTapGestureEnded( - readingDirection: ReadingDirection, - setPageIndexOffsetAction: @escaping (Int) -> Void, - toggleShowsPanelAction: @escaping () -> Void - ) { - Logger.info("onSingleTapGestureEnded", context: ["readingDirection": readingDirection]) - guard readingDirection != .vertical, - let pointX = TouchHandler.shared.currentPoint?.x - else { - toggleShowsPanelAction() - return - } - let rightToLeft = readingDirection == .rightToLeft - if pointX < DeviceUtil.absWindowW * 0.2 { - setPageIndexOffsetAction(rightToLeft ? 1 : -1) - } else if pointX > DeviceUtil.absWindowW * (1 - 0.2) { - setPageIndexOffsetAction(rightToLeft ? -1 : 1) - } else { - toggleShowsPanelAction() - } - } - - func onDoubleTapGestureEnded(scaleMaximum: Double, doubleTapScale: Double) { - Logger.info("onDoubleTapGestureEnded", context: [ - "scaleMaximum": scaleMaximum, "doubleTapScale": doubleTapScale - ]) - let newScale = scale == 1 ? doubleTapScale : 1 - if let point = TouchHandler.shared.currentPoint { - correctScaleAnchor(point: point) - } - setOffset(.zero) - setScale(scale: newScale, maximum: scaleMaximum) - } - - func onMagnificationGestureChanged(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureChanged", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) - if value == 1 { - baseScale = scale - } - if let point = TouchHandler.shared.currentPoint { - correctScaleAnchor(point: point) - } - setScale(scale: value * baseScale, maximum: scaleMaximum) - } - - func onMagnificationGestureEnded(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureEnded", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) - onMagnificationGestureChanged(value: value, scaleMaximum: scaleMaximum) - if value * baseScale - 1 < 0.01 { - setScale(scale: 1, maximum: scaleMaximum) - } - baseScale = scale - } - - func onDragGestureChanged(value: DragGesture.Value) { - Logger.info("onDragGestureChanged", context: ["value": value]) - guard scale > 1 else { return } - let newX = value.translation.width + newOffset.width - let newY = value.translation.height + newOffset.height - let newOffsetW = edgeWidth(x: newX) - let newOffsetH = edgeHeight(y: newY) - setOffset(.init(width: newOffsetW, height: newOffsetH)) - } - - func onDragGestureEnded(value: DragGesture.Value) { - Logger.info("onDragGestureEnded", context: ["value": value]) - onDragGestureChanged(value: value) - if scale > 1 { - newOffset.width = offset.width - newOffset.height = offset.height - } - } - - func onControlPanelDismissGestureEnded(value: DragGesture.Value, dismissAction: @escaping () -> Void) { - Logger.info("onControlPanelDismissGestureEnded", context: ["value": value]) - if value.predictedEndTranslation.height > 30 { - dismissAction() - } - } -} diff --git a/EhPanda/View/Reading/Support/ImageStackView.swift b/EhPanda/View/Reading/Support/ImageStackView.swift new file mode 100644 index 00000000..97f0f7b3 --- /dev/null +++ b/EhPanda/View/Reading/Support/ImageStackView.swift @@ -0,0 +1,349 @@ +// +// ImageStackView.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import Kingfisher +import ComposableArchitecture + +// MARK: - Image Stack View +struct ImageStackView: View { + // MARK: - Properties + private let index: Int + private let store: StoreOf + @Binding private var setting: Setting + @ObservedObject private var viewModel: ReadingViewModel + @ObservedObject private var gestureCoordinator: GestureCoordinator + + // MARK: - Computed Properties + private var isDualPage: Bool { + setting.enablesDualPageMode && + setting.readingDirection != .vertical && + DeviceUtil.isLandscape + } + + private var backgroundColor: Color { + Color(.systemGray4) // This should match the main view's background + } + + private var imageStackConfig: ImageStackConfig { + let dualPageConfig = pageCoordinator.getDualPageConfiguration( + for: index, + setting: setting + ) + return ImageStackConfig(from: dualPageConfig) + } + + // MARK: - Dependencies + private var pageCoordinator: PageCoordinator { + // This would ideally be injected, but for now we create a temporary one + let coordinator = PageCoordinator() + coordinator.setup(pageCount: store.gallery.pageCount, setting: setting) + return coordinator + } + + // MARK: - Initialization + init( + index: Int, + store: StoreOf, + setting: Binding, + viewModel: ReadingViewModel, + gestureCoordinator: GestureCoordinator + ) { + self.index = index + self.store = store + _setting = setting + self.viewModel = viewModel + self.gestureCoordinator = gestureCoordinator + } + + // MARK: - Body + var body: some View { + HStack(spacing: 0) { + if imageStackConfig.isFirstAvailable { + ImageContainerView( + index: imageStackConfig.firstIndex, + store: store, + setting: $setting, + viewModel: viewModel, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + } + + if imageStackConfig.isSecondAvailable { + ImageContainerView( + index: imageStackConfig.secondIndex, + store: store, + setting: $setting, + viewModel: viewModel, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + } + } + } +} + +// MARK: - Image Container View +private struct ImageContainerView: View { + // MARK: - Properties + private let index: Int + private let store: StoreOf + @Binding private var setting: Setting + @ObservedObject private var viewModel: ReadingViewModel + private let isDualPage: Bool + private let backgroundColor: Color + + // MARK: - Computed Properties + private var imageURL: URL? { + store.imageURLs[index] + } + + private var originalImageURL: URL? { + store.originalImageURLs[index] + } + + private var loadingState: LoadingState { + store.imageURLLoadingStates[index] ?? .idle + } + + private var liveTextGroups: [LiveTextGroup] { + viewModel.liveTextGroups[index] ?? [] + } + + private var containerSize: CGSize { + let width = DeviceUtil.windowW / (isDualPage ? 2 : 1) + let height = width / Defaults.ImageSize.contentAspect + return CGSize(width: width, height: height) + } + + // MARK: - Initialization + init( + index: Int, + store: StoreOf, + setting: Binding, + viewModel: ReadingViewModel, + isDualPage: Bool, + backgroundColor: Color + ) { + self.index = index + self.store = store + _setting = setting + self.viewModel = viewModel + self.isDualPage = isDualPage + self.backgroundColor = backgroundColor + } + + // MARK: - Body + var body: some View { + Group { + if loadingState == .idle { + successView + } else { + loadingOrErrorView + } + } + .onAppear { + handleAppear() + } + .contextMenu { + contextMenuItems + } + } + + // MARK: - Success View + private var successView: some View { + ZStack { + imageView + .scaledToFit() + .overlay( + LiveTextView( + liveTextGroups: liveTextGroups, + focusedLiveTextGroup: viewModel.focusedLiveTextGroup, + tapAction: viewModel.setFocusedLiveTextGroup + ) + .opacity(viewModel.enablesLiveText ? 1 : 0) + ) + } + } + + // MARK: - Image View + @ViewBuilder + private var imageView: some View { + if let url = imageURL { + if url.isGIF { + KFAnimatedImage(url) + .placeholder { placeholderView() } + .fade(duration: 0.25) + .onSuccess { _ in handleImageSuccess() } + .onFailure { _ in handleImageFailure() } + } else { + KFImage(url) + .placeholder { placeholderView() } + .defaultModifier(withRoundedCorners: false) + .onSuccess { _ in handleImageSuccess() } + .onFailure { _ in handleImageFailure() } + } + } else { + placeholderView(Progress()) + } + } + + // MARK: - Placeholder View + private func placeholderView(_ progress: Progress = Progress()) -> some View { + Placeholder( + style: .progress( + pageNumber: index, + progress: progress, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + ) + .frame(width: containerSize.width, height: containerSize.height) + } + + // MARK: - Loading/Error View + private var loadingOrErrorView: some View { + ZStack { + backgroundColor + + VStack(spacing: 30) { + Text("\(index)") + .font(.largeTitle.bold()) + .foregroundColor(.gray) + + ZStack { + if loadingState == .loading { + ProgressView() + } else { + Button(action: handleReloadTap) { + Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + .font(.system(size: 30, weight: .medium)) + .foregroundColor(.gray) + } + } + } + } + } + .frame(width: containerSize.width, height: containerSize.height) + } + + // MARK: - Context Menu + @ViewBuilder + private var contextMenuItems: some View { + Button(action: handleRefetch) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.reload, + systemSymbol: .arrowCounterclockwise + ) + } + + if let imageURL = imageURL { + Button(action: { handleCopyImage(imageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.copy, + systemSymbol: .plusSquareOnSquare + ) + } + + Button(action: { handleSaveImage(imageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.save, + systemSymbol: .squareAndArrowDown + ) + } + + if let originalImageURL = originalImageURL { + Button(action: { handleSaveImage(originalImageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, + systemSymbol: .squareAndArrowDownOnSquare + ) + } + } + + Button(action: { handleShareImage(imageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.share, + systemSymbol: .squareAndArrowUp + ) + } + } + } + + // MARK: - Event Handlers + private func handleAppear() { + let isDatabaseLoading = store.databaseLoadingState != .idle + + if !isDatabaseLoading { + if imageURL == nil { + store.send(.fetchImageURLs(index)) + } + store.send(.prefetchImages(index, setting.prefetchLimit)) + } + } + + private func handleImageSuccess() { + store.send(.onWebImageSucceeded(index)) + + if viewModel.enablesLiveText { + viewModel.analyzeImageForLiveText( + index: index, + imageURL: imageURL, + recognitionLanguages: store.galleryDetail?.language.codes + ) + } + } + + private func handleImageFailure() { + store.send(.onWebImageFailed(index)) + } + + private func handleReloadTap() { + if case .failed(let error) = loadingState { + if case .webImageFailed = error { + store.send(.onWebImageRetry(index)) + } else { + store.send(.refetchImageURLs(index)) + } + } + } + + private func handleRefetch() { + store.send(.refetchImageURLs(index)) + } + + private func handleCopyImage(_ url: URL) { + store.send(.copyImage(url)) + } + + private func handleSaveImage(_ url: URL) { + store.send(.saveImage(url)) + } + + private func handleShareImage(_ url: URL) { + store.send(.shareImage(url)) + } +} + +// MARK: - Preview +struct ImageStackView_Previews: PreviewProvider { + static var previews: some View { + ImageStackView( + index: 1, + store: .init( + initialState: .init(gallery: .empty), + reducer: ReadingReducer.init + ), + setting: .constant(.init()), + viewModel: ReadingViewModel(), + gestureCoordinator: GestureCoordinator() + ) + .previewLayout(.sizeThatFits) + .padding() + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift deleted file mode 100644 index 9a9451f4..00000000 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// LiveTextHandler.swift -// EhPanda -// -// Created by xioxin on 2022/2/12. -// -// swiftlint:disable line_length -// Refercence -// https://www.codeproject.com/Articles/15573/2D-Polygon-Collision-Detection -// https://developer.apple.com/documentation/vision/recognizing_text_in_images -// https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/Translate/Sources/Translate.swift -// https://github.com/TelegramMessenger/Telegram-iOS/blob/0be460b147321b7455247aedca81ca819702959d/submodules/ImageContentAnalysis/Sources/ImageContentAnalysis.swift -// swiftlint:enable line_length -// - -import Vision -import SwiftUI -import Foundation - -final class LiveTextHandler: ObservableObject { - @Published var enablesLiveText = false - @Published var liveTextGroups = [Int: [LiveTextGroup]]() - @Published private(set) var focusedLiveTextGroup: LiveTextGroup? - - private var processingRequests = [VNRequest]() - - deinit { - cancelRequests() - } - - func cancelRequests() { - Logger.info("cancelRequests", context: [ - "processingRequestsCount": processingRequests.count - ]) - processingRequests.forEach { request in - request.cancel() - } - } - - func setFocusedLiveTextGroup(_ group: LiveTextGroup) { - Logger.info("setFocusedLiveTextGroup", context: ["group": group]) - focusedLiveTextGroup = group - } - - func analyzeImage(_ cgImage: CGImage, size: CGSize, index: Int, recognitionLanguages: [String]?) { - Logger.info("analyzeImage", context: [ - "index": index, "recognitionLanguages": recognitionLanguages as Any - ]) - - let requestHandler = VNImageRequestHandler(cgImage: cgImage) - let textRecognitionRequest = VNRecognizeTextRequest { [weak self] in - self?.textRecognitionHandler(request: $0, error: $1, size: size, index: index) - } - textRecognitionRequest.usesLanguageCorrection = true - textRecognitionRequest.preferBackgroundProcessing = true - if let languages = recognitionLanguages { - textRecognitionRequest.recognitionLanguages = languages - } - - processingRequests.append(textRecognitionRequest) - DispatchQueue.global(qos: .utility).async { [weak self] in - guard let self = self else { return } - do { - try requestHandler.perform([textRecognitionRequest]) - } catch { - self.removeRequest(textRecognitionRequest) - Logger.info("Unable to perform the requests.", context: ["error": error]) - } - } - } - - private func removeRequest(_ request: VNRequest) { - if let index = processingRequests.firstIndex(of: request) { - processingRequests.remove(at: index) - } - } - - private func textRecognitionHandler(request: VNRequest, error: Error?, size: CGSize, index: Int) { - Logger.info("textRecognitionHandler", context: [ - "request": request, "error": error as Any, "index": index - ]) - removeRequest(request) - - guard let observations = request.results as? [VNRecognizedTextObservation] else { return } - - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - guard let self = self else { return } - let blocks: [LiveTextBlock] = observations.compactMap { observation in - guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } - return .init( - text: recognizedText, - bounds: .init( - topLeft: observation.topLeft.verticalReversed, - topRight: observation.topRight.verticalReversed, - bottomLeft: observation.bottomLeft.verticalReversed, - bottomRight: observation.bottomRight.verticalReversed - ) - ) - } - - var groupData = [[LiveTextBlock]]() - blocks.forEach { newItem in - if let groupIndex = groupData.firstIndex(where: { items in - items.first { item in - let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) - .truncatingRemainder(dividingBy: 360.0) - let isAngleValid = angle < 5 || angle > (360 - 5) - let aHeight = item.bounds.getHeight(size) - let bHeight = newItem.bounds.getHeight(size) - let isHeightValid = abs(aHeight - bHeight) < (min(aHeight, bHeight) / 2) - - guard isAngleValid && isHeightValid else { return false } - return self.polygonsIntersecting( - lhs: item.bounds.expandingHalfHeight(size).edges, - rhs: newItem.bounds.expandingHalfHeight(size).edges - ) - } != nil - }) { - groupData[groupIndex].append(newItem) - } else { - groupData.append([newItem]) - } - } - - let groups = groupData.compactMap(LiveTextGroup.init) - DispatchQueue.main.async { - self.liveTextGroups[index] = groups - } - } - } - - private func polygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { - guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } - for points in [lhs, rhs] { - for index1 in 0..() + + // MARK: - Configuration + private var pageConfig: PageConfiguration = .init() + + // MARK: - Initialization + init() { + setupObservers() + } + + deinit { + cleanup() + } + + // MARK: - Setup Methods + func setup(pageCount: Int, setting: Setting) { + self.pageCount = pageCount + self.setting = setting + self.pageConfig = PageConfiguration(setting: setting) + + Logger.info("Page coordinator setup", context: [ + "pageCount": pageCount, + "readingDirection": setting.readingDirection.rawValue + ]) + } + + func setup(pageCount: Int, setting: Setting, initialPage: Int) { + setup(pageCount: pageCount, setting: setting) + + // Initialize slider value with reading progress + let validProgress = max(1, min(initialPage, pageCount)) + sliderValue = Float(validProgress) + + Logger.info("Page coordinator setup with initial page", context: [ + "initialPage": initialPage, + "validProgress": validProgress + ]) + } + + private func setupObservers() { + // Observe slider value changes for page navigation + $sliderValue + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] newValue in + self?.handleSliderValueChange(newValue) + } + .store(in: &cancellables) + } + + func cleanup() { + cancellables.removeAll() + } + + // MARK: - Page Mapping Methods + + /// Maps from pager index to page number + func mapFromPager( + index: Int, + pageCount: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> Int { + Logger.info("Map from pager", context: [ + "index": index, + "pageCount": pageCount, + "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) + ]) + + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { + return index + 1 + } + + guard index > 0 else { return 1 } + + let result = setting.exceptCover ? index * 2 : index * 2 + 1 + + // Handle edge case for last page in dual mode + if result + 1 == pageCount { + return pageCount + } else { + return result + } + } + + /// Maps from page number to pager index + func mapToPager( + index: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> Int { + Logger.info("Map to pager", context: [ + "index": index, + "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) + ]) + + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { + return index - 1 + } + + guard index > 1 else { return 0 } + + return setting.exceptCover ? index / 2 : (index - 1) / 2 + } + + // MARK: - Page Navigation + + /// Updates the current page and synchronizes slider + func updateCurrentPage(_ pageIndex: Int) { + let clampedIndex = max(1, min(pageIndex, pageCount)) + sliderValue = Float(clampedIndex) + + Logger.info("Updated current page", context: [ + "pageIndex": pageIndex, + "clampedIndex": clampedIndex + ]) + } + + /// Handles page navigation with bounds checking + func navigatePage(offset: Int, currentIndex: Int) -> Int { + let newIndex = currentIndex + offset + let clampedIndex = max(0, min(newIndex, pageCount - 1)) + + Logger.info("Navigate page", context: [ + "offset": offset, + "currentIndex": currentIndex, + "newIndex": newIndex, + "clampedIndex": clampedIndex + ]) + + return clampedIndex + } + + /// Gets valid page range for the current configuration + func getValidPageRange() -> ClosedRange { + return 1...pageCount + } + + /// Checks if a page index is valid + func isValidPageIndex(_ index: Int) -> Bool { + return index >= 1 && index <= pageCount + } + + // MARK: - Dual Page Support + + /// Determines if dual page mode should be active + func isDualPageMode(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Bool { + return isLandscape && + setting.enablesDualPageMode && + setting.readingDirection != .vertical + } + + /// Gets the page configuration for dual page mode + func getDualPageConfiguration( + for index: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> DualPageConfiguration { + let isDualPage = isDualPageMode(setting: setting, isLandscape: isLandscape) + let isReversed = setting.readingDirection == .rightToLeft + let isFirstSingle = setting.exceptCover + let isFirstPageAndSingle = index == 1 && isFirstSingle + + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index + let secondIndex = firstIndex + (isReversed ? -1 : 1) + + let isValidFirstRange = firstIndex >= 1 && firstIndex <= pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= pageCount + : secondIndex >= 1 && secondIndex <= pageCount + + return DualPageConfiguration( + firstIndex: firstIndex, + secondIndex: secondIndex, + isFirstAvailable: isValidFirstRange, + isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage, + isDualPage: isDualPage + ) + } + + // MARK: - Auto Play Support + + /// Gets the next page index for auto play + func getNextAutoPlayIndex(currentIndex: Int) -> Int? { + let nextIndex = currentIndex + 1 + guard nextIndex < pageCount else { return nil } + return nextIndex + } + + // MARK: - Private Methods + + private func handleSliderValueChange(_ newValue: Float) { + Logger.info("Handle slider value change", context: [ + "newValue": newValue, + "pageCount": pageCount + ]) + + // Validate slider value + let clampedValue = max(1, min(newValue, Float(pageCount))) + if clampedValue != newValue { + DispatchQueue.main.async { [weak self] in + self?.sliderValue = clampedValue + } + } + } +} + +// MARK: - Supporting Types + +/// Configuration for dual page display +struct DualPageConfiguration { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool + let isDualPage: Bool +} + +/// Configuration for page behavior +private struct PageConfiguration { + let enablesDualPage: Bool + let exceptCover: Bool + let readingDirection: ReadingDirection + + init(setting: Setting? = nil) { + self.enablesDualPage = setting?.enablesDualPageMode ?? false + self.exceptCover = setting?.exceptCover ?? false + self.readingDirection = setting?.readingDirection ?? .leftToRight + } +} + +// MARK: - Page Coordinator Extensions + +extension PageCoordinator { + /// Gets container data source for the current page configuration + func getContainerDataSource( + pageCount: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> [Int] { + let defaultData = Array(1...pageCount) + + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { + return defaultData + } + + let data = setting.exceptCover + ? [1] + Array(stride(from: 2, through: pageCount, by: 2)) + : Array(stride(from: 1, through: pageCount, by: 2)) + + Logger.info("Generated container data source", context: [ + "defaultCount": defaultData.count, + "dualPageCount": data.count, + "exceptCover": setting.exceptCover + ]) + + return data + } +} + +// MARK: - Image Stack Configuration + +/// Configuration for image stack display +struct ImageStackConfig { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool + + init(from dualPageConfig: DualPageConfiguration) { + self.firstIndex = dualPageConfig.firstIndex + self.secondIndex = dualPageConfig.secondIndex + self.isFirstAvailable = dualPageConfig.isFirstAvailable + self.isSecondAvailable = dualPageConfig.isSecondAvailable + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/PageHandler.swift b/EhPanda/View/Reading/Support/PageHandler.swift deleted file mode 100644 index 1b03eea6..00000000 --- a/EhPanda/View/Reading/Support/PageHandler.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// PageHandler.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/02/09. -// - -import SwiftUI - -final class PageHandler: ObservableObject { - @Published var sliderValue: Float = 1 { - didSet { - Logger.info("sliderValue.didSet", context: ["sliderValue": sliderValue]) - } - } - - func mapFromPager(index: Int, pageCount: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { - guard isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return index + 1 } - guard index > 0 else { return 1 } - - let result = setting.exceptCover ? index * 2 : index * 2 + 1 - - if result + 1 == pageCount { - return pageCount - } else { - return result - } - } - - func mapToPager(index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { - guard isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return index - 1 } - guard index > 1 else { return 0 } - - return setting.exceptCover ? index / 2 : (index - 1) / 2 - } -} diff --git a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift new file mode 100644 index 00000000..6c0adcbf --- /dev/null +++ b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift @@ -0,0 +1,445 @@ +// +// ReadingViewExtensions.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import SwiftUIPager +import ComposableArchitecture + +// MARK: - Auto Play Policy +enum AutoPlayPolicy: Int, CaseIterable, Identifiable { + var id: Int { rawValue } + + case off = -1 + case sec1 = 1 + case sec2 = 2 + case sec3 = 3 + case sec4 = 4 + case sec5 = 5 +} + +extension AutoPlayPolicy { + /// Human-readable value for the auto play policy + var value: String { + switch self { + case .off: + return L10n.Localizable.Enum.AutoPlayPolicy.Value.off + default: + return L10n.Localizable.Common.Value.seconds("\(rawValue)") + } + } + + /// Time interval for the timer (0 means disabled) + var timeInterval: TimeInterval { + return rawValue > 0 ? TimeInterval(rawValue) : 0 + } + + /// Whether auto play is enabled + var isEnabled: Bool { + return self != .off + } +} + +// MARK: - Reading View Modifiers + +extension View { + /// Applies all reading view modifiers including sheets, progress HUD, and animations + func readingViewModifiers( + store: StoreOf, + setting: Binding, + blurRadius: Double + ) -> some View { + self + .readingSheets(store: store, setting: setting, blurRadius: blurRadius) + .readingProgressHUD(store: store) + .readingAnimations() + .readingStatusBar(store: store) + } + + /// Applies reading-specific sheet presentations + private func readingSheets( + store: StoreOf, + setting: Binding, + blurRadius: Double + ) -> some View { + self + .sheet(item: Binding( + get: { store.route?.readingSetting }, + set: { _ in store.send(.setNavigation(nil)) } + )) { _ in + NavigationView { + ReadingSettingView( + readingDirection: setting.readingDirection, + prefetchLimit: setting.prefetchLimit, + enablesLandscape: setting.enablesLandscape, + contentDividerHeight: setting.contentDividerHeight, + maximumScaleFactor: setting.maximumScaleFactor, + doubleTapScaleFactor: setting.doubleTapScaleFactor + ) + .readingSettingToolbar { + store.send(.setNavigation(nil)) + } + } + .accentColor(setting.wrappedValue.accentColor) + .tint(setting.wrappedValue.accentColor) + .autoBlur(radius: blurRadius) + .navigationViewStyle(.stack) + } + .sheet(item: Binding( + get: { store.route?.share }, + set: { _ in store.send(.setNavigation(nil)) } + )) { shareItemBox in + ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) + .accentColor(setting.wrappedValue.accentColor) + .autoBlur(radius: blurRadius) + } + } + + /// Applies progress HUD for reading operations + private func readingProgressHUD(store: StoreOf) -> some View { + self.progressHUD( + config: store.hudConfig, + unwrapping: Binding( + get: { store.route }, + set: { store.send(.setNavigation($0)) } + ), + case: \.hud + ) + } + + /// Applies reading-specific animations + private func readingAnimations() -> some View { + self + .animation(.linear(duration: 0.1), value: UUID()) // Placeholder for gesture animations + .animation(.default, value: UUID()) // Placeholder for other animations + } + + /// Configures status bar visibility + private func readingStatusBar(store: StoreOf) -> some View { + self.statusBar(hidden: !store.showsPanel) + } +} + +// MARK: - Reading Setting Toolbar + +extension View { + func readingSettingToolbar(dismissAction: @escaping () -> Void) -> some View { + self.toolbar { + CustomToolbarItem(placement: .cancellationAction) { + if !DeviceUtil.isPad && DeviceUtil.isLandscape { + Button(action: dismissAction) { + Image(systemSymbol: .chevronDown) + } + } + } + } + } +} + +// MARK: - Reading Changes Observer + +extension View { + /// Observes reading-related changes and handles side effects + func observeReadingChanges( + store: StoreOf, + setting: Binding, + viewModel: ReadingViewModel, + pageCoordinator: PageCoordinator, + page: Page + ) -> some View { + self + .onChange(of: page.index) { _, newValue in + handlePageIndexChange( + newValue: newValue, + store: store, + setting: setting.wrappedValue, + pageCoordinator: pageCoordinator + ) + } + .onChange(of: pageCoordinator.sliderValue) { _, newValue in + handleSliderValueChange( + newValue: newValue, + store: store, + showsSliderPreview: store.showsSliderPreview, + page: page, + pageCoordinator: pageCoordinator, + setting: setting.wrappedValue + ) + } + .onChange(of: store.showsSliderPreview) { _, newValue in + handleSliderPreviewChange( + newValue: newValue, + pageCoordinator: pageCoordinator, + viewModel: viewModel, + page: page, + setting: setting.wrappedValue + ) + } + .onChange(of: store.readingProgress) { _, newValue in + handleReadingProgressChange( + newValue: newValue, + pageCoordinator: pageCoordinator, + page: page, + setting: setting.wrappedValue + ) + } + .onChange(of: store.route) { _, newValue in + handleRouteChange(newValue: newValue, viewModel: viewModel) + } + .onChange(of: viewModel.enablesLiveText) { _, newValue in + handleLiveTextToggle( + newValue: newValue, + store: store, + viewModel: viewModel + ) + } + .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in + handleImageLoadSuccess( + newValue: newValue, + viewModel: viewModel, + store: store + ) + } + .onChange(of: setting.wrappedValue.enablesLandscape) { _, newValue in + handleLandscapeSettingChange(newValue: newValue, store: store) + } + } + + private func handlePageIndexChange( + newValue: Int, + store: StoreOf, + setting: Setting, + pageCoordinator: PageCoordinator + ) { + Logger.info("Page index changed", context: ["pageIndex": newValue]) + + let mappedValue = pageCoordinator.mapFromPager( + index: newValue, + pageCount: store.gallery.pageCount, + setting: setting + ) + + pageCoordinator.sliderValue = Float(mappedValue) + + if store.databaseLoadingState == .idle { + store.send(.syncReadingProgress(mappedValue)) + } + } + + private func handleSliderValueChange( + newValue: Float, + store: StoreOf, + showsSliderPreview: Bool, + page: Page, + pageCoordinator: PageCoordinator, + setting: Setting + ) { + Logger.info("Slider value changed", context: ["sliderValue": newValue]) + + if !showsSliderPreview { + let pagerIndex = pageCoordinator.mapToPager(index: Int(newValue), setting: setting) + if page.index != pagerIndex { + page.update(.new(index: pagerIndex)) + Logger.info("Pager updated from slider", context: ["pagerIndex": pagerIndex]) + } + } + } + + private func handleSliderPreviewChange( + newValue: Bool, + pageCoordinator: PageCoordinator, + viewModel: ReadingViewModel, + page: Page, + setting: Setting + ) { + Logger.info("Slider preview changed", context: ["isShown": newValue]) + + if !newValue { + let pagerIndex = pageCoordinator.mapToPager( + index: Int(pageCoordinator.sliderValue), + setting: setting + ) + if page.index != pagerIndex { + page.update(.new(index: pagerIndex)) + } + } + + viewModel.stopAutoPlay() + } + + private func handleReadingProgressChange( + newValue: Int, + pageCoordinator: PageCoordinator, + page: Page, + setting: Setting + ) { + Logger.info("Reading progress changed", context: ["readingProgress": newValue]) + + // Ensure valid reading progress (at least page 1) + let validProgress = max(1, newValue) + + // Update slider value + pageCoordinator.sliderValue = Float(validProgress) + + // Update pager position to match the reading progress + let pagerIndex = pageCoordinator.mapToPager(index: validProgress, setting: setting) + if page.index != pagerIndex { + page.update(.new(index: pagerIndex)) + Logger.info("Pager updated from reading progress", context: [ + "readingProgress": validProgress, + "pagerIndex": pagerIndex + ]) + } + } + + private func handleRouteChange(newValue: ReadingReducer.Route?, viewModel: ReadingViewModel) { + Logger.info("Route changed", context: ["route": newValue as Any]) + + if let route = newValue, ![ReadingReducer.Route.hud, nil].contains(where: { $0 == route }) { + viewModel.stopAutoPlay() + } + } + + private func handleLiveTextToggle( + newValue: Bool, + store: StoreOf, + viewModel: ReadingViewModel + ) { + Logger.info("Live text toggled", context: ["isEnabled": newValue]) + + if newValue { + store.webImageLoadSuccessIndices.forEach { index in + viewModel.analyzeImageForLiveText( + index: index, + imageURL: store.imageURLs[index], + recognitionLanguages: store.galleryDetail?.language.codes + ) + } + } + } + + private func handleImageLoadSuccess( + newValue: Set, + viewModel: ReadingViewModel, + store: StoreOf + ) { + Logger.info("Image load success indices changed", context: [ + "count": newValue.count + ]) + + if viewModel.enablesLiveText { + newValue.forEach { index in + viewModel.analyzeImageForLiveText( + index: index, + imageURL: store.imageURLs[index], + recognitionLanguages: store.galleryDetail?.language.codes + ) + } + } + } + + private func handleLandscapeSettingChange(newValue: Bool, store: StoreOf) { + Logger.info("Landscape setting changed", context: ["newValue": newValue]) + store.send(.setOrientationPortrait(!newValue)) + } +} + +// MARK: - Reading Control Panel + +/// Replacement for the original ControlPanel component with improved architecture +struct ReadingControlPanel: View { + @Binding private var showsPanel: Bool + @Binding private var showsSliderPreview: Bool + @Binding private var sliderValue: Float + @Binding private var setting: Setting + @Binding private var enablesLiveText: Bool + @Binding private var autoPlayPolicy: AutoPlayPolicy + + private let range: ClosedRange + private let previewURLs: [Int: URL] + private let dismissGesture: G + private let dismissAction: () -> Void + private let navigateSettingAction: () -> Void + private let reloadAllImagesAction: () -> Void + private let retryAllFailedImagesAction: () -> Void + private let fetchPreviewURLsAction: (Int) -> Void + + init( + showsPanel: Binding, + showsSliderPreview: Binding, + sliderValue: Binding, + setting: Binding, + enablesLiveText: Binding, + autoPlayPolicy: Binding, + range: ClosedRange, + previewURLs: [Int: URL], + dismissGesture: G, + dismissAction: @escaping () -> Void, + navigateSettingAction: @escaping () -> Void, + reloadAllImagesAction: @escaping () -> Void, + retryAllFailedImagesAction: @escaping () -> Void, + fetchPreviewURLsAction: @escaping (Int) -> Void + ) { + _showsPanel = showsPanel + _showsSliderPreview = showsSliderPreview + _sliderValue = sliderValue + _setting = setting + _enablesLiveText = enablesLiveText + _autoPlayPolicy = autoPlayPolicy + self.range = range + self.previewURLs = previewURLs + self.dismissGesture = dismissGesture + self.dismissAction = dismissAction + self.navigateSettingAction = navigateSettingAction + self.reloadAllImagesAction = reloadAllImagesAction + self.retryAllFailedImagesAction = retryAllFailedImagesAction + self.fetchPreviewURLsAction = fetchPreviewURLsAction + } + + var body: some View { + ControlPanel( + showsPanel: $showsPanel, + showsSliderPreview: $showsSliderPreview, + sliderValue: $sliderValue, + setting: $setting, + enablesLiveText: $enablesLiveText, + autoPlayPolicy: $autoPlayPolicy, + range: range, + previewURLs: previewURLs, + dismissGesture: dismissGesture, + dismissAction: dismissAction, + navigateSettingAction: navigateSettingAction, + reloadAllImagesAction: reloadAllImagesAction, + retryAllFailedImagesAction: retryAllFailedImagesAction, + fetchPreviewURLsAction: fetchPreviewURLsAction + ) + } +} + +// MARK: - Route Binding Extensions + +extension ReadingReducer.Route { + var readingSetting: EquatableVoid? { + if case .readingSetting(let void) = self { + return void + } + return nil + } + + var share: IdentifiableBox? { + if case .share(let shareItem) = self { + return shareItem + } + return nil + } + + var hud: Void? { + if case .hud = self { + return () + } + return nil + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/ReadingViewModel.swift b/EhPanda/View/Reading/Support/ReadingViewModel.swift new file mode 100644 index 00000000..fdfc5904 --- /dev/null +++ b/EhPanda/View/Reading/Support/ReadingViewModel.swift @@ -0,0 +1,321 @@ +// +// ReadingViewModel.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import Combine +import Kingfisher + +// MARK: - Reading View Model +final class ReadingViewModel: ObservableObject { + // MARK: - Published Properties + @Published var enablesLiveText = false + @Published var liveTextGroups = [Int: [LiveTextGroup]]() + @Published var focusedLiveTextGroup: LiveTextGroup? + @Published var autoPlayPolicy: AutoPlayPolicy = .off + @Published var webImageLoadSuccessIndices = Set() + + // MARK: - Private Properties + private var autoPlayTimer: Timer? + private var liveTextRequests = [VNRequest]() + private var cancellables = Set() + + // MARK: - Initialization + init() { + setupObservers() + } + + deinit { + cleanup() + } + + // MARK: - Setup Methods + func setup(with state: ReadingReducer.State, setting: Setting) { + // Initialize with current state + webImageLoadSuccessIndices = state.webImageLoadSuccessIndices + + // Setup live text if needed + if enablesLiveText { + analyzeExistingImages(indices: Array(webImageLoadSuccessIndices)) + } + } + + private func setupObservers() { + // Observe live text state changes + $enablesLiveText + .sink { [weak self] isEnabled in + if isEnabled { + self?.analyzeExistingImages(indices: Array(self?.webImageLoadSuccessIndices ?? [])) + } else { + self?.clearLiveText() + } + } + .store(in: &cancellables) + } + + // MARK: - Auto Play Management + func setAutoPlayPolicy(_ policy: AutoPlayPolicy, pageUpdater: @escaping () -> Void) { + Logger.info("Setting auto play policy", context: ["policy": policy]) + + autoPlayPolicy = policy + autoPlayTimer?.invalidate() + + if policy.isEnabled { + autoPlayTimer = Timer.scheduledTimer(withTimeInterval: policy.timeInterval, repeats: true) { _ in + pageUpdater() + } + } + } + + func stopAutoPlay() { + autoPlayTimer?.invalidate() + autoPlayPolicy = .off + } + + // MARK: - Live Text Management + func setFocusedLiveTextGroup(_ group: LiveTextGroup) { + Logger.info("Setting focused live text group", context: ["group": group]) + focusedLiveTextGroup = group + } + + func analyzeImageForLiveText( + index: Int, + imageURL: URL?, + recognitionLanguages: [String]? + ) { + Logger.info("Analyzing image for live text", context: ["index": index]) + + guard enablesLiveText, + liveTextGroups[index] == nil, + let imageURL = imageURL, + let key = imageURL.absoluteString as String? + else { + Logger.info("Skipping live text analysis", context: [ + "enablesLiveText": enablesLiveText, + "alreadyAnalyzed": liveTextGroups[index] != nil, + "hasURL": imageURL != nil + ]) + return + } + + KingfisherManager.shared.cache.retrieveImage(forKey: key) { [weak self] result in + switch result { + case .success(let result): + if let image = result.image, let cgImage = image.cgImage { + self?.performLiveTextAnalysis( + cgImage: cgImage, + size: image.size, + index: index, + recognitionLanguages: recognitionLanguages + ) + } else { + Logger.info("Live text analysis: image not found", context: ["index": index]) + } + case .failure(let error): + Logger.info("Live text analysis failed", context: [ + "index": index, + "error": error + ] as [String: Any]) + } + } + } + + private func analyzeExistingImages(indices: [Int]) { + indices.forEach { index in + // This would be called with proper parameters from the main view + // analyzeImageForLiveText(index: index, imageURL: nil, recognitionLanguages: nil) + } + } + + private func performLiveTextAnalysis( + cgImage: CGImage, + size: CGSize, + index: Int, + recognitionLanguages: [String]? + ) { + let requestHandler = VNImageRequestHandler(cgImage: cgImage) + let textRecognitionRequest = VNRecognizeTextRequest { [weak self] request, error in + self?.handleLiveTextRecognition( + request: request, + error: error, + size: size, + index: index + ) + } + + textRecognitionRequest.usesLanguageCorrection = true + textRecognitionRequest.preferBackgroundProcessing = true + + if let languages = recognitionLanguages { + textRecognitionRequest.recognitionLanguages = languages + } + + liveTextRequests.append(textRecognitionRequest) + + DispatchQueue.global(qos: .utility).async { [weak self] in + do { + try requestHandler.perform([textRecognitionRequest]) + } catch { + self?.removeLiveTextRequest(textRecognitionRequest) + Logger.info("Live text recognition failed", context: ["error": error]) + } + } + } + + private func handleLiveTextRecognition( + request: VNRequest, + error: Error?, + size: CGSize, + index: Int + ) { + removeLiveTextRequest(request) + + guard let observations = request.results as? [VNRecognizedTextObservation] else { + return + } + + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + let blocks = self?.processLiveTextObservations(observations) ?? [] + let groups = self?.groupLiveTextBlocks(blocks, size: size) ?? [] + + DispatchQueue.main.async { + self?.liveTextGroups[index] = groups + } + } + } + + private func processLiveTextObservations(_ observations: [VNRecognizedTextObservation]) -> [LiveTextBlock] { + return observations.compactMap { observation in + guard let recognizedText = observation.topCandidates(1).first?.string else { + return nil + } + + return LiveTextBlock( + text: recognizedText, + bounds: LiveTextBounds( + topLeft: observation.topLeft.verticalReversed, + topRight: observation.topRight.verticalReversed, + bottomLeft: observation.bottomLeft.verticalReversed, + bottomRight: observation.bottomRight.verticalReversed + ) + ) + } + } + + private func groupLiveTextBlocks(_ blocks: [LiveTextBlock], size: CGSize) -> [LiveTextGroup] { + var groupData = [[LiveTextBlock]]() + + blocks.forEach { newBlock in + if let groupIndex = findMatchingGroup(for: newBlock, in: groupData, size: size) { + groupData[groupIndex].append(newBlock) + } else { + groupData.append([newBlock]) + } + } + + return groupData.compactMap(LiveTextGroup.init) + } + + private func findMatchingGroup( + for newBlock: LiveTextBlock, + in groupData: [[LiveTextBlock]], + size: CGSize + ) -> Int? { + return groupData.firstIndex { blocks in + blocks.contains { existingBlock in + areLiveTextBlocksCompatible(existingBlock, newBlock, size: size) + } + } + } + + private func areLiveTextBlocksCompatible( + _ block1: LiveTextBlock, + _ block2: LiveTextBlock, + size: CGSize + ) -> Bool { + let angle1 = block1.bounds.getAngle(size) + let angle2 = block2.bounds.getAngle(size) + let angleDiff = abs(angle1 - angle2).truncatingRemainder(dividingBy: 360.0) + let isAngleValid = angleDiff < 5 || angleDiff > (360 - 5) + + let height1 = block1.bounds.getHeight(size) + let height2 = block2.bounds.getHeight(size) + let isHeightValid = abs(height1 - height2) < (min(height1, height2) / 2) + + guard isAngleValid && isHeightValid else { return false } + + return arePolygonsIntersecting( + lhs: block1.bounds.expandingHalfHeight(size).edges, + rhs: block2.bounds.expandingHalfHeight(size).edges + ) + } + + private func arePolygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { + guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } + + for points in [lhs, rhs] { + for index1 in 0.. (min: Double, max: Double) { + let projections = points.map { point in + basis.x * point.x + basis.y * point.y + } + return (projections.min() ?? 0, projections.max() ?? 0) + } + + private func clearLiveText() { + liveTextGroups.removeAll() + focusedLiveTextGroup = nil + cancelLiveTextRequests() + } + + private func removeLiveTextRequest(_ request: VNRequest) { + if let index = liveTextRequests.firstIndex(of: request) { + liveTextRequests.remove(at: index) + } + } + + private func cancelLiveTextRequests() { + Logger.info("Canceling live text requests", context: [ + "count": liveTextRequests.count + ]) + liveTextRequests.forEach { $0.cancel() } + liveTextRequests.removeAll() + } + + // MARK: - Cleanup + func cleanup() { + autoPlayTimer?.invalidate() + cancelLiveTextRequests() + cancellables.removeAll() + } +} + +// MARK: - Extensions +private extension CGPoint { + var verticalReversed: CGPoint { + CGPoint(x: x, y: 1 - y) + } +} + +// MARK: - Import Vision Framework +import Vision \ No newline at end of file diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist index 6c33430d..4c8c1c00 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -2,6 +2,8 @@ + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) NSExtension NSExtensionAttributes @@ -17,7 +19,5 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ShareViewController - CFBundleShortVersionString - 2.7.10