From 7b1720d86a60e05ae46436f3113b1ebc58b1732e Mon Sep 17 00:00:00 2001 From: Zack Yu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:26:16 +0800 Subject: [PATCH] Revert "completely refactored Reading View, fix vertical scrolling" --- EhPanda.xcodeproj/project.pbxproj | 76 +- .../xcshareddata/WorkspaceSettings.xcsettings | 5 - .../xcshareddata/swiftpm/Package.resolved | 4 +- EhPanda/App/Info.plist | 28 +- EhPanda/View/Reading/ReadingReducer.swift | 1364 ++++++----------- EhPanda/View/Reading/ReadingView.swift | 914 ++++++----- .../View/Reading/Support/AdvancedList.swift | 189 +-- .../Reading/Support/AutoPlayHandler.swift | 35 + .../Reading/Support/GestureCoordinator.swift | 390 ----- .../View/Reading/Support/GestureHandler.swift | 131 ++ .../View/Reading/Support/ImageStackView.swift | 349 ----- .../Reading/Support/LiveTextHandler.swift | 191 +++ .../Reading/Support/PageCoordinator.swift | 297 ---- .../View/Reading/Support/PageHandler.swift | 40 + .../Support/ReadingViewExtensions.swift | 445 ------ .../Reading/Support/ReadingViewModel.swift | 321 ---- ShareExtension/Info.plist | 4 +- 17 files changed, 1500 insertions(+), 3283 deletions(-) delete mode 100644 EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 EhPanda/View/Reading/Support/AutoPlayHandler.swift delete mode 100644 EhPanda/View/Reading/Support/GestureCoordinator.swift create mode 100644 EhPanda/View/Reading/Support/GestureHandler.swift delete mode 100644 EhPanda/View/Reading/Support/ImageStackView.swift create mode 100644 EhPanda/View/Reading/Support/LiveTextHandler.swift delete mode 100644 EhPanda/View/Reading/Support/PageCoordinator.swift create mode 100644 EhPanda/View/Reading/Support/PageHandler.swift delete mode 100644 EhPanda/View/Reading/Support/ReadingViewExtensions.swift delete mode 100644 EhPanda/View/Reading/Support/ReadingViewModel.swift diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 46280b8b..b8d56e7e 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,11 +7,6 @@ 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 */; }; @@ -219,10 +214,14 @@ 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 */; }; @@ -317,11 +316,6 @@ /* 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 = ""; }; @@ -532,10 +526,14 @@ 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 = ""; }; @@ -710,14 +708,13 @@ 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 = ""; @@ -1854,6 +1851,7 @@ 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 */, @@ -1898,6 +1896,7 @@ 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 */, @@ -1907,11 +1906,6 @@ 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 */, @@ -1942,6 +1936,7 @@ 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 */, @@ -1958,6 +1953,7 @@ 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 */, @@ -2088,10 +2084,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2102,9 +2098,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2116,10 +2112,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2130,9 +2126,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2267,11 +2263,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2280,9 +2276,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = App_Dev; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2296,11 +2292,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2309,9 +2305,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = App_Dev; 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 deleted file mode 100644 index 0c67376e..00000000 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 65143cfc..66fbb20b 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" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", - "version" : "2.3.2" + "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", + "version" : "2.2.2" } }, { diff --git a/EhPanda/App/Info.plist b/EhPanda/App/Info.plist index 97c0ccff..925f724b 100644 --- a/EhPanda/App/Info.plist +++ b/EhPanda/App/Info.plist @@ -26,25 +26,25 @@ AppIcon_Developer - AppIcon_NotMyPresident + AppIcon_StandWithUkraine2022 CFBundleIconFiles - AppIcon_NotMyPresident + AppIcon_StandWithUkraine2022 - AppIcon_StandWithUkraine2022 + AppIcon_Ukiyoe CFBundleIconFiles - AppIcon_StandWithUkraine2022 + AppIcon_Ukiyoe - AppIcon_Ukiyoe + AppIcon_NotMyPresident CFBundleIconFiles - AppIcon_Ukiyoe + AppIcon_NotMyPresident @@ -76,14 +76,6 @@ AppIcon_Developer_iPad_Pro - AppIcon_NotMyPresident - - CFBundleIconFiles - - AppIcon_NotMyPresident_iPad - AppIcon_NotMyPresident_iPad_Pro - - AppIcon_StandWithUkraine2022 CFBundleIconFiles @@ -100,6 +92,14 @@ 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 6371be89..b747f3d9 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -3,46 +3,40 @@ // 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 @@ -53,108 +47,137 @@ 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 - - // MARK: - Loading States + var forceRefreshID: UUID = .init() + var hudConfig: TTProgressHUDConfig = .loading + 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]) - - // MARK: - Database Operations + + case teardown 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 @@ -163,871 +186,462 @@ 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): - return handleSetNavigation(&state, route: route) - + state.route = route + return .none + case .toggleShowsPanel: - return handleToggleShowsPanel(&state) - + 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) + case .onPerformDismiss: - return handlePerformDismiss() - + return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) + case .onAppear(let gid, let enablesLandscape): - 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 + var effects: [Effect] = [ + .send(.fetchDatabaseInfos(gid)) + ] + if enablesLandscape { + effects.append(.send(.setOrientationPortrait(false))) + } + return .merge(effects) + case .onWebImageRetry(let index): - return handleWebImageRetry(&state, index: index) - + state.imageURLLoadingStates[index] = .idle + return .none + case .onWebImageSucceeded(let index): - return handleWebImageSucceeded(&state, index: index) - + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + return .none + case .onWebImageFailed(let index): - return handleWebImageFailed(&state, index: index) - + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + return .none + case .reloadAllWebImages: - return handleReloadAllWebImages(&state) - + 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) + } + case .retryAllFailedWebImages: - return handleRetryAllFailedWebImages(&state) - - // MARK: - Image Actions + 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 + case .copyImage(let imageURL): - return handleCopyImage(imageURL: imageURL) - + return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) + case .saveImage(let imageURL): - return handleSaveImage(imageURL: imageURL) - + return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) + case .saveImageDone(let isSucceeded): - return handleSaveImageDone(&state, isSucceeded: isSucceeded) - + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + return .send(.setNavigation(.hud)) + case .shareImage(let imageURL): - return handleShareImage(imageURL: imageURL) - + return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) + case .fetchImage(let action, let imageURL): - return handleFetchImage(action: action, imageURL: imageURL) - + return .run { send in + let result = await imageClient.fetchImage(url: imageURL) + await send(.fetchImageDone(action, result)) + } + .cancellable(id: CancelID.fetchImage) + case .fetchImageDone(let action, let result): - return handleFetchImageDone(&state, action: action, result: result) - - // MARK: - Synchronization Actions + 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)) + } + case .syncReadingProgress(let progress): - return handleSyncReadingProgress(state: state, progress: progress) - + return .run { [state] _ in + await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + } + case .syncPreviewURLs(let previewURLs): - return handleSyncPreviewURLs(state: state, previewURLs: previewURLs) - + return .run { [state] _ in + await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) + } + case .syncThumbnailURLs(let thumbnailURLs): - return handleSyncThumbnailURLs(state: state, thumbnailURLs: thumbnailURLs) - + return .run { [state] _ in + await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) + } + case .syncImageURLs(let imageURLs, let originalImageURLs): - return handleSyncImageURLs( - state: state, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - - // MARK: - Database Actions + 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) + case .fetchDatabaseInfos(let gid): - return handleFetchDatabaseInfos(&state, gid: 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) + case .fetchDatabaseInfosDone(let galleryState): - return handleFetchDatabaseInfosDone(&state, galleryState: galleryState) - - // MARK: - Preview Actions + 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 + case .fetchPreviewURLs(let index): - return handleFetchPreviewURLs(&state, index: 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) + case .fetchPreviewURLsDone(let index, let result): - return handleFetchPreviewURLsDone(&state, index: index, result: result) - - // MARK: - Image URL Actions + 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 + case .fetchImageURLs(let index): - return handleFetchImageURLs(&state, index: index) - + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, false)) + } else { + return .send(.fetchThumbnailURLs(index)) + } + case .refetchImageURLs(let index): - return handleRefetchImageURLs(&state, index: index) - + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, true)) + } else { + return .send(.refetchNormalImageURLs(index)) + } + case .prefetchImages(let index, let prefetchLimit): - return handlePrefetchImages(&state, index: index, prefetchLimit: prefetchLimit) - - // MARK: - Thumbnail Actions + 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) + case .fetchThumbnailURLs(let index): - return handleFetchThumbnailURLs(&state, index: 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) + case .fetchThumbnailURLsDone(let index, let result): - return handleFetchThumbnailURLsDone(&state, index: index, result: result) - - // MARK: - Normal Image Actions + 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 + case .fetchNormalImageURLs(let index, let thumbnailURLs): - return handleFetchNormalImageURLs(index: index, thumbnailURLs: thumbnailURLs) - + return .run { send in + let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() + await send(.fetchNormalImageURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchNormalImageURLs) + case .fetchNormalImageURLsDone(let index, let result): - return handleFetchNormalImageURLsDone(&state, index: index, result: 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 + case .refetchNormalImageURLs(let index): - return handleRefetchNormalImageURLs(&state, index: 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) + case .refetchNormalImageURLsDone(let index, let result): - return handleRefetchNormalImageURLsDone(&state, index: index, result: result) - - // MARK: - MPV Actions + 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 + case .fetchMPVKeys(let index, let mpvURL): - return handleFetchMPVKeys(index: index, mpvURL: mpvURL) - + return .run { send in + let response = await MPVKeysRequest(mpvURL: mpvURL).response() + await send(.fetchMPVKeysDone(index, response)) + } + .cancellable(id: CancelID.fetchMPVKeys) + case .fetchMPVKeysDone(let index, let result): - 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) + 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 - } - - 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) + + 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)) } - 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) + .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.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 - } - } - - 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 + .haptics( + unwrapping: \.route, + case: \.readingSetting, + hapticsClient: hapticsClient ) - - var effects = fetchIndices.map { index in - Effect.send(.fetchImageURLs(index)) - } - - effects.append( - .run { _ in - imageClient.prefetchImages(prefetchURLs) - } + .haptics( + unwrapping: \.route, + case: \.share, + hapticsClient: hapticsClient ) - - 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 0b2d6da3..bfc301fb 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -3,7 +3,6 @@ // EhPanda // // Created by 荒木辰造 on R 4/01/22. -// Refactored for improved maintainability by zackie on 2025-07-28. // import SwiftUI @@ -11,467 +10,624 @@ 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 - // MARK: - View Models - @StateObject private var viewModel: ReadingViewModel - @StateObject private var gestureCoordinator: GestureCoordinator - @StateObject private var pageCoordinator: PageCoordinator + @StateObject private var liveTextHandler = LiveTextHandler() + @StateObject private var autoPlayHandler = AutoPlayHandler() + @StateObject private var gestureHandler = GestureHandler() + @StateObject private var pageHandler = PageHandler() @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()) } - - // MARK: - Body + + private var backgroundColor: Color { + colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) + } + 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() - ReadingContentView( - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - page: page + 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 ) - - ReadingControlsOverlay( - store: store, - setting: $setting, - viewModel: viewModel, - pageCoordinator: pageCoordinator, - gestureCoordinator: gestureCoordinator, - 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)) } ) } - .readingViewModifiers( - store: store, - setting: $setting, - blurRadius: blurRadius - ) - .onAppear { - store.send(.onAppear(gid, setting.enablesLandscape)) - setupViewModels() - } - .onDisappear { - cleanup() - } - .observeReadingChanges( - store: store, - setting: $setting, - viewModel: viewModel, - pageCoordinator: pageCoordinator, - page: page - ) - } - - // MARK: - Computed Properties - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) } - - // 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 - ) - } + + @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)) + } } - - private func cleanup() { - viewModel.cleanup() - gestureCoordinator.cleanup() - pageCoordinator.cleanup() + + @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: - 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 +// MARK: Handler methods +extension ReadingView { + func setPageIndex(sliderValue: Float) { + let newValue = pageHandler.mapToPager( + index: .init(sliderValue), setting: setting + ) + if page.index != newValue { + page.update(.new(index: newValue)) + Logger.info("Pager.update", context: ["update": newValue]) + } + } + func setAutoPlayPolocy(_ policy: AutoPlayPolicy) { + autoPlayHandler.setPolicy(policy, updatePageAction: { + page.update(.next) + Logger.info("Pager.update", context: ["update": "next"]) + }) + } + 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 + } + 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 ) } else { - HorizontalReadingView( - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - page: page, - onTogglePanel: { store.send(.toggleShowsPanel) } + 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] ) } } - .scaleEffect(gestureCoordinator.scale, anchor: gestureCoordinator.scaleAnchor) - .offset(gestureCoordinator.offset) - .ignoresSafeArea() - .id(store.databaseLoadingState) - .id(store.forceRefreshID) } } -// 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: 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) } -} - -// 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 { - Pager( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self - ) { index in - ImageStackView( - index: index, - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator + 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) } ) } - .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) - .swipeInteractionArea(.allAvailable) - .allowsDragging(gestureCoordinator.scale == 1) - .readingGestures( - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - setting: setting, - page: page, - onTogglePanel: onTogglePanel - ) } } -// 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 +// 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( - isScrollEnabled: Bool, - page: Page, - data: [Int], - spacing: CGFloat, - gestureCoordinator: GestureCoordinator, - pageCoordinator: PageCoordinator, - setting: Setting, - onTogglePanel: @escaping () -> Void, - @ViewBuilder content: @escaping (Int) -> Content + 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.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 + 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 } var body: some View { - 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")) - )] - ) - } - ) - } - } + HStack(spacing: 0) { + if config.isFirstAvailable { + imageContainer(index: config.firstIndex) + } + if config.isSecondAvailable { + imageContainer(index: config.secondIndex) + } + } + } + + 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 + ) .onAppear { - scrollToCurrentPage(proxy: proxy) + if !isDatabaseLoading { + if imageURLs[index] == nil { + fetchAction(index) } + prefetchAction(index) } - // Fixed scrollDisabled implementation for iOS 26 - .scrollDisabled(!isScrollEnabled) - .coordinateSpace(name: "ScrollView") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { preferences in - updateCurrentVisibleIndex(from: preferences) + } + .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) } - .readingGestures( - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - setting: setting, - page: page, - onTogglePanel: onTogglePanel - ) - .onChange(of: page.index) { _, newValue in - scrollToPage(newValue, proxy: proxy) + Button { + saveImageAction(imageURL) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) } - .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) - } - scrollTarget = nil - } + 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) + } } } - - 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 - } +} + +// 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 + + 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 + ) { + 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) } - - // 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 + } + + var body: some View { + if loadingState == .idle { + image(url: imageURL).scaledToFit().overlay( + LiveTextView( + liveTextGroups: liveTextGroups, + focusedLiveTextGroup: focusedLiveTextGroup, + tapAction: liveTextTapAction + ) + .opacity(enablesLiveText ? 1 : 0) + ) + } else { + ZStack { + backgroundColor + VStack { + Text(String(index)).font(.largeTitle.bold()) + .foregroundColor(.gray).padding(.bottom, 30) + ZStack { + Button(action: reloadImage) { + Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + } + .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) + .opacity(loadingState == .loading ? 0 : 1) + ProgressView().opacity(loadingState == .loading ? 1 : 0) + } } - - Logger.info("Updated page index from scroll", context: [ - "newPageIndex": newPageIndex, - "visibility": maxVisibility - ]) } + .frame(width: width, height: height) } } - - private func handleTap(index: Int) { - performingChanges = true - page.update(.new(index: index - 1)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - performingChanges = false + private func reloadImage() { + if let error = loadingState.failed { + if case .webImageFailed = error { + loadRetryAction(index) + } else { + refetchAction(index) + } } } - - private func scrollToCurrentPage(proxy: ScrollViewProxy) { - let targetId = page.index + 1 - DispatchQueue.main.async { - proxy.scrollTo(targetId, anchor: .center) - } + private func onSuccess(_: RetrieveImageResult) { + loadSucceededAction(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 + private func onFailure(_: KingfisherError) { + if imageURL != nil { + loadFailedAction(index) } } } -// MARK: - Scroll Position Tracking -private struct ScrollOffsetData: Equatable { - let index: Int - let frame: CGRect +// MARK: Definition +struct ImageStackConfig { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool } -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 } - } +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 } -// 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) } - ) - } +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: - 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 c2aec933..4adb6f52 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -3,35 +3,25 @@ // 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 { - - // MARK: - State - @State private var performingChanges = false - @State private var scrollTarget: Element? - - // MARK: - Properties + @State var performingChanges = false + 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 @@ -41,172 +31,43 @@ 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(.vertical, showsIndicators: false) { + ScrollView(showsIndicators: false) { LazyVStack(spacing: spacing) { - ForEach(data, id: id) { element in - contentWithGestures(for: element) - .id(element[keyPath: id]) + ForEach(data, id: id) { index in + let longPress = longPressGesture(index: index) + let gestures = longPress.simultaneously(with: gesture) + content(index).gesture(gestures) } } - .onAppear { - initialScrollToPage(proxy: proxy) - } + .onAppear { tryScrollTo(id: pagerModel.index + 1, proxy: proxy) } } - // iOS 26 compatible scroll handling - .coordinateSpace(name: "ScrollView") .onChange(of: pagerModel.index) { _, newValue in - handlePageChange(newValue: newValue, proxy: proxy) - } - .onChange(of: scrollTarget) { _, newValue in - if let target = newValue { - scrollToTarget(target, proxy: proxy) - } + tryScrollTo(id: newValue + 1, proxy: proxy) } } } - - // 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 { + + private func longPressGesture(index: Element) -> some Gesture { LongPressGesture(minimumDuration: 0, maximumDistance: .infinity) .onEnded { _ in - 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 + if let index = index as? Int { + performingChanges = true + pagerModel.update(.new(index: index - 1)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + performingChanges = false } } } } -} -// 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) - ) + private func tryScrollTo(id: Int, proxy: ScrollViewProxy) { + if !performingChanges { + AppUtil.dispatchMainSync { + proxy.scrollTo(id, anchor: .center) + } } - .previewLayout(.sizeThatFits) } } - diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift new file mode 100644 index 00000000..c45f6027 --- /dev/null +++ b/EhPanda/View/Reading/Support/AutoPlayHandler.swift @@ -0,0 +1,35 @@ +// +// 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 deleted file mode 100644 index 521c5603..00000000 --- a/EhPanda/View/Reading/Support/GestureCoordinator.swift +++ /dev/null @@ -1,390 +0,0 @@ -// -// 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 new file mode 100644 index 00000000..af9d5916 --- /dev/null +++ b/EhPanda/View/Reading/Support/GestureHandler.swift @@ -0,0 +1,131 @@ +// +// 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 deleted file mode 100644 index 97f0f7b3..00000000 --- a/EhPanda/View/Reading/Support/ImageStackView.swift +++ /dev/null @@ -1,349 +0,0 @@ -// -// 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 new file mode 100644 index 00000000..9a9451f4 --- /dev/null +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -0,0 +1,191 @@ +// +// 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 new file mode 100644 index 00000000..1b03eea6 --- /dev/null +++ b/EhPanda/View/Reading/Support/PageHandler.swift @@ -0,0 +1,40 @@ +// +// 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 deleted file mode 100644 index 6c0adcbf..00000000 --- a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift +++ /dev/null @@ -1,445 +0,0 @@ -// -// 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 deleted file mode 100644 index fdfc5904..00000000 --- a/EhPanda/View/Reading/Support/ReadingViewModel.swift +++ /dev/null @@ -1,321 +0,0 @@ -// -// 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 4c8c1c00..6c33430d 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -2,8 +2,6 @@ - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) NSExtension NSExtensionAttributes @@ -19,5 +17,7 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ShareViewController + CFBundleShortVersionString + 2.7.10