react-native-pdfrender 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +306 -0
- package/android/build.gradle +76 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/pdfrender/ComposeRenderer.kt +85 -0
- package/android/src/main/java/com/pdfrender/PdfCacheManager.kt +150 -0
- package/android/src/main/java/com/pdfrender/PdfConstants.kt +63 -0
- package/android/src/main/java/com/pdfrender/PdfIconComponents.kt +275 -0
- package/android/src/main/java/com/pdfrender/PdfRenderingLogic.kt +325 -0
- package/android/src/main/java/com/pdfrender/PdfUIComponents.kt +335 -0
- package/android/src/main/java/com/pdfrender/PdfViewPackage.kt +32 -0
- package/android/src/main/java/com/pdfrender/PdfViewerActivity.kt +3467 -0
- package/android/src/main/java/com/pdfrender/PdfViewerFabricManager.kt +244 -0
- package/android/src/main/java/com/pdfrender/PdfViewerFragment.kt +129 -0
- package/android/src/main/java/com/pdfrender/PdfViewerTurboModule.kt +158 -0
- package/android/src/main/java/com/pdfrender/events/FullScreenChangeEvent.kt +26 -0
- package/android/src/main/java/com/pdfrender/events/LeftScreenChangeEvent.kt +22 -0
- package/android/src/main/java/com/pdfrender/events/RightScreenChangeEvent.kt +22 -0
- package/android/src/main/java/com/pdfrender/events/ZoomChangeEvent.kt +22 -0
- package/ios/PdfCacheManager.swift +44 -0
- package/ios/PdfConstants.swift +38 -0
- package/ios/PdfPageView.swift +121 -0
- package/ios/PdfRenderingLogic.swift +107 -0
- package/ios/PdfToolbarView.swift +158 -0
- package/ios/PdfViewerComponentView.mm +194 -0
- package/ios/PdfViewerTurboModule.mm +186 -0
- package/ios/PdfViewerTurboModuleImpl.swift +141 -0
- package/ios/PdfViewerView.swift +268 -0
- package/ios/PdfViewerViewController.swift +109 -0
- package/lib/commonjs/PdfViewerView.js +105 -0
- package/lib/commonjs/PdfViewerView.js.map +1 -0
- package/lib/commonjs/index.js +28 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/specs/NativePdfViewerComponent.js +27 -0
- package/lib/commonjs/specs/NativePdfViewerComponent.js.map +1 -0
- package/lib/commonjs/specs/NativePdfViewerModule.js +21 -0
- package/lib/commonjs/specs/NativePdfViewerModule.js.map +1 -0
- package/lib/commonjs/usePdfViewer.js +65 -0
- package/lib/commonjs/usePdfViewer.js.map +1 -0
- package/lib/module/PdfViewerView.js +99 -0
- package/lib/module/PdfViewerView.js.map +1 -0
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/specs/NativePdfViewerComponent.js +26 -0
- package/lib/module/specs/NativePdfViewerComponent.js.map +1 -0
- package/lib/module/specs/NativePdfViewerModule.js +18 -0
- package/lib/module/specs/NativePdfViewerModule.js.map +1 -0
- package/lib/module/usePdfViewer.js +60 -0
- package/lib/module/usePdfViewer.js.map +1 -0
- package/lib/typescript/PdfViewerView.d.ts +58 -0
- package/lib/typescript/PdfViewerView.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +7 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/specs/NativePdfViewerComponent.d.ts +59 -0
- package/lib/typescript/specs/NativePdfViewerComponent.d.ts.map +1 -0
- package/lib/typescript/specs/NativePdfViewerModule.d.ts +47 -0
- package/lib/typescript/specs/NativePdfViewerModule.d.ts.map +1 -0
- package/lib/typescript/usePdfViewer.d.ts +45 -0
- package/lib/typescript/usePdfViewer.d.ts.map +1 -0
- package/package.json +109 -0
- package/react-native-pdfrender.podspec +35 -0
- package/react-native.config.js +11 -0
- package/src/PdfViewerView.tsx +159 -0
- package/src/index.tsx +10 -0
- package/src/specs/NativePdfViewerComponent.ts +94 -0
- package/src/specs/NativePdfViewerModule.ts +58 -0
- package/src/usePdfViewer.ts +102 -0
|
@@ -0,0 +1,3467 @@
|
|
|
1
|
+
package com.pdfrender
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PDF Viewer Activity - Main Entry Point
|
|
5
|
+
*
|
|
6
|
+
* This file contains the main PDF viewer implementation with the following structure:
|
|
7
|
+
*
|
|
8
|
+
* FILE ORGANIZATION:
|
|
9
|
+
* ==================
|
|
10
|
+
* 1. PdfViewerActivity - Android Activity class (entry point)
|
|
11
|
+
* 2. PdfRendererView - Main composable that orchestrates the PDF viewer
|
|
12
|
+
* 3. State Management - All state variables and their initialization
|
|
13
|
+
* 4. Helper Functions - Utility functions for safe context switching
|
|
14
|
+
* 5. Rendering System - Render queue, worker, and page rendering logic
|
|
15
|
+
* 6. Cache Management - Batch loading and cache size management
|
|
16
|
+
* 7. Gesture Handling - Pinch zoom gesture detection and handling
|
|
17
|
+
* 8. Zoom Management - Zoom change handling and debouncing
|
|
18
|
+
* 9. UI Components - Scaffold, LazyColumn, and page display
|
|
19
|
+
*
|
|
20
|
+
* RELATED FILES:
|
|
21
|
+
* ==============
|
|
22
|
+
* - PdfConstants.kt - Constants and configuration values
|
|
23
|
+
* - PdfRenderingLogic.kt - Page rendering functions
|
|
24
|
+
* - PdfCacheManager.kt - Cache management utilities
|
|
25
|
+
* - PdfUIComponents.kt - Reusable UI components
|
|
26
|
+
*
|
|
27
|
+
* KEY CONCEPTS:
|
|
28
|
+
* ============
|
|
29
|
+
* - Batch Loading: Pages are loaded in batches of 15 for memory efficiency
|
|
30
|
+
* - Render Queue: Sequential processing of render requests prevents conflicts
|
|
31
|
+
* - Cache Management: Keeps only nearby batches in memory to prevent OOM
|
|
32
|
+
* - Gesture Handling: Smooth pinch zoom with 60fps updates
|
|
33
|
+
* - Scale Management: Tracks rendered vs. current scale to avoid stale renders
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import android.graphics.Bitmap
|
|
37
|
+
import android.graphics.Canvas
|
|
38
|
+
import android.graphics.Color as AndroidColor
|
|
39
|
+
import android.graphics.pdf.PdfRenderer
|
|
40
|
+
import android.net.Uri
|
|
41
|
+
import android.os.Bundle
|
|
42
|
+
import android.os.ParcelFileDescriptor
|
|
43
|
+
import android.util.Log
|
|
44
|
+
import java.io.InputStream
|
|
45
|
+
import androidx.activity.ComponentActivity
|
|
46
|
+
import androidx.activity.compose.setContent
|
|
47
|
+
import androidx.compose.foundation.Image
|
|
48
|
+
import androidx.compose.ui.res.painterResource
|
|
49
|
+
import androidx.compose.ui.platform.LocalContext
|
|
50
|
+
import android.graphics.BitmapFactory
|
|
51
|
+
import kotlinx.coroutines.Dispatchers
|
|
52
|
+
import kotlinx.coroutines.withContext
|
|
53
|
+
import java.net.URL
|
|
54
|
+
import androidx.compose.foundation.layout.*
|
|
55
|
+
import androidx.compose.foundation.lazy.LazyColumn
|
|
56
|
+
import androidx.compose.foundation.lazy.itemsIndexed
|
|
57
|
+
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
58
|
+
import androidx.compose.foundation.horizontalScroll
|
|
59
|
+
import androidx.compose.foundation.rememberScrollState
|
|
60
|
+
import androidx.compose.foundation.gestures.detectTransformGestures
|
|
61
|
+
import androidx.compose.foundation.gestures.detectTapGestures
|
|
62
|
+
import androidx.compose.material.Button
|
|
63
|
+
import androidx.compose.material.ButtonDefaults
|
|
64
|
+
import androidx.compose.material.Text
|
|
65
|
+
import androidx.compose.material.MaterialTheme
|
|
66
|
+
import androidx.compose.material.Scaffold
|
|
67
|
+
import androidx.compose.material.TopAppBar
|
|
68
|
+
import androidx.compose.material.CircularProgressIndicator
|
|
69
|
+
import androidx.compose.material.LinearProgressIndicator
|
|
70
|
+
import androidx.compose.material.Slider
|
|
71
|
+
import androidx.compose.material.Card
|
|
72
|
+
import androidx.compose.runtime.*
|
|
73
|
+
import androidx.compose.ui.Alignment
|
|
74
|
+
import androidx.compose.ui.Modifier
|
|
75
|
+
import androidx.compose.ui.graphics.asImageBitmap
|
|
76
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
77
|
+
import androidx.compose.ui.input.pointer.pointerInput
|
|
78
|
+
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
79
|
+
import androidx.compose.ui.unit.dp
|
|
80
|
+
import androidx.compose.ui.layout.ContentScale
|
|
81
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
82
|
+
import androidx.compose.ui.platform.LocalConfiguration
|
|
83
|
+
import androidx.compose.ui.platform.LocalDensity
|
|
84
|
+
import androidx.compose.foundation.layout.size
|
|
85
|
+
import androidx.compose.ui.draw.drawBehind
|
|
86
|
+
import androidx.compose.ui.geometry.Offset
|
|
87
|
+
import androidx.compose.ui.geometry.Size
|
|
88
|
+
import androidx.compose.ui.graphics.Path
|
|
89
|
+
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
90
|
+
import androidx.compose.foundation.Canvas
|
|
91
|
+
import androidx.compose.foundation.border
|
|
92
|
+
import androidx.compose.foundation.clickable
|
|
93
|
+
import androidx.compose.foundation.background
|
|
94
|
+
import androidx.compose.ui.graphics.Color
|
|
95
|
+
import androidx.compose.ui.graphics.ColorFilter
|
|
96
|
+
import androidx.compose.material.icons.Icons
|
|
97
|
+
import androidx.compose.material.icons.filled.ArrowBack
|
|
98
|
+
import androidx.compose.material.Icon
|
|
99
|
+
import androidx.compose.ui.draw.clip
|
|
100
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
101
|
+
import androidx.compose.ui.unit.LayoutDirection
|
|
102
|
+
import androidx.compose.ui.tooling.preview.Preview
|
|
103
|
+
import kotlinx.coroutines.withContext
|
|
104
|
+
import kotlinx.coroutines.launch
|
|
105
|
+
import kotlinx.coroutines.async
|
|
106
|
+
import kotlinx.coroutines.awaitAll
|
|
107
|
+
import kotlinx.coroutines.delay
|
|
108
|
+
import kotlinx.coroutines.sync.Mutex
|
|
109
|
+
import kotlinx.coroutines.sync.Semaphore
|
|
110
|
+
import kotlinx.coroutines.sync.withLock
|
|
111
|
+
import kotlinx.coroutines.sync.withPermit
|
|
112
|
+
import kotlinx.coroutines.channels.Channel
|
|
113
|
+
import kotlinx.coroutines.flow.MutableStateFlow
|
|
114
|
+
import kotlinx.coroutines.flow.StateFlow
|
|
115
|
+
import kotlinx.coroutines.channels.BufferOverflow
|
|
116
|
+
import kotlinx.coroutines.Job
|
|
117
|
+
import androidx.compose.animation.core.AnimationState
|
|
118
|
+
import androidx.compose.animation.core.animateDecay
|
|
119
|
+
import androidx.compose.animation.core.exponentialDecay
|
|
120
|
+
import androidx.compose.runtime.DisposableEffect
|
|
121
|
+
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
|
122
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
123
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// SECTION 1: ACTIVITY CLASS
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Main Activity for PDF Viewer
|
|
131
|
+
*
|
|
132
|
+
* Receives PDF URI and optional initial page index from intent,
|
|
133
|
+
* then displays the PDF viewer composable.
|
|
134
|
+
*/
|
|
135
|
+
class PdfViewerActivity : ComponentActivity() {
|
|
136
|
+
|
|
137
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
138
|
+
super.onCreate(savedInstanceState)
|
|
139
|
+
|
|
140
|
+
val uriString = intent?.getStringExtra("pdf_uri") ?: ""
|
|
141
|
+
val uri = Uri.parse(uriString)
|
|
142
|
+
val initialPageIndex = intent?.getIntExtra("initial_page_index", -1) ?: -1
|
|
143
|
+
|
|
144
|
+
setContent {
|
|
145
|
+
MaterialTheme {
|
|
146
|
+
Box(modifier = Modifier.fillMaxSize().background(Color.Transparent)) {
|
|
147
|
+
PdfRendererView(uri, initialPageIndex)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// SECTION 2: MAIN COMPOSABLE - PDF RENDERER VIEW
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Main PDF Renderer View Composable
|
|
160
|
+
*
|
|
161
|
+
* This is the root composable that manages the entire PDF viewer state and UI.
|
|
162
|
+
* It handles:
|
|
163
|
+
* - PDF loading and initialization
|
|
164
|
+
* - Page rendering and caching
|
|
165
|
+
* - Gesture handling (pinch zoom)
|
|
166
|
+
* - Zoom management
|
|
167
|
+
* - Scroll handling
|
|
168
|
+
*
|
|
169
|
+
* @param uri The URI of the PDF file to display (default: Uri.EMPTY for preview)
|
|
170
|
+
* @param initialPageIndex The page index to scroll to initially (-1 for start)
|
|
171
|
+
*/
|
|
172
|
+
@Preview(showBackground = true)
|
|
173
|
+
@Composable
|
|
174
|
+
fun PdfRendererView(
|
|
175
|
+
uri: Uri = Uri.EMPTY,
|
|
176
|
+
initialPageIndex: Int = -1,
|
|
177
|
+
maxWidthDp: androidx.compose.ui.unit.Dp? = null,
|
|
178
|
+
maxZoom: Float = PdfViewerConfig.MAX_SCALE_SMALL_SCREEN,
|
|
179
|
+
screenWidthPercentage: Float = 100f,
|
|
180
|
+
iconUrls: Map<String, String?>? = null,
|
|
181
|
+
iconSize: androidx.compose.ui.unit.Dp = 40.dp, // Dynamic icon size with default
|
|
182
|
+
defaultZoom: Float = 1.5f, // Dynamic default zoom scale with fallback
|
|
183
|
+
isFullScreen: Boolean = false,
|
|
184
|
+
rightLayout: Boolean = false, // PDF position: true = right side, false = left side
|
|
185
|
+
backButtonText: String? = null,
|
|
186
|
+
headerText: String? = null,
|
|
187
|
+
onFullScreenChange: ((Boolean) -> Unit)? = null,
|
|
188
|
+
onLeftScreenChange: ((Boolean) -> Unit)? = null,
|
|
189
|
+
onRightScreenChange: ((Boolean) -> Unit)? = null,
|
|
190
|
+
onZoomChange: ((Int) -> Unit)? = null
|
|
191
|
+
) {
|
|
192
|
+
|
|
193
|
+
// ========================================================================
|
|
194
|
+
// SECTION 2.1: STATE VARIABLES - PDF Data
|
|
195
|
+
// ========================================================================
|
|
196
|
+
|
|
197
|
+
var pageCount by remember { mutableStateOf(0) }
|
|
198
|
+
val context = androidx.compose.ui.platform.LocalContext.current
|
|
199
|
+
var pdfRenderer by remember { mutableStateOf<PdfRenderer?>(null) }
|
|
200
|
+
var pfd by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
|
|
201
|
+
|
|
202
|
+
// ========================================================================
|
|
203
|
+
// SECTION 2.2: STATE VARIABLES - Page Cache and Rendering
|
|
204
|
+
// ========================================================================
|
|
205
|
+
|
|
206
|
+
// Cache map: pageIndex -> (Bitmap, scale at which it was rendered)
|
|
207
|
+
var pageCache by remember { mutableStateOf<Map<Int, Pair<Bitmap, Float>>>(emptyMap()) }
|
|
208
|
+
val renderingPages = remember { ConcurrentHashMap<Int, Long>() } // Thread-safe: pageIndex -> timestamp
|
|
209
|
+
val queuedPages = remember { mutableStateOf<Set<Int>>(emptySet()) } // Pages queued for rendering
|
|
210
|
+
val activeBitmaps = remember { mutableSetOf<Bitmap>() } // Track active bitmaps to prevent recycling
|
|
211
|
+
var reloadVisiblePagesToken by remember { mutableStateOf(0) } // Triggers reload of currently visible pages
|
|
212
|
+
val renderingStats = remember { RenderingStats() } // Track rendering performance statistics
|
|
213
|
+
|
|
214
|
+
// ========================================================================
|
|
215
|
+
// SECTION 2.3: STATE VARIABLES - Zoom and Scale
|
|
216
|
+
// ========================================================================
|
|
217
|
+
|
|
218
|
+
// ========================================================================
|
|
219
|
+
// ZOOM CONSTANTS - STRICT REQUIREMENTS
|
|
220
|
+
// ========================================================================
|
|
221
|
+
// If defaultZoom is provided from JS, use it as the minimum zoom floor.
|
|
222
|
+
// Otherwise fall back to the global constant (PdfViewerConfig.MIN_SCALE).
|
|
223
|
+
val MIN_ZOOM = defaultZoom.coerceAtMost(PdfViewerConfig.MAX_SCALE_SMALL_SCREEN)
|
|
224
|
+
|
|
225
|
+
// Initialize scale to defaultZoom (dynamic from JS)
|
|
226
|
+
var scale by remember { mutableStateOf(defaultZoom) } // Current zoom scale (dynamic default)
|
|
227
|
+
var lastRenderedScale by remember { mutableStateOf(defaultZoom) } // Last scale that was rendered (dynamic default)
|
|
228
|
+
var isZoomInProgress by remember { mutableStateOf(false) } // True when zoom is changing
|
|
229
|
+
var lastScaleUpdateTime by remember { mutableStateOf(0L) } // Throttle scale updates
|
|
230
|
+
|
|
231
|
+
// ========================================================================
|
|
232
|
+
// SECTION 2.4: STATE VARIABLES - Gesture Handling
|
|
233
|
+
// ========================================================================
|
|
234
|
+
|
|
235
|
+
var isPinchZoomActive by remember { mutableStateOf(false) } // Track active pinch gesture
|
|
236
|
+
var lastPinchZoomTime by remember { mutableStateOf(0L) } // Track last pinch zoom time
|
|
237
|
+
var touchGestureCount by remember { mutableStateOf(0) } // Track number of touch gestures
|
|
238
|
+
var recentPinchZoom by remember { mutableStateOf(false) } // Track if pinch zoom happened recently (keeps pages visible)
|
|
239
|
+
// Initialize fullscreen state from prop (React Native controls the state)
|
|
240
|
+
var isFullScreenState by remember { mutableStateOf(true) } // Default to fullscreen
|
|
241
|
+
|
|
242
|
+
// Sync with prop changes from React Native (parent controls fullscreen)
|
|
243
|
+
LaunchedEffect(isFullScreen) {
|
|
244
|
+
isFullScreenState = isFullScreen
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ========================================================================
|
|
248
|
+
// SECTION 2.5: STATE VARIABLES - Loading State
|
|
249
|
+
// ========================================================================
|
|
250
|
+
|
|
251
|
+
var isInitialLoading by remember { mutableStateOf(true) }
|
|
252
|
+
var loadingProgress by remember { mutableStateOf(0) } // 0-100 percentage
|
|
253
|
+
var loadingStatus by remember { mutableStateOf("Opening PDF...") }
|
|
254
|
+
|
|
255
|
+
// ========================================================================
|
|
256
|
+
// SECTION 2.6: STATE VARIABLES - Batch Management
|
|
257
|
+
// ========================================================================
|
|
258
|
+
|
|
259
|
+
var loadedBatches by remember { mutableStateOf<Set<Int>>(emptySet()) } // Track which batches are loaded
|
|
260
|
+
|
|
261
|
+
// ========================================================================
|
|
262
|
+
// SECTION 2.7: CONFIGURATION AND HELPERS
|
|
263
|
+
// ========================================================================
|
|
264
|
+
|
|
265
|
+
val renderMutex = remember { Mutex() } // Mutex for thread-safe PDF renderer access
|
|
266
|
+
val renderQueue = remember { Channel<RenderRequest>(capacity = Channel.UNLIMITED) } // Render request queue
|
|
267
|
+
val configuration = LocalConfiguration.current
|
|
268
|
+
val screenWidthDp = maxWidthDp ?: configuration.screenWidthDp.dp // Use maxWidthDp if provided, otherwise use screen width
|
|
269
|
+
val density = androidx.compose.ui.platform.LocalDensity.current.density
|
|
270
|
+
|
|
271
|
+
// Use maxZoom parameter directly (always 2.5f = 250% from ViewManager)
|
|
272
|
+
// This ensures both buttons and pinch gestures use the same limits: 100% min, 250% max
|
|
273
|
+
// The maxZoom parameter is always set to 2.5f (250%) to match button zoom limits
|
|
274
|
+
val effectiveMaxZoom = maxZoom.coerceIn(MIN_ZOOM, PdfViewerConfig.MAX_SCALE_SMALL_SCREEN)
|
|
275
|
+
|
|
276
|
+
// Ensure scale is within STRICT bounds (1.0f MINIMUM to effectiveMaxZoom MAXIMUM)
|
|
277
|
+
LaunchedEffect(effectiveMaxZoom) {
|
|
278
|
+
scale = scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
|
|
279
|
+
lastRenderedScale = lastRenderedScale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Send zoom percentage updates to React Native when scale changes
|
|
283
|
+
LaunchedEffect(scale) {
|
|
284
|
+
// Convert scale (1.0 = 100%) to percentage and send to React Native
|
|
285
|
+
val zoomPercentage = (scale * 100).toInt()
|
|
286
|
+
onZoomChange?.invoke(zoomPercentage)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Calculate button disabled states - derived from zoom bounds (NO re-renders)
|
|
290
|
+
val isZoomInDisabled = scale >= effectiveMaxZoom
|
|
291
|
+
val isZoomOutDisabled = scale <= MIN_ZOOM
|
|
292
|
+
|
|
293
|
+
// Calculate layout button disabled states based on PDF position
|
|
294
|
+
// When rightLayout = true: PDF is on right → disable right button, enable left button
|
|
295
|
+
// When rightLayout = false: PDF is on left → disable left button, enable right button
|
|
296
|
+
val isLeftLayoutDisabled = !rightLayout // Disable left button when PDF is on left
|
|
297
|
+
val isRightLayoutDisabled = rightLayout // Disable right button when PDF is on right
|
|
298
|
+
|
|
299
|
+
// Use constants from PdfViewerConfig
|
|
300
|
+
val batchSize = PdfViewerConfig.BATCH_SIZE
|
|
301
|
+
val maxCacheSize = PdfViewerConfig.MAX_CACHE_SIZE
|
|
302
|
+
val RENDERING_TIMEOUT_MS = PdfViewerConfig.RENDERING_TIMEOUT_MS
|
|
303
|
+
|
|
304
|
+
// ========================================================================
|
|
305
|
+
// SECTION 2.8: DYNAMIC ZOOM STEP SIZES BASED ON SCREEN SIZE
|
|
306
|
+
// ========================================================================
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Calculate zoom step sizes based on screen width percentage
|
|
310
|
+
* - Full screen (100%): Smaller steps (5%) for precise control
|
|
311
|
+
* - Split screen (<100%): Larger steps proportional to maxZoom for efficient navigation
|
|
312
|
+
*
|
|
313
|
+
* The step size is calculated as a percentage of maxZoom to maintain proportional scaling:
|
|
314
|
+
* - Full screen: step = 0.6f * 0.0833 = 0.05f (5%)
|
|
315
|
+
* - Split screen: step = 2.5f * 0.04f = 0.10f (10%) - capped for usability
|
|
316
|
+
*/
|
|
317
|
+
val zoomInStep = if (screenWidthPercentage >= 100f) {
|
|
318
|
+
// Full screen: use standard 5% step (8.33% of maxZoom 0.6f)
|
|
319
|
+
0.05f
|
|
320
|
+
} else {
|
|
321
|
+
// Split screen: calculate step as percentage of maxZoom, but cap at reasonable value
|
|
322
|
+
// Use 4% of maxZoom, but ensure minimum 0.10f and maximum 0.15f for usability
|
|
323
|
+
val calculatedStep = maxZoom * 0.04f
|
|
324
|
+
calculatedStep.coerceIn(0.10f, 0.15f)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
val zoomOutStep = zoomInStep // Use same step size for both directions
|
|
328
|
+
|
|
329
|
+
// ========================================================================
|
|
330
|
+
// SECTION 3: HELPER FUNCTIONS
|
|
331
|
+
// ========================================================================
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Safely switches to Main thread with proper cancellation handling
|
|
335
|
+
*
|
|
336
|
+
* This function handles LeftCompositionCancellationException which is
|
|
337
|
+
* expected when a composable leaves composition. It's not an error.
|
|
338
|
+
*
|
|
339
|
+
* @param block The code block to execute on Main thread
|
|
340
|
+
* @return The result of the block, or null if cancelled/errored
|
|
341
|
+
*/
|
|
342
|
+
suspend fun <T> safeMainContext(block: suspend () -> T): T? {
|
|
343
|
+
return try {
|
|
344
|
+
withContext(Dispatchers.Main) {
|
|
345
|
+
block()
|
|
346
|
+
}
|
|
347
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
348
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
349
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
350
|
+
// Expected when composable leaves composition - not an error
|
|
351
|
+
}
|
|
352
|
+
throw e
|
|
353
|
+
} catch (e: Exception) {
|
|
354
|
+
null
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Requests the UI layer to re-queue currently visible pages.
|
|
360
|
+
*
|
|
361
|
+
* This is triggered after aggressive cache cleanups (usually at max zoom) to make
|
|
362
|
+
* sure the pages that just got evicted are scheduled for rendering again.
|
|
363
|
+
*/
|
|
364
|
+
suspend fun requestVisiblePagesReload(reason: String) {
|
|
365
|
+
try {
|
|
366
|
+
safeMainContext {
|
|
367
|
+
reloadVisiblePagesToken = reloadVisiblePagesToken + 1
|
|
368
|
+
}
|
|
369
|
+
} catch (e: Exception) {
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ========================================================================
|
|
374
|
+
// SECTION 4: PAGE RENDERING SYSTEM
|
|
375
|
+
// ========================================================================
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Internal rendering function - renders a single PDF page to a Bitmap
|
|
379
|
+
*
|
|
380
|
+
* This function is called by the render worker. It handles:
|
|
381
|
+
* - Page validation
|
|
382
|
+
* - Thread-safe page opening (using mutex)
|
|
383
|
+
* - Bitmap creation with memory management
|
|
384
|
+
* - Error handling and cleanup
|
|
385
|
+
* - Performance timing tracking
|
|
386
|
+
*
|
|
387
|
+
* @param pageIndex The index of the page to render (0-based)
|
|
388
|
+
* @param targetScale The scale at which to render (1.0f minimum, up to maxZoom)
|
|
389
|
+
* @return The rendered Bitmap, or null if rendering failed
|
|
390
|
+
*/
|
|
391
|
+
suspend fun renderPageInternal(pageIndex: Int, targetScale: Float): Bitmap? = withContext(Dispatchers.Default) {
|
|
392
|
+
// Use the optimized rendering function with timing
|
|
393
|
+
val result = renderPageToBitmap(
|
|
394
|
+
pageIndex = pageIndex,
|
|
395
|
+
targetScale = targetScale,
|
|
396
|
+
pageCount = pageCount,
|
|
397
|
+
pdfRenderer = pdfRenderer,
|
|
398
|
+
renderMutex = renderMutex,
|
|
399
|
+
density = density,
|
|
400
|
+
activeBitmaps = activeBitmaps,
|
|
401
|
+
logTimings = true // Enable detailed timing logs
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
// Add timing to statistics if rendering was successful
|
|
405
|
+
result?.let { (bitmap, timings) ->
|
|
406
|
+
renderingStats.addTiming(timings)
|
|
407
|
+
|
|
408
|
+
// Log aggregated stats every 10 pages
|
|
409
|
+
if (renderingStats.getStats().contains("10")) {
|
|
410
|
+
Log.i("PdfRenderer", renderingStats.getStats())
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
bitmap
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ========================================================================
|
|
418
|
+
// SECTION 5: RENDER WORKER AND WATCHDOG
|
|
419
|
+
// ========================================================================
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Watchdog - Monitors and clears stuck pages in renderingPages
|
|
423
|
+
*
|
|
424
|
+
* Checks every 2 seconds for pages that have been "rendering" for too long
|
|
425
|
+
* (longer than RENDERING_TIMEOUT_MS). These are likely stuck and need to be cleared.
|
|
426
|
+
*/
|
|
427
|
+
LaunchedEffect(Unit) {
|
|
428
|
+
launch(Dispatchers.Default) {
|
|
429
|
+
while (true) {
|
|
430
|
+
try {
|
|
431
|
+
delay(100) // Check every 2 seconds
|
|
432
|
+
val currentTime = System.currentTimeMillis()
|
|
433
|
+
val stuckPages = mutableListOf<Int>()
|
|
434
|
+
|
|
435
|
+
// Check for stuck pages in renderingPages
|
|
436
|
+
renderingPages.forEach { (pageIndex, startTime) ->
|
|
437
|
+
if (currentTime - startTime > RENDERING_TIMEOUT_MS) {
|
|
438
|
+
stuckPages.add(pageIndex)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Clear stuck pages from both renderingPages and queuedPages
|
|
443
|
+
if (stuckPages.isNotEmpty()) {
|
|
444
|
+
stuckPages.forEach { pageIndex ->
|
|
445
|
+
renderingPages.remove(pageIndex)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
withContext(Dispatchers.Main) {
|
|
450
|
+
val currentQueued = queuedPages.value
|
|
451
|
+
queuedPages.value = currentQueued - stuckPages.toSet()
|
|
452
|
+
}
|
|
453
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
454
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
455
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
456
|
+
// Expected when composable leaves composition - not an error
|
|
457
|
+
} else {
|
|
458
|
+
// Other cancellation - also expected
|
|
459
|
+
}
|
|
460
|
+
throw e
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
}
|
|
464
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
465
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
466
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
467
|
+
// Expected when composable leaves composition - not an error
|
|
468
|
+
} else {
|
|
469
|
+
// Other cancellation - also expected
|
|
470
|
+
}
|
|
471
|
+
throw e
|
|
472
|
+
} catch (e: Exception) {
|
|
473
|
+
// Only log actual errors, not cancellations
|
|
474
|
+
e.printStackTrace()
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Render Worker - Processes render requests sequentially
|
|
482
|
+
*
|
|
483
|
+
* This worker processes render requests from the queue one at a time.
|
|
484
|
+
* It handles:
|
|
485
|
+
* - Request validation
|
|
486
|
+
* - Stale request detection (scale changed)
|
|
487
|
+
* - Memory management
|
|
488
|
+
* - Cache updates
|
|
489
|
+
* - Bitmap recycling
|
|
490
|
+
*/
|
|
491
|
+
LaunchedEffect(Unit) {
|
|
492
|
+
launch(Dispatchers.Default) {
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
for (request in renderQueue) {
|
|
496
|
+
var pageIndex: Int? = null
|
|
497
|
+
try {
|
|
498
|
+
pageIndex = request.pageIndex
|
|
499
|
+
|
|
500
|
+
// Check if PDF renderer is still valid
|
|
501
|
+
if (pdfRenderer == null) {
|
|
502
|
+
renderingPages.remove(request.pageIndex)
|
|
503
|
+
safeMainContext {
|
|
504
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
505
|
+
}
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Validate page index
|
|
510
|
+
if (request.pageIndex < 0 || request.pageIndex >= pageCount) {
|
|
511
|
+
renderingPages.remove(request.pageIndex)
|
|
512
|
+
safeMainContext {
|
|
513
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
514
|
+
}
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// CRITICAL FIX: Don't skip rendering during pinch zoom - allow pages to render
|
|
519
|
+
// Only skip during button zoom (isZoomInProgress) to avoid conflicts
|
|
520
|
+
// During pinch zoom, pages should render to prevent white screen
|
|
521
|
+
val zoomActive = safeMainContext { isZoomInProgress } ?: false
|
|
522
|
+
val pinchActive = safeMainContext { isPinchZoomActive } ?: false
|
|
523
|
+
val recentPinch = safeMainContext { recentPinchZoom } ?: false
|
|
524
|
+
|
|
525
|
+
// Skip only if button zoom is active (not pinch zoom)
|
|
526
|
+
if (zoomActive && !pinchActive && !recentPinch) {
|
|
527
|
+
renderingPages.remove(request.pageIndex)
|
|
528
|
+
safeMainContext {
|
|
529
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
530
|
+
}
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Check if scale has changed significantly since request was queued
|
|
535
|
+
// CRITICAL FIX: Reduced threshold from 10% to 5% to catch stale renders faster
|
|
536
|
+
// This prevents rendering pages at stale scales during rapid zoom changes
|
|
537
|
+
// CRITICAL FIX: During pinch zoom, be more lenient with stale render detection
|
|
538
|
+
// This allows pages to render during pinch zoom even if scale changes slightly
|
|
539
|
+
val currentRenderedScale = safeMainContext { lastRenderedScale } ?: request.targetScale
|
|
540
|
+
val currentScale = safeMainContext { scale } ?: request.targetScale
|
|
541
|
+
val scaleDiff = kotlin.math.abs(currentRenderedScale - request.targetScale)
|
|
542
|
+
val currentScaleDiff = kotlin.math.abs(currentScale - request.targetScale)
|
|
543
|
+
|
|
544
|
+
// During pinch zoom, use current scale instead of lastRenderedScale for stale detection
|
|
545
|
+
// This allows rendering during pinch zoom even if lastRenderedScale hasn't updated yet
|
|
546
|
+
val effectiveScaleDiff = if (pinchActive || recentPinch) {
|
|
547
|
+
currentScaleDiff // Use current scale during pinch zoom
|
|
548
|
+
} else {
|
|
549
|
+
scaleDiff // Use lastRenderedScale for normal zoom
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// More lenient threshold during pinch zoom
|
|
553
|
+
val staleThreshold = if (pinchActive || recentPinch) {
|
|
554
|
+
0.15f // 15% threshold during pinch zoom (more lenient)
|
|
555
|
+
} else {
|
|
556
|
+
0.05f // 5% threshold for normal zoom
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (effectiveScaleDiff > staleThreshold) {
|
|
560
|
+
renderingPages.remove(request.pageIndex)
|
|
561
|
+
safeMainContext {
|
|
562
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
563
|
+
}
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Skip if already cached at this scale (with error handling)
|
|
568
|
+
val cached = safeMainContext { pageCache[request.pageIndex] }
|
|
569
|
+
if (cached != null && kotlin.math.abs(cached.second - request.targetScale) < 0.08f) {
|
|
570
|
+
// Remove from queued if it was there
|
|
571
|
+
safeMainContext {
|
|
572
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
573
|
+
}
|
|
574
|
+
continue
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Check if page is being rendered - but allow stale entries to be re-rendered
|
|
578
|
+
val renderStartTime = renderingPages[request.pageIndex]
|
|
579
|
+
val currentTime = System.currentTimeMillis()
|
|
580
|
+
val isStale = renderStartTime != null && (currentTime - renderStartTime) > RENDERING_TIMEOUT_MS
|
|
581
|
+
|
|
582
|
+
if (renderStartTime != null && !isStale) {
|
|
583
|
+
// Remove from queued if it was there
|
|
584
|
+
try {
|
|
585
|
+
withContext(Dispatchers.Main) {
|
|
586
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
587
|
+
}
|
|
588
|
+
} catch (e: Exception) {
|
|
589
|
+
}
|
|
590
|
+
continue
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (isStale && renderStartTime != null) {
|
|
594
|
+
renderingPages.remove(request.pageIndex) // Clear stale entry
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Check memory before rendering
|
|
598
|
+
val runtime = Runtime.getRuntime()
|
|
599
|
+
val usedMemory = runtime.totalMemory() - runtime.freeMemory()
|
|
600
|
+
val maxMemory = runtime.maxMemory()
|
|
601
|
+
val memoryUsagePercent = (usedMemory * 100 / maxMemory).toInt()
|
|
602
|
+
|
|
603
|
+
// CRITICAL FIX: At max zoom, be more conservative with memory thresholds
|
|
604
|
+
// Max zoom creates much larger bitmaps, so we need higher thresholds
|
|
605
|
+
// Reuse currentScale from above (line 568) to avoid duplicate declaration
|
|
606
|
+
val isMaxZoom = currentScale > 1.0f
|
|
607
|
+
|
|
608
|
+
// Adjust memory threshold based on zoom level
|
|
609
|
+
// At max zoom, allow higher memory usage before aggressive cleanup
|
|
610
|
+
val memoryThreshold = if (isMaxZoom) {
|
|
611
|
+
85 // Higher threshold for max zoom (was 75)
|
|
612
|
+
} else {
|
|
613
|
+
75 // Normal threshold for regular zoom
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// If memory is high, clean cache before rendering
|
|
617
|
+
if (memoryUsagePercent > memoryThreshold) {
|
|
618
|
+
|
|
619
|
+
// CRITICAL FIX: At max zoom, preserve pages at current scale to prevent white screen
|
|
620
|
+
// Keep visible pages and pages at current scale, not just batches
|
|
621
|
+
val currentBatch = request.pageIndex / batchSize
|
|
622
|
+
val batchesToKeep = setOf(currentBatch - 1, currentBatch, currentBatch + 1)
|
|
623
|
+
.filter { batchNum -> batchNum >= 0 && batchNum * batchSize < pageCount }
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
// CRITICAL: Collect bitmaps to recycle BEFORE updating cache
|
|
627
|
+
val bitmapsToRecycle = mutableListOf<Bitmap>()
|
|
628
|
+
|
|
629
|
+
withContext(Dispatchers.Main) {
|
|
630
|
+
// CRITICAL FIX: At max zoom, preserve pages at current scale
|
|
631
|
+
// This prevents white screen when scrolling at max zoom
|
|
632
|
+
val pagesToKeep = if (isMaxZoom) {
|
|
633
|
+
pageCache.filter { (pageIndex, pair) ->
|
|
634
|
+
val pageBatch = pageIndex / batchSize
|
|
635
|
+
val bitmapScale = pair.second
|
|
636
|
+
val scaleDiff = kotlin.math.abs(bitmapScale - currentScale)
|
|
637
|
+
|
|
638
|
+
// Keep pages in nearby batches OR pages at current scale (max zoom)
|
|
639
|
+
batchesToKeep.contains(pageBatch) || scaleDiff < 0.50f
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
// Normal zoom: just keep nearby batches
|
|
643
|
+
pageCache.filter { (pageIndex, _) ->
|
|
644
|
+
val pageBatch = pageIndex / batchSize
|
|
645
|
+
batchesToKeep.contains(pageBatch)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Collect bitmaps that will be removed (but don't recycle yet)
|
|
650
|
+
pageCache.forEach { (pageIndex, pair) ->
|
|
651
|
+
if (!pagesToKeep.containsKey(pageIndex)) {
|
|
652
|
+
bitmapsToRecycle.add(pair.first)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// CRITICAL: Update cache FIRST
|
|
657
|
+
pageCache = pagesToKeep
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// CRITICAL: Wait to ensure UI has stopped using removed bitmaps
|
|
661
|
+
delay(100)
|
|
662
|
+
|
|
663
|
+
// CRITICAL: Now safely recycle removed bitmaps AFTER cache update
|
|
664
|
+
bitmapsToRecycle.forEach { bitmap ->
|
|
665
|
+
try {
|
|
666
|
+
// Double-check bitmap is not still in cache
|
|
667
|
+
val stillInCache = withContext(Dispatchers.Main) {
|
|
668
|
+
pageCache.values.any { it.first == bitmap }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (stillInCache) {
|
|
672
|
+
return@forEach
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
activeBitmaps.remove(bitmap)
|
|
676
|
+
if (!bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0) {
|
|
677
|
+
bitmap.recycle()
|
|
678
|
+
}
|
|
679
|
+
} catch (e: Exception) {
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} catch (e: Exception) {
|
|
683
|
+
}
|
|
684
|
+
System.gc()
|
|
685
|
+
if (isMaxZoom) {
|
|
686
|
+
requestVisiblePagesReload("RenderWorkerCacheCleanup")
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Remove from queued and mark as rendering with timestamp
|
|
691
|
+
try {
|
|
692
|
+
withContext(Dispatchers.Main) {
|
|
693
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
694
|
+
}
|
|
695
|
+
} catch (e: Exception) {
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
renderingPages[request.pageIndex] = System.currentTimeMillis()
|
|
699
|
+
|
|
700
|
+
var bitmap: Bitmap? = null
|
|
701
|
+
var renderSuccess = false
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
bitmap = renderPageInternal(request.pageIndex, request.targetScale)
|
|
705
|
+
} catch (e: OutOfMemoryError) {
|
|
706
|
+
// Clean cache aggressively
|
|
707
|
+
try {
|
|
708
|
+
withContext(Dispatchers.Main) {
|
|
709
|
+
val currentBatch = request.pageIndex / batchSize
|
|
710
|
+
val batchesToKeep = setOf(currentBatch).filter { batchNum -> batchNum >= 0 }
|
|
711
|
+
pageCache = pageCache.filter { (idx, _) ->
|
|
712
|
+
val pageBatch = idx / batchSize
|
|
713
|
+
batchesToKeep.contains(pageBatch)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
} catch (ex: Exception) {
|
|
717
|
+
}
|
|
718
|
+
System.gc()
|
|
719
|
+
delay(100)
|
|
720
|
+
bitmap = null // Return null, page will be retried later
|
|
721
|
+
} catch (e: Exception) {
|
|
722
|
+
e.printStackTrace()
|
|
723
|
+
bitmap = null
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (bitmap != null) {
|
|
727
|
+
// CRITICAL: Store reference to old bitmap BEFORE updating cache
|
|
728
|
+
val oldCachedBitmap = cached?.first
|
|
729
|
+
|
|
730
|
+
// CRITICAL FIX: Check if pinch zoom is active before recycling old bitmap
|
|
731
|
+
// During pinch zoom, keep old bitmap to prevent white screen
|
|
732
|
+
val pinchActive = safeMainContext { isPinchZoomActive } ?: false
|
|
733
|
+
val recentPinch = safeMainContext { recentPinchZoom } ?: false
|
|
734
|
+
|
|
735
|
+
// CRITICAL: Update cache FIRST on main thread
|
|
736
|
+
// This ensures UI switches to new bitmap before old one is recycled
|
|
737
|
+
try {
|
|
738
|
+
withContext(Dispatchers.Main) {
|
|
739
|
+
pageCache = pageCache.toMutableMap().apply {
|
|
740
|
+
put(request.pageIndex, bitmap!! to request.targetScale)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
renderSuccess = true
|
|
744
|
+
} catch (e: Exception) {
|
|
745
|
+
// Still mark as success since bitmap was created
|
|
746
|
+
renderSuccess = true
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// CRITICAL FIX: During pinch zoom or recent pinch zoom, DON'T recycle old bitmap immediately
|
|
750
|
+
// Keep it available for visual scaling to prevent white screen
|
|
751
|
+
if (!pinchActive && !recentPinch) {
|
|
752
|
+
// CRITICAL: Wait a bit to ensure UI has switched to new bitmap
|
|
753
|
+
delay(50)
|
|
754
|
+
|
|
755
|
+
// CRITICAL: Now safely recycle old bitmap AFTER cache update
|
|
756
|
+
if (oldCachedBitmap != null) {
|
|
757
|
+
try {
|
|
758
|
+
// Double-check old bitmap is not still in cache
|
|
759
|
+
val stillInCache = withContext(Dispatchers.Main) {
|
|
760
|
+
pageCache[request.pageIndex]?.first == oldCachedBitmap
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (stillInCache) {
|
|
764
|
+
} else {
|
|
765
|
+
// Remove from active bitmaps
|
|
766
|
+
activeBitmaps.remove(oldCachedBitmap)
|
|
767
|
+
|
|
768
|
+
// Final validation before recycling
|
|
769
|
+
if (!oldCachedBitmap.isRecycled && oldCachedBitmap.width > 0 && oldCachedBitmap.height > 0) {
|
|
770
|
+
oldCachedBitmap.recycle()
|
|
771
|
+
} else {
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
} catch (e: Exception) {
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
// During pinch zoom, keep old bitmap for visual scaling
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ALWAYS remove from renderingPages, even on error
|
|
783
|
+
renderingPages.remove(request.pageIndex)
|
|
784
|
+
if (!renderSuccess) {
|
|
785
|
+
// Remove from queued if render failed
|
|
786
|
+
try {
|
|
787
|
+
withContext(Dispatchers.Main) {
|
|
788
|
+
queuedPages.value = queuedPages.value - request.pageIndex
|
|
789
|
+
}
|
|
790
|
+
} catch (e: Exception) {
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Small delay to prevent CPU hogging
|
|
795
|
+
delay(5)
|
|
796
|
+
|
|
797
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
798
|
+
// Clean up on cancellation
|
|
799
|
+
if (pageIndex != null) {
|
|
800
|
+
renderingPages.remove(pageIndex)
|
|
801
|
+
}
|
|
802
|
+
throw e // Re-throw cancellation
|
|
803
|
+
} catch (e: Exception) {
|
|
804
|
+
e.printStackTrace()
|
|
805
|
+
// Ensure cleanup even if outer try fails
|
|
806
|
+
if (pageIndex != null) {
|
|
807
|
+
renderingPages.remove(pageIndex)
|
|
808
|
+
try {
|
|
809
|
+
withContext(Dispatchers.Main) {
|
|
810
|
+
queuedPages.value = queuedPages.value - pageIndex
|
|
811
|
+
}
|
|
812
|
+
} catch (ex: Exception) {
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
818
|
+
throw e
|
|
819
|
+
} catch (e: Exception) {
|
|
820
|
+
e.printStackTrace()
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ========================================================================
|
|
826
|
+
// SECTION 6: CACHE AND BATCH MANAGEMENT FUNCTIONS
|
|
827
|
+
// ========================================================================
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Calculates which batch a page belongs to
|
|
831
|
+
* Uses helper function from PdfCacheManager
|
|
832
|
+
*/
|
|
833
|
+
fun getBatchNumber(pageIndex: Int): Int {
|
|
834
|
+
return getBatchNumber(pageIndex, batchSize)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Gets the page range for a specific batch
|
|
839
|
+
* Uses helper function from PdfCacheManager
|
|
840
|
+
*/
|
|
841
|
+
fun getBatchRange(batchNumber: Int): IntRange {
|
|
842
|
+
return getBatchRange(batchNumber, batchSize, pageCount)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Clears the render queue and all rendering states
|
|
847
|
+
*
|
|
848
|
+
* Called when scroll/zoom changes to prevent stale renders.
|
|
849
|
+
* Clears:
|
|
850
|
+
* - Render queue (pending requests)
|
|
851
|
+
* - Queued pages tracking
|
|
852
|
+
* - Rendering pages tracking
|
|
853
|
+
*/
|
|
854
|
+
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
855
|
+
suspend fun clearRenderQueue() = withContext(Dispatchers.Default) {
|
|
856
|
+
var cleared = 0
|
|
857
|
+
try {
|
|
858
|
+
while (!renderQueue.isEmpty) {
|
|
859
|
+
renderQueue.tryReceive().getOrNull()?.let { cleared++ }
|
|
860
|
+
}
|
|
861
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
862
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
863
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
864
|
+
// Expected when composable leaves composition - not an error
|
|
865
|
+
} else {
|
|
866
|
+
// Other cancellation - also expected
|
|
867
|
+
}
|
|
868
|
+
throw e
|
|
869
|
+
} catch (e: Exception) {
|
|
870
|
+
// Only log actual errors
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Clear queued pages tracking and stuck rendering pages
|
|
874
|
+
try {
|
|
875
|
+
withContext(Dispatchers.Main) {
|
|
876
|
+
queuedPages.value = emptySet()
|
|
877
|
+
}
|
|
878
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
879
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
880
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
881
|
+
// Expected when composable leaves composition - not an error
|
|
882
|
+
} else {
|
|
883
|
+
// Other cancellation - also expected
|
|
884
|
+
}
|
|
885
|
+
throw e
|
|
886
|
+
} catch (e: Exception) {
|
|
887
|
+
// Only log actual errors
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Also clear renderingPages to prevent stuck states
|
|
891
|
+
renderingPages.clear()
|
|
892
|
+
|
|
893
|
+
if (cleared > 0) {
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Loads a batch of pages by submitting them to the render queue
|
|
899
|
+
*
|
|
900
|
+
* This function queues pages for rendering but doesn't render them directly.
|
|
901
|
+
* The render worker processes them sequentially.
|
|
902
|
+
*
|
|
903
|
+
* @param batchNumber The batch number to load
|
|
904
|
+
* @param targetScale The scale at which to render pages
|
|
905
|
+
* @param priority Priority level (0=highest, 1=medium, 2=low)
|
|
906
|
+
*/
|
|
907
|
+
suspend fun loadBatch(batchNumber: Int, targetScale: Float, priority: Int = 2) = withContext(Dispatchers.Default) {
|
|
908
|
+
if (loadedBatches.contains(batchNumber)) {
|
|
909
|
+
return@withContext
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
val range = getBatchRange(batchNumber)
|
|
913
|
+
|
|
914
|
+
// Submit each page to the render queue (worker will process them smoothly)
|
|
915
|
+
for (pageIndex in range) {
|
|
916
|
+
// Skip if already in cache at correct scale
|
|
917
|
+
val cached = pageCache[pageIndex]
|
|
918
|
+
val needsRender = cached == null ||
|
|
919
|
+
kotlin.math.abs(cached.second - targetScale) > 0.08f
|
|
920
|
+
|
|
921
|
+
if (needsRender) {
|
|
922
|
+
val request = RenderRequest(pageIndex, targetScale, priority)
|
|
923
|
+
renderQueue.send(request) // Non-blocking send to queue
|
|
924
|
+
|
|
925
|
+
// Track queued pages
|
|
926
|
+
withContext(Dispatchers.Main) {
|
|
927
|
+
queuedPages.value = queuedPages.value + pageIndex
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Mark batch as loaded (pages are now queued for rendering)
|
|
934
|
+
withContext(Dispatchers.Main) {
|
|
935
|
+
loadedBatches = loadedBatches + batchNumber
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Manages cache size by removing batches far from current position
|
|
942
|
+
*
|
|
943
|
+
* Keeps only nearby batches (current ± 1) in memory to prevent OOM.
|
|
944
|
+
* More aggressive cleanup if memory usage is high.
|
|
945
|
+
*
|
|
946
|
+
* @param currentVisiblePage The currently visible page index
|
|
947
|
+
*/
|
|
948
|
+
suspend fun manageCacheSize(currentVisiblePage: Int) = withContext(Dispatchers.Default) {
|
|
949
|
+
// Check memory usage and cache size
|
|
950
|
+
val runtime = Runtime.getRuntime()
|
|
951
|
+
val usedMemory = runtime.totalMemory() - runtime.freeMemory()
|
|
952
|
+
val maxMemory = runtime.maxMemory()
|
|
953
|
+
val memoryUsagePercent = (usedMemory * 100 / maxMemory).toInt()
|
|
954
|
+
|
|
955
|
+
// CRITICAL FIX: At max zoom, be more conservative with cache management
|
|
956
|
+
// Max zoom creates much larger bitmaps, so we need to preserve more pages
|
|
957
|
+
val currentScale = safeMainContext { scale } ?: MIN_ZOOM
|
|
958
|
+
val isMaxZoom = currentScale > 1.0f
|
|
959
|
+
|
|
960
|
+
// More aggressive cache management if memory is high
|
|
961
|
+
// But at max zoom, be more lenient to prevent white screen
|
|
962
|
+
val effectiveCacheSize = if (isMaxZoom) {
|
|
963
|
+
// At max zoom, preserve more pages to prevent white screen
|
|
964
|
+
if (memoryUsagePercent > 80) {
|
|
965
|
+
(maxCacheSize * 2 / 3).toInt() // Only reduce to 2/3 if memory > 80%
|
|
966
|
+
} else if (memoryUsagePercent > 70) {
|
|
967
|
+
(maxCacheSize * 3 / 4).toInt() // Reduce to 3/4 if memory > 70%
|
|
968
|
+
} else {
|
|
969
|
+
maxCacheSize // Keep full cache if memory < 70%
|
|
970
|
+
}
|
|
971
|
+
} else {
|
|
972
|
+
// Normal zoom: use original logic
|
|
973
|
+
if (memoryUsagePercent > 60) {
|
|
974
|
+
maxCacheSize / 2 // Reduce cache size by half if memory > 60%
|
|
975
|
+
} else if (memoryUsagePercent > 50) {
|
|
976
|
+
(maxCacheSize * 2 / 3).toInt() // Reduce to 2/3 if memory > 50%
|
|
977
|
+
} else {
|
|
978
|
+
maxCacheSize
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// CRITICAL FIX: At max zoom, use higher memory threshold before aggressive cleanup
|
|
983
|
+
val memoryThreshold = if (isMaxZoom) {
|
|
984
|
+
80 // Higher threshold for max zoom (was 70)
|
|
985
|
+
} else {
|
|
986
|
+
70 // Normal threshold
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (pageCache.size > effectiveCacheSize || memoryUsagePercent > memoryThreshold) {
|
|
990
|
+
try {
|
|
991
|
+
val currentBatch = getBatchNumber(currentVisiblePage)
|
|
992
|
+
|
|
993
|
+
// Keep current batch and 1 batch before/after (3 batches total = 60 pages)
|
|
994
|
+
val batchesToKeep = setOf(
|
|
995
|
+
currentBatch - 1,
|
|
996
|
+
currentBatch,
|
|
997
|
+
currentBatch + 1
|
|
998
|
+
).filter { it >= 0 && it * batchSize < pageCount }
|
|
999
|
+
|
|
1000
|
+
val pagesToKeep = mutableMapOf<Int, Pair<Bitmap, Float>>()
|
|
1001
|
+
val pagesToRemove = mutableListOf<Bitmap>()
|
|
1002
|
+
val batchesToUnload = mutableSetOf<Int>()
|
|
1003
|
+
|
|
1004
|
+
// CRITICAL FIX: Get current scale to preserve pages at max zoom AND minimum zoom
|
|
1005
|
+
val currentScale = safeMainContext { scale } ?: MIN_ZOOM
|
|
1006
|
+
val scaleDiffThreshold = when {
|
|
1007
|
+
currentScale > 1.0f -> 0.50f // For max zoom, be more lenient (50% difference)
|
|
1008
|
+
currentScale <= 0.35f -> 0.50f // For minimum zoom, also be lenient (keeps pages at higher scales)
|
|
1009
|
+
currentScale >= 1.0f && currentScale <= 1.5f -> 0.50f // CRITICAL FIX: For moderate zoom (135-150%), be lenient to prevent white screen
|
|
1010
|
+
else -> 0.10f // For normal zoom, use 10% threshold
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
pageCache.forEach { (pageIndex, pair) ->
|
|
1014
|
+
val pageBatch = getBatchNumber(pageIndex)
|
|
1015
|
+
val bitmapScale = pair.second
|
|
1016
|
+
val scaleDiff = kotlin.math.abs(bitmapScale - currentScale)
|
|
1017
|
+
|
|
1018
|
+
// Keep pages if they're in nearby batches OR if they're at the current scale (max zoom)
|
|
1019
|
+
// This prevents clearing pages at max zoom when scrolling
|
|
1020
|
+
if (batchesToKeep.contains(pageBatch) || scaleDiff < scaleDiffThreshold) {
|
|
1021
|
+
pagesToKeep[pageIndex] = pair
|
|
1022
|
+
} else {
|
|
1023
|
+
pagesToRemove.add(pair.first)
|
|
1024
|
+
batchesToUnload.add(pageBatch)
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// CRITICAL: Update cache on main thread FIRST, then wait before recycling
|
|
1029
|
+
// This ensures UI stops using bitmaps before they're recycled
|
|
1030
|
+
try {
|
|
1031
|
+
safeMainContext {
|
|
1032
|
+
pageCache = pagesToKeep
|
|
1033
|
+
loadedBatches = loadedBatches.filter { batchesToKeep.contains(it) }.toSet()
|
|
1034
|
+
if (isMaxZoom) {
|
|
1035
|
+
reloadVisiblePagesToken = reloadVisiblePagesToken + 1
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1039
|
+
// Expected cancellation - re-throw to propagate
|
|
1040
|
+
throw e
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// CRITICAL: Longer delay to ensure UI has stopped using removed bitmaps
|
|
1044
|
+
// This prevents race condition where bitmap is recycled while still being drawn
|
|
1045
|
+
try {
|
|
1046
|
+
delay(300)
|
|
1047
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1048
|
+
// Expected cancellation - re-throw to propagate
|
|
1049
|
+
throw e
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Safely recycle removed bitmaps - double check they're not in cache
|
|
1053
|
+
pagesToRemove.forEach { bitmap ->
|
|
1054
|
+
try {
|
|
1055
|
+
// CRITICAL: Check if bitmap is still in cache (shouldn't be, but double-check)
|
|
1056
|
+
val stillInCache = safeMainContext {
|
|
1057
|
+
pageCache.values.any { it.first == bitmap }
|
|
1058
|
+
} ?: false
|
|
1059
|
+
|
|
1060
|
+
if (stillInCache) {
|
|
1061
|
+
return@forEach
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Don't recycle if still in active use
|
|
1065
|
+
if (activeBitmaps.contains(bitmap)) {
|
|
1066
|
+
return@forEach
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Final check before recycling
|
|
1070
|
+
if (!bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0) {
|
|
1071
|
+
bitmap.recycle()
|
|
1072
|
+
} else {
|
|
1073
|
+
}
|
|
1074
|
+
} catch (e: Exception) {
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1079
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
1080
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
1081
|
+
// Expected when composable leaves composition - not an error
|
|
1082
|
+
} else {
|
|
1083
|
+
// Other cancellation - also expected
|
|
1084
|
+
}
|
|
1085
|
+
throw e
|
|
1086
|
+
} catch (e: Exception) {
|
|
1087
|
+
// Only log actual errors, not cancellations
|
|
1088
|
+
e.printStackTrace()
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ========================================================================
|
|
1094
|
+
// SECTION 7: CLEANUP AND RESOURCE MANAGEMENT
|
|
1095
|
+
// ========================================================================
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Cleanup - Recycles bitmaps and closes PDF renderer when composable is disposed
|
|
1099
|
+
*
|
|
1100
|
+
* This ensures proper resource cleanup when the viewer is closed.
|
|
1101
|
+
*/
|
|
1102
|
+
DisposableEffect(Unit) {
|
|
1103
|
+
onDispose {
|
|
1104
|
+
|
|
1105
|
+
// Close render queue
|
|
1106
|
+
try {
|
|
1107
|
+
renderQueue.close()
|
|
1108
|
+
} catch (e: Exception) {
|
|
1109
|
+
e.printStackTrace()
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Recycle all cached bitmaps
|
|
1113
|
+
pageCache.values.forEach { (bitmap, _) ->
|
|
1114
|
+
try {
|
|
1115
|
+
activeBitmaps.remove(bitmap)
|
|
1116
|
+
if (!bitmap.isRecycled) {
|
|
1117
|
+
bitmap.recycle()
|
|
1118
|
+
}
|
|
1119
|
+
} catch (e: Exception) {
|
|
1120
|
+
e.printStackTrace()
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Recycle any remaining active bitmaps
|
|
1125
|
+
activeBitmaps.forEach { bitmap ->
|
|
1126
|
+
try {
|
|
1127
|
+
if (!bitmap.isRecycled) {
|
|
1128
|
+
bitmap.recycle()
|
|
1129
|
+
}
|
|
1130
|
+
} catch (e: Exception) {
|
|
1131
|
+
e.printStackTrace()
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
activeBitmaps.clear()
|
|
1135
|
+
renderingPages.clear()
|
|
1136
|
+
|
|
1137
|
+
// Close PDF renderer and file descriptor
|
|
1138
|
+
try {
|
|
1139
|
+
pdfRenderer?.close()
|
|
1140
|
+
pdfRenderer = null
|
|
1141
|
+
} catch (e: Exception) {
|
|
1142
|
+
e.printStackTrace()
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
try {
|
|
1146
|
+
pfd?.close()
|
|
1147
|
+
pfd = null
|
|
1148
|
+
} catch (e: Exception) {
|
|
1149
|
+
e.printStackTrace()
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// ========================================================================
|
|
1156
|
+
// SECTION 8: PDF INITIALIZATION AND LOADING
|
|
1157
|
+
// ========================================================================
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Initial PDF Load - Opens PDF and pre-loads first few batches
|
|
1161
|
+
*
|
|
1162
|
+
* This LaunchedEffect runs when the URI changes. It:
|
|
1163
|
+
* - Opens the PDF file
|
|
1164
|
+
* - Creates the PdfRenderer
|
|
1165
|
+
* - Pre-loads first 4 batches (60 pages) for smooth initial experience
|
|
1166
|
+
* - Switches to on-demand loading for remaining pages
|
|
1167
|
+
*/
|
|
1168
|
+
LaunchedEffect(uri) {
|
|
1169
|
+
isInitialLoading = true
|
|
1170
|
+
loadingProgress = 0
|
|
1171
|
+
loadingStatus = "Opening PDF..."
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
val contentResolver = context.contentResolver
|
|
1175
|
+
val tempPfd = contentResolver.openFileDescriptor(uri, "r")
|
|
1176
|
+
|
|
1177
|
+
if (tempPfd == null) {
|
|
1178
|
+
Log.e("PdfRenderer", "Failed to open PDF file")
|
|
1179
|
+
isInitialLoading = false
|
|
1180
|
+
return@LaunchedEffect
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Store PDF renderer and file descriptor
|
|
1184
|
+
pfd = tempPfd
|
|
1185
|
+
pdfRenderer = PdfRenderer(tempPfd)
|
|
1186
|
+
pageCount = pdfRenderer!!.pageCount
|
|
1187
|
+
|
|
1188
|
+
loadingStatus = "Preparing PDF..."
|
|
1189
|
+
// Initialize lastRenderedScale to default scale (100%)
|
|
1190
|
+
lastRenderedScale = scale.coerceIn(0.3f, maxZoom)
|
|
1191
|
+
|
|
1192
|
+
// Hybrid approach: Pre-load first few batches, then load rest on-demand
|
|
1193
|
+
// This prevents OOM for large PDFs while still providing smooth initial experience
|
|
1194
|
+
withContext(Dispatchers.Default) {
|
|
1195
|
+
val totalBatches = (pageCount + batchSize - 1) / batchSize
|
|
1196
|
+
val initialBatchesToLoad = minOf(4, totalBatches) // Pre-load first 4 batches (60 pages max)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
// Pre-load initial batches sequentially
|
|
1200
|
+
for (batchNum in 0 until initialBatchesToLoad) {
|
|
1201
|
+
val batchStart = batchNum * batchSize
|
|
1202
|
+
val batchEnd = minOf((batchNum + 1) * batchSize - 1, pageCount - 1)
|
|
1203
|
+
|
|
1204
|
+
loadingStatus = "Loading pages ${batchStart + 1}-${batchEnd + 1}..."
|
|
1205
|
+
loadingProgress = ((batchNum + 1) * 100 / initialBatchesToLoad).coerceAtMost(90)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
// Load this batch
|
|
1209
|
+
loadBatch(batchNum, scale, priority = 0)
|
|
1210
|
+
|
|
1211
|
+
// Wait for batch to complete
|
|
1212
|
+
var batchComplete = false
|
|
1213
|
+
var attempts = 0
|
|
1214
|
+
while (!batchComplete && attempts < 1000) {
|
|
1215
|
+
delay(10)
|
|
1216
|
+
val allPagesInBatchLoaded = (batchStart..batchEnd).all { pageIndex ->
|
|
1217
|
+
pageCache.containsKey(pageIndex)
|
|
1218
|
+
}
|
|
1219
|
+
if (allPagesInBatchLoaded) {
|
|
1220
|
+
batchComplete = true
|
|
1221
|
+
}
|
|
1222
|
+
attempts++
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Check memory before loading next batch
|
|
1226
|
+
val runtime = Runtime.getRuntime()
|
|
1227
|
+
val usedMemory = runtime.totalMemory() - runtime.freeMemory()
|
|
1228
|
+
val maxMemory = runtime.maxMemory()
|
|
1229
|
+
val memoryUsagePercent = (usedMemory * 100 / maxMemory).toInt()
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
// If memory usage is high, stop pre-loading and switch to on-demand
|
|
1233
|
+
if (memoryUsagePercent > 70) {
|
|
1234
|
+
break
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
delay(50) // Small delay between batches
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Mark initial batches as loaded
|
|
1241
|
+
val loadedBatchesSet = (0 until minOf(initialBatchesToLoad, totalBatches)).toSet()
|
|
1242
|
+
withContext(Dispatchers.Main) {
|
|
1243
|
+
loadedBatches = loadedBatchesSet
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
loadingProgress = 95
|
|
1247
|
+
|
|
1248
|
+
// More accurate loading status
|
|
1249
|
+
val pagesLoaded = pageCache.size
|
|
1250
|
+
val totalPages = pageCount
|
|
1251
|
+
if (pagesLoaded < totalPages) {
|
|
1252
|
+
loadingStatus = "Loaded $pagesLoaded of $totalPages pages. Remaining pages will load as you scroll."
|
|
1253
|
+
} else {
|
|
1254
|
+
loadingStatus = "Ready!"
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Small delay to show completion
|
|
1260
|
+
delay(100)
|
|
1261
|
+
|
|
1262
|
+
isInitialLoading = false
|
|
1263
|
+
|
|
1264
|
+
// Log loading status
|
|
1265
|
+
val pagesLoaded = pageCache.size
|
|
1266
|
+
val totalPages = pageCount
|
|
1267
|
+
if (pagesLoaded < totalPages) {
|
|
1268
|
+
Log.i("PdfRenderer", "PDF opened: $pagesLoaded of $totalPages pages loaded. Scroll to load more.")
|
|
1269
|
+
} else {
|
|
1270
|
+
Log.i("PdfRenderer", "PDF loaded: $pageCount pages ready")
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
} catch (e: OutOfMemoryError) {
|
|
1274
|
+
e.printStackTrace()
|
|
1275
|
+
|
|
1276
|
+
// Clear cache and trigger GC
|
|
1277
|
+
pageCache = emptyMap()
|
|
1278
|
+
activeBitmaps.clear()
|
|
1279
|
+
renderingPages.clear()
|
|
1280
|
+
queuedPages.value = emptySet()
|
|
1281
|
+
System.gc()
|
|
1282
|
+
|
|
1283
|
+
Log.e("PdfRenderer", "Out of memory loading PDF. Switching to on-demand loading.")
|
|
1284
|
+
|
|
1285
|
+
// Still show viewer, but with on-demand loading only
|
|
1286
|
+
isInitialLoading = false
|
|
1287
|
+
|
|
1288
|
+
} catch (e: SecurityException) {
|
|
1289
|
+
e.printStackTrace()
|
|
1290
|
+
Log.e("PdfRenderer", "Permission denied: Cannot access PDF file", e)
|
|
1291
|
+
isInitialLoading = false
|
|
1292
|
+
|
|
1293
|
+
} catch (e: java.io.IOException) {
|
|
1294
|
+
e.printStackTrace()
|
|
1295
|
+
Log.e("PdfRenderer", "IO Error: Cannot read PDF file", e)
|
|
1296
|
+
isInitialLoading = false
|
|
1297
|
+
|
|
1298
|
+
} catch (e: Exception) {
|
|
1299
|
+
e.printStackTrace()
|
|
1300
|
+
Log.e("PdfRenderer", "Error loading PDF: ${e.message}", e)
|
|
1301
|
+
isInitialLoading = false
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ========================================================================
|
|
1306
|
+
// SECTION 9: ZOOM CHANGE HANDLING
|
|
1307
|
+
// ========================================================================
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Zoom Change Handler - Debounced to avoid constant re-rendering
|
|
1311
|
+
*
|
|
1312
|
+
* This LaunchedEffect triggers when scale changes. It:
|
|
1313
|
+
* - Waits for zoom gesture to complete (debouncing)
|
|
1314
|
+
* - Clears render queue and cache at wrong scales
|
|
1315
|
+
* - Updates lastRenderedScale to prevent stale renders
|
|
1316
|
+
* - Allows pages to reload at new scale on-demand
|
|
1317
|
+
*
|
|
1318
|
+
* Uses scale as key so previous effect is cancelled when scale changes rapidly.
|
|
1319
|
+
*/
|
|
1320
|
+
LaunchedEffect(scale) {
|
|
1321
|
+
if (!isInitialLoading && pageCount > 0) {
|
|
1322
|
+
var crashContext = "ZoomHandlerStart"
|
|
1323
|
+
try {
|
|
1324
|
+
|
|
1325
|
+
crashContext = "ScaleValidation"
|
|
1326
|
+
// Validate scale values
|
|
1327
|
+
val currentScale = scale
|
|
1328
|
+
val currentLastRendered = lastRenderedScale
|
|
1329
|
+
|
|
1330
|
+
if (currentScale.isNaN() || currentScale.isInfinite() || currentScale <= 0f) {
|
|
1331
|
+
return@LaunchedEffect
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (currentLastRendered.isNaN() || currentLastRendered.isInfinite() || currentLastRendered <= 0f) {
|
|
1335
|
+
try {
|
|
1336
|
+
lastRenderedScale = currentScale
|
|
1337
|
+
} catch (e: Exception) {
|
|
1338
|
+
}
|
|
1339
|
+
return@LaunchedEffect
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
crashContext = "ScaleDifferenceCalculation"
|
|
1343
|
+
val scaleDifference = try {
|
|
1344
|
+
kotlin.math.abs(currentScale - currentLastRendered)
|
|
1345
|
+
} catch (e: Exception) {
|
|
1346
|
+
return@LaunchedEffect
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Reduced threshold from 0.08f to 0.03f (3%) for smoother zoom transitions
|
|
1350
|
+
if (scaleDifference >= 0.03f) {
|
|
1351
|
+
|
|
1352
|
+
crashContext = "WaitTimeCalculation"
|
|
1353
|
+
// If pinch zoom is active, wait longer for gesture to complete
|
|
1354
|
+
// For button clicks (Zoom In/Out), use much shorter wait time for smoother, faster response
|
|
1355
|
+
val waitTime = try {
|
|
1356
|
+
if (isPinchZoomActive || recentPinchZoom) {
|
|
1357
|
+
1200L // Wait 1.2 seconds for pinch gesture to complete or recent pinch zoom to settle
|
|
1358
|
+
} else {
|
|
1359
|
+
100L // Reduced from 300ms to 100ms for button clicks - much smoother response
|
|
1360
|
+
}
|
|
1361
|
+
} catch (e: Exception) {
|
|
1362
|
+
100L // Default to shorter wait for smoother response
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
crashContext = "ZoomInProgressFlag"
|
|
1366
|
+
// CRITICAL FIX: Don't set isZoomInProgress during active pinch zoom
|
|
1367
|
+
// This allows pages to be displayed with visual scaling during pinch gestures
|
|
1368
|
+
// For button clicks, set it briefly but allow visual scaling for smooth experience
|
|
1369
|
+
try {
|
|
1370
|
+
val pinchActive = isPinchZoomActive
|
|
1371
|
+
val recentPinch = recentPinchZoom
|
|
1372
|
+
if (!pinchActive && !recentPinch) {
|
|
1373
|
+
// Set isZoomInProgress for button clicks, but keep it brief for smooth visual scaling
|
|
1374
|
+
isZoomInProgress = true
|
|
1375
|
+
} else {
|
|
1376
|
+
// Pinch zoom is active or recent - cancel button zoom handling to prevent interference
|
|
1377
|
+
val reason = if (pinchActive) "Pinch zoom active" else "Recent pinch zoom"
|
|
1378
|
+
// Don't proceed with cache clearing or other button zoom logic
|
|
1379
|
+
return@LaunchedEffect
|
|
1380
|
+
}
|
|
1381
|
+
} catch (e: Exception) {
|
|
1382
|
+
// Continue anyway
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// CRITICAL FIX: Update lastRenderedScale IMMEDIATELY to prevent stale renders
|
|
1386
|
+
// This ensures that any renders queued at old scales will be rejected
|
|
1387
|
+
crashContext = "ImmediateScaleUpdate"
|
|
1388
|
+
try {
|
|
1389
|
+
lastRenderedScale = currentScale
|
|
1390
|
+
} catch (e: Exception) {
|
|
1391
|
+
// Continue anyway
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// CRITICAL FIX: Don't clear cache during active pinch zoom OR recent pinch zoom - keep all pages for visual scaling
|
|
1395
|
+
// Only clear cache for button/slider zoom (not pinch)
|
|
1396
|
+
crashContext = "ClearWrongScaleCache"
|
|
1397
|
+
try {
|
|
1398
|
+
val pinchActive = isPinchZoomActive
|
|
1399
|
+
val recentPinch = recentPinchZoom
|
|
1400
|
+
// Triple-check: Don't clear cache if pinch zoom is active OR recent pinch zoom OR button zoom is in progress
|
|
1401
|
+
// This prevents interference between button and pinch zoom AND prevents white screen after pinch zoom ends
|
|
1402
|
+
if (!pinchActive && !recentPinch && !isZoomInProgress) {
|
|
1403
|
+
// Only clear cache if NOT pinch zooming AND NOT recent pinch zoom AND NOT button zooming
|
|
1404
|
+
val currentCache = pageCache
|
|
1405
|
+
val isZoomingOut = currentScale < currentLastRendered
|
|
1406
|
+
|
|
1407
|
+
val pagesToKeep = currentCache.filter { (pageIndex, pair) ->
|
|
1408
|
+
val bitmapScale = pair.second
|
|
1409
|
+
val scaleDiff = kotlin.math.abs(bitmapScale - currentScale)
|
|
1410
|
+
|
|
1411
|
+
if (isZoomingOut) {
|
|
1412
|
+
// Zoom out: Keep pages that are larger (can be scaled down visually) OR within reasonable range
|
|
1413
|
+
// CRITICAL FIX: For minimum zoom (100% = 1.0f), be more lenient to prevent white screen
|
|
1414
|
+
// Allow pages at any scale that can be visually scaled down to minimum zoom
|
|
1415
|
+
val minZoomThreshold = if (currentScale <= 1.1f) {
|
|
1416
|
+
0.50f // For minimum zoom (100% = 1.0f), allow up to 50% difference (keeps pages at higher scales)
|
|
1417
|
+
} else {
|
|
1418
|
+
0.10f // For normal zoom out, use 10% threshold
|
|
1419
|
+
}
|
|
1420
|
+
bitmapScale >= currentScale * 0.9f || scaleDiff < minZoomThreshold || bitmapScale <= currentScale * 2.0f
|
|
1421
|
+
} else {
|
|
1422
|
+
// Zoom in: Keep pages that can be scaled up visually OR within reasonable range
|
|
1423
|
+
// For max zoom (e.g., 2.5f) and moderate zoom (135-150%), keep pages even if scale difference is large
|
|
1424
|
+
// This prevents white screen when zooming to max or moderate zoom levels
|
|
1425
|
+
val maxScaleDiff = when {
|
|
1426
|
+
currentScale > 1.5f -> 0.50f // For max zoom (>150%), be more lenient (50% difference allowed)
|
|
1427
|
+
currentScale >= 1.0f && currentScale <= 1.5f -> 0.50f // CRITICAL FIX: For moderate zoom (135-150%), be lenient to prevent white screen
|
|
1428
|
+
else -> 0.05f // For normal zoom, strict (5% difference)
|
|
1429
|
+
}
|
|
1430
|
+
scaleDiff < maxScaleDiff || bitmapScale <= currentScale * 1.5f
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
if (pagesToKeep.size < currentCache.size) {
|
|
1435
|
+
pageCache = pagesToKeep
|
|
1436
|
+
}
|
|
1437
|
+
} else {
|
|
1438
|
+
val reason = when {
|
|
1439
|
+
pinchActive -> "Pinch zoom active"
|
|
1440
|
+
recentPinch -> "Recent pinch zoom (keeping pages visible)"
|
|
1441
|
+
isZoomInProgress -> "Button zoom in progress"
|
|
1442
|
+
else -> "Unknown"
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
} catch (e: Exception) {
|
|
1446
|
+
// Continue anyway
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
crashContext = "ClearRenderQueue"
|
|
1450
|
+
try {
|
|
1451
|
+
// Clear render queue and stuck rendering states
|
|
1452
|
+
clearRenderQueue()
|
|
1453
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1454
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
1455
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
1456
|
+
// Expected when composable leaves composition - not an error
|
|
1457
|
+
} else {
|
|
1458
|
+
// Other cancellation - also expected
|
|
1459
|
+
}
|
|
1460
|
+
throw e
|
|
1461
|
+
} catch (e: Exception) {
|
|
1462
|
+
// Only log actual errors, not cancellations
|
|
1463
|
+
// Continue anyway - don't crash
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
crashContext = "ClearRenderingPages"
|
|
1467
|
+
try {
|
|
1468
|
+
// Clear all renderingPages to prevent stuck states (ConcurrentHashMap is thread-safe)
|
|
1469
|
+
renderingPages.clear()
|
|
1470
|
+
} catch (e: Exception) {
|
|
1471
|
+
// Continue anyway - don't crash
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
crashContext = "ClearQueuedPages"
|
|
1475
|
+
try {
|
|
1476
|
+
withContext(Dispatchers.Main) {
|
|
1477
|
+
queuedPages.value = emptySet()
|
|
1478
|
+
}
|
|
1479
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1480
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
1481
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
1482
|
+
// Expected when composable leaves composition - not an error
|
|
1483
|
+
} else {
|
|
1484
|
+
// Other cancellation - also expected
|
|
1485
|
+
}
|
|
1486
|
+
throw e
|
|
1487
|
+
} catch (e: Exception) {
|
|
1488
|
+
// Only log actual errors, not cancellations
|
|
1489
|
+
// Continue anyway - don't crash
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
crashContext = "WaitDelay"
|
|
1494
|
+
// Wait for user to finish gesture (longer for pinch zoom)
|
|
1495
|
+
try {
|
|
1496
|
+
delay(waitTime)
|
|
1497
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1498
|
+
throw e
|
|
1499
|
+
} catch (e: Exception) {
|
|
1500
|
+
isZoomInProgress = false
|
|
1501
|
+
return@LaunchedEffect
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
crashContext = "ScaleRecheck"
|
|
1505
|
+
// Check if scale changed again during the delay (cancellation check)
|
|
1506
|
+
val updatedScale = try {
|
|
1507
|
+
scale
|
|
1508
|
+
} catch (e: Exception) {
|
|
1509
|
+
isZoomInProgress = false
|
|
1510
|
+
return@LaunchedEffect
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
val updatedLastRendered = try {
|
|
1514
|
+
lastRenderedScale
|
|
1515
|
+
} catch (e: Exception) {
|
|
1516
|
+
isZoomInProgress = false
|
|
1517
|
+
return@LaunchedEffect
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
crashContext = "ScaleValidationAfterWait"
|
|
1521
|
+
// Validate updated values
|
|
1522
|
+
if (updatedScale.isNaN() || updatedScale.isInfinite() || updatedScale <= 0f ||
|
|
1523
|
+
updatedLastRendered.isNaN() || updatedLastRendered.isInfinite() || updatedLastRendered <= 0f) {
|
|
1524
|
+
try {
|
|
1525
|
+
isZoomInProgress = false
|
|
1526
|
+
} catch (e: Exception) {
|
|
1527
|
+
}
|
|
1528
|
+
return@LaunchedEffect
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
crashContext = "PinchZoomStillActiveCheck"
|
|
1532
|
+
// Check if pinch zoom is still active - if so, wait more
|
|
1533
|
+
if (isPinchZoomActive) {
|
|
1534
|
+
try {
|
|
1535
|
+
delay(500)
|
|
1536
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1537
|
+
throw e
|
|
1538
|
+
} catch (e: Exception) {
|
|
1539
|
+
isZoomInProgress = false
|
|
1540
|
+
return@LaunchedEffect
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
crashContext = "FinalScaleCheck"
|
|
1544
|
+
// Re-check scale after additional wait
|
|
1545
|
+
val finalScale = try {
|
|
1546
|
+
scale
|
|
1547
|
+
} catch (e: Exception) {
|
|
1548
|
+
isZoomInProgress = false
|
|
1549
|
+
return@LaunchedEffect
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (finalScale.isNaN() || finalScale.isInfinite() || finalScale <= 0f) {
|
|
1553
|
+
try {
|
|
1554
|
+
isZoomInProgress = false
|
|
1555
|
+
} catch (e: Exception) {
|
|
1556
|
+
}
|
|
1557
|
+
return@LaunchedEffect
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
val scaleChange = try {
|
|
1561
|
+
kotlin.math.abs(finalScale - updatedScale)
|
|
1562
|
+
} catch (e: Exception) {
|
|
1563
|
+
0f
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
if (scaleChange > 0.05f) {
|
|
1567
|
+
// Scale changed significantly, restart
|
|
1568
|
+
return@LaunchedEffect
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
crashContext = "ScaleSettledCheck"
|
|
1573
|
+
// Check if scale changed again during the delay
|
|
1574
|
+
val finalScaleDiff = try {
|
|
1575
|
+
kotlin.math.abs(updatedScale - updatedLastRendered)
|
|
1576
|
+
} catch (e: Exception) {
|
|
1577
|
+
0f
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Reduced threshold from 0.08f to 0.03f for smoother transitions
|
|
1581
|
+
if (finalScaleDiff < 0.03f) {
|
|
1582
|
+
// Scale settled back, no need to re-render
|
|
1583
|
+
try {
|
|
1584
|
+
isZoomInProgress = false
|
|
1585
|
+
} catch (e: Exception) {
|
|
1586
|
+
}
|
|
1587
|
+
return@LaunchedEffect
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
crashContext = "UpdateLastRenderedScale"
|
|
1592
|
+
// Note: lastRenderedScale was already updated immediately when zoom started
|
|
1593
|
+
// This is just a confirmation/validation step
|
|
1594
|
+
try {
|
|
1595
|
+
// Double-check scale hasn't changed during wait
|
|
1596
|
+
val finalScale = scale
|
|
1597
|
+
if (kotlin.math.abs(finalScale - updatedScale) > 0.01f) {
|
|
1598
|
+
lastRenderedScale = finalScale
|
|
1599
|
+
} else {
|
|
1600
|
+
lastRenderedScale = updatedScale
|
|
1601
|
+
}
|
|
1602
|
+
} catch (e: Exception) {
|
|
1603
|
+
isZoomInProgress = false
|
|
1604
|
+
return@LaunchedEffect
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
crashContext = "ClearLoadedBatches"
|
|
1608
|
+
// Clear loaded batches so pages reload at new scale
|
|
1609
|
+
try {
|
|
1610
|
+
loadedBatches = emptySet()
|
|
1611
|
+
} catch (e: Exception) {
|
|
1612
|
+
// Continue anyway
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Don't queue ALL pages - let scroll handler load them on-demand
|
|
1616
|
+
// This prevents memory issues and conflicts
|
|
1617
|
+
|
|
1618
|
+
crashContext = "ResetZoomInProgress"
|
|
1619
|
+
// Reset zoom flag immediately so scroll handler can start loading
|
|
1620
|
+
try {
|
|
1621
|
+
isZoomInProgress = false
|
|
1622
|
+
} catch (e: Exception) {
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
crashContext = "FinalDelay"
|
|
1626
|
+
// Minimal delay to let UI update - reduced for faster response and smoother zoom
|
|
1627
|
+
try {
|
|
1628
|
+
delay(30) // Reduced from 50ms to 30ms for even faster, smoother page reload
|
|
1629
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1630
|
+
throw e
|
|
1631
|
+
} catch (e: Exception) {
|
|
1632
|
+
// Continue anyway
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Note: Scroll handler will automatically reload visible pages when lastRenderedScale changes
|
|
1636
|
+
// The reduced delay (30ms) ensures pages reload quickly after zoom, preventing white screen
|
|
1637
|
+
// Visual scaling ensures smooth transitions even before re-render completes
|
|
1638
|
+
}
|
|
1639
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
1640
|
+
// Expected when scale changes rapidly - previous effect cancelled
|
|
1641
|
+
throw e
|
|
1642
|
+
} catch (e: OutOfMemoryError) {
|
|
1643
|
+
System.gc()
|
|
1644
|
+
// Reset zoom state on error
|
|
1645
|
+
try {
|
|
1646
|
+
isZoomInProgress = false
|
|
1647
|
+
} catch (ex: Exception) {
|
|
1648
|
+
}
|
|
1649
|
+
} catch (e: Exception) {
|
|
1650
|
+
// Reset zoom state on error
|
|
1651
|
+
try {
|
|
1652
|
+
isZoomInProgress = false
|
|
1653
|
+
} catch (ex: Exception) {
|
|
1654
|
+
}
|
|
1655
|
+
} catch (e: Throwable) {
|
|
1656
|
+
// Reset zoom state on error
|
|
1657
|
+
try {
|
|
1658
|
+
isZoomInProgress = false
|
|
1659
|
+
} catch (ex: Exception) {
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ========================================================================
|
|
1666
|
+
// SECTION 10: UI COMPOSITION
|
|
1667
|
+
// ========================================================================
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Helper function to load image from URL, local file, or fallback to drawable
|
|
1671
|
+
*/
|
|
1672
|
+
@Composable
|
|
1673
|
+
fun loadIconImage(imageUrl: String?, drawableName: String, modifier: Modifier, tint: Color, contentDescription: String) {
|
|
1674
|
+
val context = LocalContext.current
|
|
1675
|
+
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
|
1676
|
+
|
|
1677
|
+
LaunchedEffect(imageUrl) {
|
|
1678
|
+
if (!imageUrl.isNullOrEmpty()) {
|
|
1679
|
+
// Load from URL or local file
|
|
1680
|
+
bitmap = withContext(Dispatchers.IO) {
|
|
1681
|
+
try {
|
|
1682
|
+
val result = when {
|
|
1683
|
+
// Handle React Native resource URIs (res://drawable/... or res:/drawable/...)
|
|
1684
|
+
imageUrl.startsWith("res://") || imageUrl.startsWith("res:/") -> {
|
|
1685
|
+
try {
|
|
1686
|
+
// Extract resource name from URI (e.g., "res://drawable/icon_name" -> "icon_name")
|
|
1687
|
+
val resourceName = imageUrl.substringAfterLast("/").substringBefore(".")
|
|
1688
|
+
val resourceId = context.resources.getIdentifier(resourceName, "drawable", context.packageName)
|
|
1689
|
+
if (resourceId != 0) {
|
|
1690
|
+
BitmapFactory.decodeResource(context.resources, resourceId)
|
|
1691
|
+
} else {
|
|
1692
|
+
null
|
|
1693
|
+
}
|
|
1694
|
+
} catch (e: Exception) {
|
|
1695
|
+
null
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
// Handle asset URIs (file:///android_asset/...)
|
|
1699
|
+
imageUrl.startsWith("file:///android_asset/") -> {
|
|
1700
|
+
val assetPath = imageUrl.removePrefix("file:///android_asset/")
|
|
1701
|
+
try {
|
|
1702
|
+
context.assets.open(assetPath).use { inputStream ->
|
|
1703
|
+
BitmapFactory.decodeStream(inputStream)
|
|
1704
|
+
}
|
|
1705
|
+
} catch (e: Exception) {
|
|
1706
|
+
null
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
// Handle local file URIs (file://...)
|
|
1710
|
+
imageUrl.startsWith("file://") -> {
|
|
1711
|
+
val filePath = imageUrl.removePrefix("file://")
|
|
1712
|
+
BitmapFactory.decodeFile(filePath)
|
|
1713
|
+
}
|
|
1714
|
+
// Handle content:// URIs (content provider)
|
|
1715
|
+
imageUrl.startsWith("content://") -> {
|
|
1716
|
+
try {
|
|
1717
|
+
context.contentResolver.openInputStream(android.net.Uri.parse(imageUrl))?.use { inputStream ->
|
|
1718
|
+
BitmapFactory.decodeStream(inputStream)
|
|
1719
|
+
}
|
|
1720
|
+
} catch (e: Exception) {
|
|
1721
|
+
null
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
// Handle http/https URLs
|
|
1725
|
+
imageUrl.startsWith("http://") || imageUrl.startsWith("https://") -> {
|
|
1726
|
+
try {
|
|
1727
|
+
val url = URL(imageUrl)
|
|
1728
|
+
val connection = url.openConnection()
|
|
1729
|
+
connection.connectTimeout = 5000
|
|
1730
|
+
connection.readTimeout = 5000
|
|
1731
|
+
connection.connect()
|
|
1732
|
+
val inputStream = connection.getInputStream()
|
|
1733
|
+
BitmapFactory.decodeStream(inputStream)
|
|
1734
|
+
} catch (e: Exception) {
|
|
1735
|
+
null
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
// Try as numeric resource ID (React Native sometimes returns just the ID)
|
|
1739
|
+
imageUrl.matches(Regex("^\\d+$")) -> {
|
|
1740
|
+
try {
|
|
1741
|
+
val resourceId = imageUrl.toInt()
|
|
1742
|
+
BitmapFactory.decodeResource(context.resources, resourceId)
|
|
1743
|
+
} catch (e: Exception) {
|
|
1744
|
+
null
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
// Try as asset name directly (React Native bundled assets)
|
|
1748
|
+
else -> {
|
|
1749
|
+
try {
|
|
1750
|
+
// First try as drawable resource
|
|
1751
|
+
val resourceId = context.resources.getIdentifier(imageUrl, "drawable", context.packageName)
|
|
1752
|
+
if (resourceId != 0) {
|
|
1753
|
+
BitmapFactory.decodeResource(context.resources, resourceId)
|
|
1754
|
+
} else {
|
|
1755
|
+
// Try as asset
|
|
1756
|
+
val assetPaths = listOf(
|
|
1757
|
+
imageUrl,
|
|
1758
|
+
"drawable-mdpi/$imageUrl",
|
|
1759
|
+
"drawable-hdpi/$imageUrl",
|
|
1760
|
+
"drawable-xhdpi/$imageUrl",
|
|
1761
|
+
"drawable-xxhdpi/$imageUrl"
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
var loadedBitmap: android.graphics.Bitmap? = null
|
|
1765
|
+
assetLoop@ for (path in assetPaths) {
|
|
1766
|
+
try {
|
|
1767
|
+
val inputStream = context.assets.open(path)
|
|
1768
|
+
loadedBitmap = BitmapFactory.decodeStream(inputStream)
|
|
1769
|
+
inputStream.close()
|
|
1770
|
+
if (loadedBitmap != null) {
|
|
1771
|
+
break@assetLoop
|
|
1772
|
+
}
|
|
1773
|
+
} catch (e: Exception) {
|
|
1774
|
+
// Try next path
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
loadedBitmap ?: BitmapFactory.decodeFile(imageUrl)
|
|
1778
|
+
}
|
|
1779
|
+
} catch (e: Exception) {
|
|
1780
|
+
// Try as file path
|
|
1781
|
+
BitmapFactory.decodeFile(imageUrl)
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
if (result != null) {
|
|
1786
|
+
} else {
|
|
1787
|
+
}
|
|
1788
|
+
result
|
|
1789
|
+
} catch (e: Exception) {
|
|
1790
|
+
null
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
} else {
|
|
1794
|
+
// Fallback to drawable
|
|
1795
|
+
val resourceId = context.resources.getIdentifier(drawableName, "drawable", context.packageName)
|
|
1796
|
+
if (resourceId != 0) {
|
|
1797
|
+
bitmap = BitmapFactory.decodeResource(context.resources, resourceId)
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
bitmap?.let {
|
|
1803
|
+
Image(
|
|
1804
|
+
bitmap = it.asImageBitmap(),
|
|
1805
|
+
contentDescription = contentDescription,
|
|
1806
|
+
modifier = modifier,
|
|
1807
|
+
colorFilter = ColorFilter.tint(tint)
|
|
1808
|
+
)
|
|
1809
|
+
} ?: run {
|
|
1810
|
+
// Fallback to drawable if URL loading failed
|
|
1811
|
+
val resourceId = context.resources.getIdentifier(drawableName, "drawable", context.packageName)
|
|
1812
|
+
if (resourceId != 0) {
|
|
1813
|
+
Image(
|
|
1814
|
+
painter = painterResource(id = resourceId),
|
|
1815
|
+
contentDescription = contentDescription,
|
|
1816
|
+
modifier = modifier,
|
|
1817
|
+
colorFilter = ColorFilter.tint(tint)
|
|
1818
|
+
)
|
|
1819
|
+
} else {
|
|
1820
|
+
// If drawable not found, show empty space (or a placeholder)
|
|
1821
|
+
Box(modifier = modifier)
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/**
|
|
1827
|
+
* Zoom In Icon - Loads from URL or drawable resource
|
|
1828
|
+
*/
|
|
1829
|
+
@Composable
|
|
1830
|
+
fun ZoomInIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
|
|
1831
|
+
loadIconImage(imageUrl, "zoom_in_enable", modifier, tint, "Zoom In")
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* Zoom Out Icon - Loads from URL or drawable resource
|
|
1836
|
+
*/
|
|
1837
|
+
@Composable
|
|
1838
|
+
fun ZoomOutIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
|
|
1839
|
+
loadIconImage(imageUrl, "zoom_out_enable", modifier, tint, "Zoom Out")
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Fullscreen Icon - Loads from URL or drawable resource
|
|
1844
|
+
*/
|
|
1845
|
+
@Composable
|
|
1846
|
+
fun FullscreenIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
|
|
1847
|
+
loadIconImage(imageUrl, "full_screen", modifier, tint, "Fullscreen")
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* Minimize Icon - Loads from URL or drawable resource
|
|
1852
|
+
*/
|
|
1853
|
+
@Composable
|
|
1854
|
+
fun MinimizeIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
|
|
1855
|
+
loadIconImage(imageUrl, "minimize_screen", modifier, tint, "Minimize")
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Left Layout Sidebar Icon - Loads from URL or drawable resource
|
|
1860
|
+
*/
|
|
1861
|
+
@Composable
|
|
1862
|
+
fun LeftLayoutSidebarIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
|
|
1863
|
+
loadIconImage(imageUrl, "left_layout_sidebar_enable", modifier, tint, "Left Layout")
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* Right Layout Sidebar Icon - Loads from URL or drawable resource
|
|
1868
|
+
*/
|
|
1869
|
+
@Composable
|
|
1870
|
+
fun RightLayoutSidebarIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
|
|
1871
|
+
loadIconImage(imageUrl, "right_layout_sidebar_enable", modifier, tint, "Right Layout")
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Custom Reset Icon (circular arrow)
|
|
1876
|
+
*/
|
|
1877
|
+
@Composable
|
|
1878
|
+
fun ResetIcon(modifier: Modifier = Modifier, tint: Color = Color(0x66000000)) {
|
|
1879
|
+
Canvas(modifier = modifier.size(24.dp)) {
|
|
1880
|
+
val centerX = size.width / 2
|
|
1881
|
+
val centerY = size.height / 2
|
|
1882
|
+
val radius = size.minDimension * 0.3f
|
|
1883
|
+
|
|
1884
|
+
// Draw circular arrow (reset/refresh icon)
|
|
1885
|
+
val path = Path()
|
|
1886
|
+
val startAngle = 45f
|
|
1887
|
+
val sweepAngle = 270f
|
|
1888
|
+
|
|
1889
|
+
// Create Rect for arc
|
|
1890
|
+
val oval = androidx.compose.ui.geometry.Rect(
|
|
1891
|
+
left = centerX - radius,
|
|
1892
|
+
top = centerY - radius,
|
|
1893
|
+
right = centerX + radius,
|
|
1894
|
+
bottom = centerY + radius
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
// Draw arc
|
|
1898
|
+
path.addArc(
|
|
1899
|
+
oval = oval,
|
|
1900
|
+
startAngleDegrees = startAngle,
|
|
1901
|
+
sweepAngleDegrees = sweepAngle
|
|
1902
|
+
)
|
|
1903
|
+
|
|
1904
|
+
// Draw arrow head
|
|
1905
|
+
val arrowAngle = Math.toRadians((startAngle + sweepAngle).toDouble())
|
|
1906
|
+
val arrowX = centerX + radius * kotlin.math.cos(arrowAngle).toFloat()
|
|
1907
|
+
val arrowY = centerY + radius * kotlin.math.sin(arrowAngle).toFloat()
|
|
1908
|
+
val arrowSize = size.minDimension * 0.1f
|
|
1909
|
+
|
|
1910
|
+
// Arrow pointing right
|
|
1911
|
+
path.moveTo(arrowX, arrowY)
|
|
1912
|
+
path.lineTo(arrowX - arrowSize, arrowY - arrowSize)
|
|
1913
|
+
path.moveTo(arrowX, arrowY)
|
|
1914
|
+
path.lineTo(arrowX - arrowSize, arrowY + arrowSize)
|
|
1915
|
+
|
|
1916
|
+
drawPath(path, color = tint, style = Stroke(width = 2.dp.toPx()))
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Main UI Scaffold
|
|
1922
|
+
*
|
|
1923
|
+
* Contains:
|
|
1924
|
+
* - PDF page list (LazyColumn)
|
|
1925
|
+
* - Gesture handling (pinch zoom)
|
|
1926
|
+
* - Top bar with back button and text
|
|
1927
|
+
* - Bottom bar with zoom controls
|
|
1928
|
+
* - Loading screen
|
|
1929
|
+
*/
|
|
1930
|
+
Scaffold(
|
|
1931
|
+
|
|
1932
|
+
topBar = {
|
|
1933
|
+
if (!isInitialLoading && pageCount > 0 && backButtonText != null && isFullScreenState) {
|
|
1934
|
+
TopAppBar(
|
|
1935
|
+
backgroundColor = Color.Transparent,
|
|
1936
|
+
elevation = 0.dp,
|
|
1937
|
+
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
|
1938
|
+
) {
|
|
1939
|
+
Row(
|
|
1940
|
+
modifier = Modifier.fillMaxWidth(),
|
|
1941
|
+
horizontalArrangement = Arrangement.Start,
|
|
1942
|
+
verticalAlignment = Alignment.CenterVertically
|
|
1943
|
+
) {
|
|
1944
|
+
// Left side: Back Button (Red pill-shaped button with left arrow and "Topics" text)
|
|
1945
|
+
Box(
|
|
1946
|
+
modifier = Modifier
|
|
1947
|
+
.background(
|
|
1948
|
+
Color(0xFFF97A60.toInt()), // Red background, full opacity
|
|
1949
|
+
shape = RoundedCornerShape(30.dp)
|
|
1950
|
+
)
|
|
1951
|
+
.clickable(
|
|
1952
|
+
onClick = {
|
|
1953
|
+
// Exit fullscreen mode when back button is clicked
|
|
1954
|
+
isFullScreenState = false
|
|
1955
|
+
onFullScreenChange?.invoke(false)
|
|
1956
|
+
}
|
|
1957
|
+
)
|
|
1958
|
+
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
1959
|
+
contentAlignment = Alignment.Center
|
|
1960
|
+
) {
|
|
1961
|
+
Row(
|
|
1962
|
+
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
1963
|
+
verticalAlignment = Alignment.CenterVertically
|
|
1964
|
+
) {
|
|
1965
|
+
// Left Arrow Icon (gray)
|
|
1966
|
+
Icon(
|
|
1967
|
+
imageVector = Icons.Default.ArrowBack,
|
|
1968
|
+
contentDescription = "Back",
|
|
1969
|
+
tint = Color(0xFFFFFFFF),
|
|
1970
|
+
modifier = Modifier.size(20.dp)
|
|
1971
|
+
)
|
|
1972
|
+
// Back Button Text (e.g., "Topics")
|
|
1973
|
+
Text(
|
|
1974
|
+
text = backButtonText,
|
|
1975
|
+
color = Color.White,
|
|
1976
|
+
style = MaterialTheme.typography.body1,
|
|
1977
|
+
fontWeight = FontWeight.Medium
|
|
1978
|
+
)
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// Right side: Header Text (e.g., "Book") - positioned right next to button
|
|
1983
|
+
if (headerText != null) {
|
|
1984
|
+
Text(
|
|
1985
|
+
text = headerText,
|
|
1986
|
+
color = Color.Black,
|
|
1987
|
+
style = MaterialTheme.typography.body1,
|
|
1988
|
+
fontWeight = FontWeight.Medium,
|
|
1989
|
+
modifier = Modifier.padding(start = 16.dp)
|
|
1990
|
+
)
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
},
|
|
1996
|
+
bottomBar = {
|
|
1997
|
+
if (!isInitialLoading && pageCount > 0) {
|
|
1998
|
+
// Toolbar with gray background when not fullscreen, transparent when fullscreen
|
|
1999
|
+
Box(
|
|
2000
|
+
modifier = Modifier
|
|
2001
|
+
.fillMaxWidth()
|
|
2002
|
+
.then(
|
|
2003
|
+
if (!isFullScreenState) {
|
|
2004
|
+
Modifier.background( Color(0x66000000) )
|
|
2005
|
+
} else {
|
|
2006
|
+
Modifier // No background when fullscreen
|
|
2007
|
+
}
|
|
2008
|
+
)
|
|
2009
|
+
.padding(horizontal = 8.dp, vertical = 12.dp),
|
|
2010
|
+
contentAlignment = if (isFullScreenState) Alignment.Center else Alignment.TopStart
|
|
2011
|
+
) {
|
|
2012
|
+
Row(
|
|
2013
|
+
modifier = if (isFullScreenState) {
|
|
2014
|
+
// Fullscreen: Center buttons with fixed spacing
|
|
2015
|
+
Modifier.wrapContentWidth()
|
|
2016
|
+
} else {
|
|
2017
|
+
// Split-screen: Full width with even distribution
|
|
2018
|
+
Modifier.fillMaxWidth()
|
|
2019
|
+
},
|
|
2020
|
+
horizontalArrangement = if (isFullScreenState) {
|
|
2021
|
+
// Fullscreen: Fixed 15.dp spacing between buttons
|
|
2022
|
+
Arrangement.spacedBy(25.dp)
|
|
2023
|
+
} else {
|
|
2024
|
+
// Split-screen: Even distribution across width
|
|
2025
|
+
Arrangement.SpaceEvenly
|
|
2026
|
+
},
|
|
2027
|
+
verticalAlignment = Alignment.CenterVertically
|
|
2028
|
+
) {
|
|
2029
|
+
//* pdf positen left Icon Button */
|
|
2030
|
+
if(!isFullScreenState) {
|
|
2031
|
+
Box(
|
|
2032
|
+
modifier = Modifier
|
|
2033
|
+
.size(48.dp)
|
|
2034
|
+
.clickable(
|
|
2035
|
+
enabled = !isLeftLayoutDisabled, // Disable when PDF is on left
|
|
2036
|
+
onClick = {
|
|
2037
|
+
if (!isLeftLayoutDisabled) {
|
|
2038
|
+
onLeftScreenChange?.invoke(true)
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
),
|
|
2042
|
+
contentAlignment = Alignment.Center
|
|
2043
|
+
) {
|
|
2044
|
+
LeftLayoutSidebarIcon(
|
|
2045
|
+
tint = if (isLeftLayoutDisabled) {
|
|
2046
|
+
Color(0xFF9E9E9E) // Lighter gray if disabled
|
|
2047
|
+
} else {
|
|
2048
|
+
if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
|
|
2049
|
+
},
|
|
2050
|
+
modifier = Modifier.size(iconSize),
|
|
2051
|
+
imageUrl = iconUrls?.get("leftLayout")
|
|
2052
|
+
)
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// ** pdf positen Right Icon Button (bordered style) */
|
|
2056
|
+
Box(
|
|
2057
|
+
modifier = Modifier
|
|
2058
|
+
.size(48.dp)
|
|
2059
|
+
.clickable(
|
|
2060
|
+
enabled = !isRightLayoutDisabled, // Disable when PDF is on right
|
|
2061
|
+
onClick = {
|
|
2062
|
+
if (!isRightLayoutDisabled) {
|
|
2063
|
+
onRightScreenChange?.invoke(true)
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
),
|
|
2067
|
+
contentAlignment = Alignment.Center
|
|
2068
|
+
) {
|
|
2069
|
+
RightLayoutSidebarIcon(
|
|
2070
|
+
tint = if (isRightLayoutDisabled) {
|
|
2071
|
+
Color(0xFF9E9E9E) // Lighter gray if disabled
|
|
2072
|
+
} else {
|
|
2073
|
+
if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
|
|
2074
|
+
},
|
|
2075
|
+
modifier = Modifier.size(iconSize),
|
|
2076
|
+
imageUrl = iconUrls?.get("rightLayout")
|
|
2077
|
+
)
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
// Zoom In Icon Button (bordered style)
|
|
2081
|
+
Box(
|
|
2082
|
+
modifier = Modifier
|
|
2083
|
+
.size(48.dp)
|
|
2084
|
+
.clickable(
|
|
2085
|
+
enabled = !isPinchZoomActive && !isZoomInDisabled, // Disable when at max zoom
|
|
2086
|
+
onClick = {
|
|
2087
|
+
if (!isPinchZoomActive && !isZoomInDisabled) {
|
|
2088
|
+
// Clamp to ensure we never exceed max zoom
|
|
2089
|
+
scale = (scale + zoomInStep).coerceAtMost(effectiveMaxZoom)
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
),
|
|
2093
|
+
contentAlignment = Alignment.Center
|
|
2094
|
+
) {
|
|
2095
|
+
ZoomInIcon(
|
|
2096
|
+
tint = if (isZoomInDisabled || isPinchZoomActive) {
|
|
2097
|
+
Color(0xFF9E9E9E) // Lighter gray if disabled
|
|
2098
|
+
} else {
|
|
2099
|
+
if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
|
|
2100
|
+
},
|
|
2101
|
+
modifier = Modifier.size(iconSize),
|
|
2102
|
+
imageUrl = iconUrls?.get("zoomIn")
|
|
2103
|
+
)
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Zoom Out Icon Button (bordered style)
|
|
2107
|
+
Box(
|
|
2108
|
+
modifier = Modifier
|
|
2109
|
+
.size(48.dp)
|
|
2110
|
+
.clickable(
|
|
2111
|
+
enabled = !isPinchZoomActive && !isZoomOutDisabled, // Disable when at min zoom (100%)
|
|
2112
|
+
onClick = {
|
|
2113
|
+
if (!isPinchZoomActive && !isZoomOutDisabled) {
|
|
2114
|
+
// Clamp to ensure we never go below 100% (1.0f)
|
|
2115
|
+
scale = (scale - zoomOutStep).coerceAtLeast(MIN_ZOOM)
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
),
|
|
2119
|
+
contentAlignment = Alignment.Center
|
|
2120
|
+
) {
|
|
2121
|
+
ZoomOutIcon(
|
|
2122
|
+
tint = if (isZoomOutDisabled || isPinchZoomActive) {
|
|
2123
|
+
Color(0xFF9E9E9E) // Lighter gray if disabled
|
|
2124
|
+
} else {
|
|
2125
|
+
if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
|
|
2126
|
+
},
|
|
2127
|
+
modifier = Modifier.size(iconSize),
|
|
2128
|
+
imageUrl = iconUrls?.get("zoomOut")
|
|
2129
|
+
)
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
// Full Screen / Minimize Icon Button (bordered style)
|
|
2134
|
+
// Shows minimize icon when fullscreen, fullscreen icon when not
|
|
2135
|
+
Box(
|
|
2136
|
+
modifier = Modifier
|
|
2137
|
+
.size(48.dp)
|
|
2138
|
+
.clickable(
|
|
2139
|
+
onClick = {
|
|
2140
|
+
// Toggle fullscreen state and notify parent
|
|
2141
|
+
val newFullScreenState = !isFullScreenState
|
|
2142
|
+
isFullScreenState = newFullScreenState
|
|
2143
|
+
onFullScreenChange?.invoke(newFullScreenState)
|
|
2144
|
+
}
|
|
2145
|
+
),
|
|
2146
|
+
contentAlignment = Alignment.Center
|
|
2147
|
+
) {
|
|
2148
|
+
// Show minimize icon when fullscreen, fullscreen icon when not
|
|
2149
|
+
if (isFullScreenState) {
|
|
2150
|
+
MinimizeIcon(
|
|
2151
|
+
tint = Color(0xFF424242), // Gray icon when fullscreen
|
|
2152
|
+
modifier = Modifier.size(iconSize),
|
|
2153
|
+
imageUrl = iconUrls?.get("minimizeScreen")
|
|
2154
|
+
)
|
|
2155
|
+
} else {
|
|
2156
|
+
FullscreenIcon(
|
|
2157
|
+
tint = Color.White, // White icon when not fullscreen
|
|
2158
|
+
modifier = Modifier.size(iconSize),
|
|
2159
|
+
imageUrl = iconUrls?.get("fullScreen")
|
|
2160
|
+
)
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
|
|
2167
|
+
// Reset Icon Button (bordered style)
|
|
2168
|
+
// Box(
|
|
2169
|
+
// modifier = Modifier
|
|
2170
|
+
// .size(48.dp)
|
|
2171
|
+
// .background(
|
|
2172
|
+
// Color(0xFFF5F5F5), // Light gray background
|
|
2173
|
+
// shape = RoundedCornerShape(8.dp)
|
|
2174
|
+
// )
|
|
2175
|
+
// .border(
|
|
2176
|
+
// 1.dp,
|
|
2177
|
+
// Color(0xFFE0E0E0), // Light border
|
|
2178
|
+
// shape = RoundedCornerShape(8.dp)
|
|
2179
|
+
// )
|
|
2180
|
+
// .clickable(
|
|
2181
|
+
// onClick = {
|
|
2182
|
+
// scale = 0.3f
|
|
2183
|
+
// }
|
|
2184
|
+
// ),
|
|
2185
|
+
// contentAlignment = Alignment.Center
|
|
2186
|
+
// ) {
|
|
2187
|
+
// ResetIcon(
|
|
2188
|
+
// tint = Color(0xFF424242), // Dark gray icon
|
|
2189
|
+
// modifier = Modifier.size(24.dp)
|
|
2190
|
+
// )
|
|
2191
|
+
// }
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
) { paddingValues ->
|
|
2197
|
+
Box(
|
|
2198
|
+
modifier = Modifier
|
|
2199
|
+
.fillMaxSize()
|
|
2200
|
+
.then(
|
|
2201
|
+
if (!isFullScreenState) {
|
|
2202
|
+
// CRITICAL: Clip ONLY TOP corners to match border radius (10.dp)
|
|
2203
|
+
// This ensures the entire container (border + content) is clipped consistently
|
|
2204
|
+
Modifier
|
|
2205
|
+
.clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomStart = 0.dp, bottomEnd = 0.dp))
|
|
2206
|
+
.background(Color.Transparent)
|
|
2207
|
+
} else {
|
|
2208
|
+
Modifier
|
|
2209
|
+
}
|
|
2210
|
+
)
|
|
2211
|
+
.then(
|
|
2212
|
+
if (!isFullScreenState) {
|
|
2213
|
+
Modifier.padding(
|
|
2214
|
+
top = 0.dp,
|
|
2215
|
+
bottom = paddingValues.calculateBottomPadding(),
|
|
2216
|
+
start = paddingValues.calculateStartPadding(LayoutDirection.Ltr),
|
|
2217
|
+
end = paddingValues.calculateEndPadding(LayoutDirection.Ltr)
|
|
2218
|
+
)
|
|
2219
|
+
} else {
|
|
2220
|
+
Modifier.padding(paddingValues)
|
|
2221
|
+
}
|
|
2222
|
+
)
|
|
2223
|
+
.drawBehind {
|
|
2224
|
+
// Draw gray border with rounded corners ONLY at TOP - straight bottom
|
|
2225
|
+
if (!isFullScreenState) {
|
|
2226
|
+
val cornerRadius = 10.dp.toPx()
|
|
2227
|
+
val strokeWidth = 20.dp.toPx()
|
|
2228
|
+
val halfStroke = strokeWidth / 2f
|
|
2229
|
+
|
|
2230
|
+
val path = Path().apply {
|
|
2231
|
+
// Start from bottom-left (straight corner)
|
|
2232
|
+
moveTo(halfStroke, size.height)
|
|
2233
|
+
|
|
2234
|
+
// Left border up to top-left corner
|
|
2235
|
+
lineTo(halfStroke, cornerRadius + halfStroke)
|
|
2236
|
+
|
|
2237
|
+
// Top-left rounded corner
|
|
2238
|
+
arcTo(
|
|
2239
|
+
rect = androidx.compose.ui.geometry.Rect(
|
|
2240
|
+
left = halfStroke,
|
|
2241
|
+
top = halfStroke,
|
|
2242
|
+
right = cornerRadius * 2 + halfStroke,
|
|
2243
|
+
bottom = cornerRadius * 2 + halfStroke
|
|
2244
|
+
),
|
|
2245
|
+
startAngleDegrees = 180f,
|
|
2246
|
+
sweepAngleDegrees = 90f,
|
|
2247
|
+
forceMoveTo = false
|
|
2248
|
+
)
|
|
2249
|
+
|
|
2250
|
+
// Top border
|
|
2251
|
+
lineTo(size.width - cornerRadius * 2 - halfStroke, halfStroke)
|
|
2252
|
+
|
|
2253
|
+
// Top-right rounded corner
|
|
2254
|
+
arcTo(
|
|
2255
|
+
rect = androidx.compose.ui.geometry.Rect(
|
|
2256
|
+
left = size.width - cornerRadius * 2 - halfStroke,
|
|
2257
|
+
top = halfStroke,
|
|
2258
|
+
right = size.width - halfStroke,
|
|
2259
|
+
bottom = cornerRadius * 2 + halfStroke
|
|
2260
|
+
),
|
|
2261
|
+
startAngleDegrees = 270f,
|
|
2262
|
+
sweepAngleDegrees = 90f,
|
|
2263
|
+
forceMoveTo = false
|
|
2264
|
+
)
|
|
2265
|
+
|
|
2266
|
+
// Right border straight down to bottom-right (NO rounded corner)
|
|
2267
|
+
lineTo(size.width - halfStroke, size.height)
|
|
2268
|
+
}
|
|
2269
|
+
drawPath(
|
|
2270
|
+
path = path,
|
|
2271
|
+
color = Color(0x66000000), // Gray border
|
|
2272
|
+
style = Stroke(width = strokeWidth)
|
|
2273
|
+
)
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
) {
|
|
2277
|
+
if (!isInitialLoading && pageCount > 0) {
|
|
2278
|
+
Column(modifier = Modifier.fillMaxSize()) {
|
|
2279
|
+
// Rendering Performance Stats Panel (in debug mode or when explicitly enabled)
|
|
2280
|
+
// TODO: Make this toggleable via a debug flag or settings
|
|
2281
|
+
val showStats = remember { mutableStateOf(false) } // Set to true to enable stats display
|
|
2282
|
+
if (showStats.value) {
|
|
2283
|
+
RenderingStatsPanel(
|
|
2284
|
+
renderingStats = renderingStats,
|
|
2285
|
+
showStats = true
|
|
2286
|
+
)
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
val lazyListState = rememberLazyListState()
|
|
2290
|
+
val horizontalScrollState = rememberScrollState()
|
|
2291
|
+
val gestureScope = rememberCoroutineScope()
|
|
2292
|
+
|
|
2293
|
+
// Get current visible page
|
|
2294
|
+
val currentPage = remember {
|
|
2295
|
+
derivedStateOf {
|
|
2296
|
+
lazyListState.firstVisibleItemIndex + 1
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// Scroll to initial page when PDF is loaded
|
|
2301
|
+
LaunchedEffect(isInitialLoading, pageCount, initialPageIndex) {
|
|
2302
|
+
if (!isInitialLoading && pageCount > 0 && initialPageIndex >= 0 && initialPageIndex < pageCount) {
|
|
2303
|
+
// Wait for the list to be ready and ensure the target batch is loaded
|
|
2304
|
+
delay(500)
|
|
2305
|
+
|
|
2306
|
+
// Load the batch containing the target page if not already loaded
|
|
2307
|
+
val targetBatch = getBatchNumber(initialPageIndex)
|
|
2308
|
+
if (!loadedBatches.contains(targetBatch)) {
|
|
2309
|
+
loadBatch(targetBatch, lastRenderedScale, priority = 0)
|
|
2310
|
+
|
|
2311
|
+
// Wait for batch to load (with timeout)
|
|
2312
|
+
var attempts = 0
|
|
2313
|
+
while (attempts < 50 && !loadedBatches.contains(targetBatch)) {
|
|
2314
|
+
delay(100)
|
|
2315
|
+
attempts++
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// Wait a bit more for the page to render
|
|
2320
|
+
delay(300)
|
|
2321
|
+
|
|
2322
|
+
try {
|
|
2323
|
+
// Scroll to the specified page index
|
|
2324
|
+
lazyListState.animateScrollToItem(
|
|
2325
|
+
index = initialPageIndex,
|
|
2326
|
+
scrollOffset = 0
|
|
2327
|
+
)
|
|
2328
|
+
} catch (e: Exception) {
|
|
2329
|
+
e.printStackTrace()
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// Retry mechanism for visible pages that failed to load
|
|
2335
|
+
LaunchedEffect(lazyListState.firstVisibleItemIndex) {
|
|
2336
|
+
delay(500) // Wait 1 second after scroll settles
|
|
2337
|
+
|
|
2338
|
+
// CRITICAL: Allow retry during recentPinchZoom to prevent white screen
|
|
2339
|
+
// Only skip during active zoom or active pinch zoom
|
|
2340
|
+
if (isZoomInProgress || isPinchZoomActive) {
|
|
2341
|
+
return@LaunchedEffect
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
val visiblePages = lazyListState.layoutInfo.visibleItemsInfo.map { it.index }
|
|
2345
|
+
val targetScale = lastRenderedScale
|
|
2346
|
+
|
|
2347
|
+
// Check visible pages and retry failed ones
|
|
2348
|
+
visiblePages.forEach { pageIndex ->
|
|
2349
|
+
val cached = pageCache[pageIndex]
|
|
2350
|
+
val isRendering = renderingPages.containsKey(pageIndex)
|
|
2351
|
+
val isQueued = queuedPages.value.contains(pageIndex)
|
|
2352
|
+
|
|
2353
|
+
// If page is not cached, not rendering, and not queued, retry it
|
|
2354
|
+
if (cached == null && !isRendering && !isQueued) {
|
|
2355
|
+
launch(Dispatchers.Default) {
|
|
2356
|
+
try {
|
|
2357
|
+
val request = RenderRequest(pageIndex, targetScale, priority = 0)
|
|
2358
|
+
renderQueue.send(request)
|
|
2359
|
+
withContext(Dispatchers.Main) {
|
|
2360
|
+
queuedPages.value = queuedPages.value + pageIndex
|
|
2361
|
+
}
|
|
2362
|
+
} catch (e: Exception) {
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// Load pages on-demand as user scrolls (for pages beyond initial pre-load)
|
|
2370
|
+
LaunchedEffect(lazyListState.firstVisibleItemIndex, lastRenderedScale) {
|
|
2371
|
+
// CRITICAL: If lastRenderedScale just changed (zoom completed), reduce delay
|
|
2372
|
+
// This prevents white screen after rapid zoom, especially zoom out
|
|
2373
|
+
// Allow loading during recentPinchZoom to prevent white screen when scrolling
|
|
2374
|
+
val delayTime = if (isZoomInProgress || isPinchZoomActive) {
|
|
2375
|
+
500L // Wait longer if zoom still in progress or active pinch zoom
|
|
2376
|
+
} else {
|
|
2377
|
+
50L // Very short delay if zoom just completed - prioritize visible pages immediately
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
delay(delayTime)
|
|
2381
|
+
|
|
2382
|
+
// Skip only if zoom is actively in progress (not recentPinchZoom)
|
|
2383
|
+
if (isZoomInProgress || isPinchZoomActive) {
|
|
2384
|
+
return@LaunchedEffect
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// CRITICAL FIX: Only skip if pinch zoom is actively happening (not recentPinchZoom)
|
|
2388
|
+
// Allow scroll handler to load pages during recentPinchZoom to prevent white screen
|
|
2389
|
+
// Skip only during active pinch zoom to prevent conflicts
|
|
2390
|
+
val timeSinceLastPinch = System.currentTimeMillis() - lastPinchZoomTime
|
|
2391
|
+
if (isPinchZoomActive) {
|
|
2392
|
+
// Only skip during active pinch zoom, not during recentPinchZoom period
|
|
2393
|
+
return@LaunchedEffect
|
|
2394
|
+
}
|
|
2395
|
+
// Note: We allow loading during recentPinchZoom to prevent white screen when scrolling
|
|
2396
|
+
|
|
2397
|
+
val firstVisibleIndex = lazyListState.firstVisibleItemIndex
|
|
2398
|
+
val currentBatch = getBatchNumber(firstVisibleIndex)
|
|
2399
|
+
// CRITICAL FIX: Use current scale if recentPinchZoom is active to prevent loading old pages
|
|
2400
|
+
// When scrolling after max/min pinch zoom, we want to load at current scale, not old lastRenderedScale
|
|
2401
|
+
val targetScale = if (recentPinchZoom || isPinchZoomActive) {
|
|
2402
|
+
// During recent pinch zoom, use current scale to ensure pages load at correct zoom (max or min)
|
|
2403
|
+
scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
|
|
2404
|
+
} else {
|
|
2405
|
+
// Otherwise use lastRenderedScale (for normal scrolling)
|
|
2406
|
+
lastRenderedScale
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// Get visible pages - prioritize loading them first
|
|
2410
|
+
val visiblePages = lazyListState.layoutInfo.visibleItemsInfo.map { it.index }
|
|
2411
|
+
|
|
2412
|
+
// First, queue visible pages that need rendering (at correct scale or missing)
|
|
2413
|
+
// CRITICAL: Use more lenient threshold for max zoom AND minimum zoom to prevent white screen
|
|
2414
|
+
val scaleDiffThreshold = when {
|
|
2415
|
+
targetScale > 1.5f -> 0.50f // For max zoom (>150%), allow up to 50% difference
|
|
2416
|
+
targetScale >= 1.0f && targetScale <= 1.5f -> 0.50f // CRITICAL FIX: For moderate zoom (135-150%), allow 50% difference to ensure pages reload
|
|
2417
|
+
targetScale <= 1.1f -> 0.50f // For minimum zoom (100% = 1.0f), also allow 50% difference (keeps pages at higher scales)
|
|
2418
|
+
else -> 0.08f // For normal zoom, use 8% threshold
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
visiblePages.forEach { pageIndex ->
|
|
2422
|
+
val cached = pageCache[pageIndex]
|
|
2423
|
+
val needsRender = cached == null ||
|
|
2424
|
+
kotlin.math.abs(cached.second - targetScale) > scaleDiffThreshold
|
|
2425
|
+
|
|
2426
|
+
if (needsRender && !renderingPages.containsKey(pageIndex) && !queuedPages.value.contains(pageIndex)) {
|
|
2427
|
+
launch(Dispatchers.Default) {
|
|
2428
|
+
try {
|
|
2429
|
+
val request = RenderRequest(pageIndex, targetScale, priority = 0)
|
|
2430
|
+
renderQueue.send(request)
|
|
2431
|
+
withContext(Dispatchers.Main) {
|
|
2432
|
+
queuedPages.value = queuedPages.value + pageIndex
|
|
2433
|
+
}
|
|
2434
|
+
} catch (e: Exception) {
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Load current batch if not loaded (or needs re-render at new scale)
|
|
2441
|
+
// CRITICAL: Use same lenient threshold for max zoom
|
|
2442
|
+
val batchNeedsLoad = !loadedBatches.contains(currentBatch) ||
|
|
2443
|
+
(0 until batchSize).any { offset ->
|
|
2444
|
+
val pageIndex = currentBatch * batchSize + offset
|
|
2445
|
+
if (pageIndex >= pageCount) false
|
|
2446
|
+
else {
|
|
2447
|
+
val cached = pageCache[pageIndex]
|
|
2448
|
+
cached == null || kotlin.math.abs(cached.second - targetScale) > scaleDiffThreshold
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
if (batchNeedsLoad) {
|
|
2453
|
+
launch(Dispatchers.Default) {
|
|
2454
|
+
try {
|
|
2455
|
+
loadBatch(currentBatch, targetScale, priority = 1)
|
|
2456
|
+
|
|
2457
|
+
// Mark batch as loaded
|
|
2458
|
+
withContext(Dispatchers.Main) {
|
|
2459
|
+
loadedBatches = loadedBatches + currentBatch
|
|
2460
|
+
}
|
|
2461
|
+
} catch (e: Exception) {
|
|
2462
|
+
e.printStackTrace()
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// Prefetch next batch if near end of current batch (using configurable threshold)
|
|
2468
|
+
val positionInBatch = firstVisibleIndex % batchSize
|
|
2469
|
+
val nextBatch = currentBatch + 1
|
|
2470
|
+
if (positionInBatch >= batchSize - PdfViewerConfig.SCROLL_PREFETCH_THRESHOLD && nextBatch * batchSize < pageCount) {
|
|
2471
|
+
val nextBatchNeedsLoad = !loadedBatches.contains(nextBatch) ||
|
|
2472
|
+
((nextBatch * batchSize) until minOf((nextBatch + 1) * batchSize, pageCount)).any { pageIndex ->
|
|
2473
|
+
val cached = pageCache[pageIndex]
|
|
2474
|
+
cached == null || kotlin.math.abs(cached.second - targetScale) > 0.08f
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
if (nextBatchNeedsLoad) {
|
|
2478
|
+
launch(Dispatchers.Default) {
|
|
2479
|
+
try {
|
|
2480
|
+
loadBatch(nextBatch, targetScale, priority = 2)
|
|
2481
|
+
safeMainContext {
|
|
2482
|
+
loadedBatches = loadedBatches + nextBatch
|
|
2483
|
+
}
|
|
2484
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
2485
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
2486
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
2487
|
+
// Expected when composable leaves composition - not an error
|
|
2488
|
+
} else {
|
|
2489
|
+
// Other cancellation - also expected
|
|
2490
|
+
}
|
|
2491
|
+
throw e
|
|
2492
|
+
} catch (e: Exception) {
|
|
2493
|
+
// Only log actual errors, not cancellations
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// Prefetch previous batch if scrolling up (using configurable threshold)
|
|
2500
|
+
val prevBatch = currentBatch - 1
|
|
2501
|
+
if (positionInBatch <= PdfViewerConfig.SCROLL_PREFETCH_THRESHOLD && prevBatch >= 0) {
|
|
2502
|
+
val prevBatchNeedsLoad = !loadedBatches.contains(prevBatch) ||
|
|
2503
|
+
((prevBatch * batchSize) until minOf((prevBatch + 1) * batchSize, pageCount)).any { pageIndex ->
|
|
2504
|
+
val cached = pageCache[pageIndex]
|
|
2505
|
+
cached == null || kotlin.math.abs(cached.second - targetScale) > 0.08f
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
if (prevBatchNeedsLoad) {
|
|
2509
|
+
launch(Dispatchers.Default) {
|
|
2510
|
+
try {
|
|
2511
|
+
loadBatch(prevBatch, targetScale, priority = 2)
|
|
2512
|
+
safeMainContext {
|
|
2513
|
+
loadedBatches = loadedBatches + prevBatch
|
|
2514
|
+
}
|
|
2515
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
2516
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
2517
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
2518
|
+
// Expected when composable leaves composition - not an error
|
|
2519
|
+
} else {
|
|
2520
|
+
// Other cancellation - also expected
|
|
2521
|
+
}
|
|
2522
|
+
throw e
|
|
2523
|
+
} catch (e: Exception) {
|
|
2524
|
+
// Only log actual errors, not cancellations
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Manage cache size - keep only nearby batches
|
|
2531
|
+
// CRITICAL: Skip cache management during zoom OR recentPinchZoom to prevent clearing visible pages
|
|
2532
|
+
// This prevents white screen when scrolling after max pinch zoom
|
|
2533
|
+
if (!isZoomInProgress && !isPinchZoomActive && !recentPinchZoom) {
|
|
2534
|
+
try {
|
|
2535
|
+
manageCacheSize(firstVisibleIndex)
|
|
2536
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
2537
|
+
// Check if it's a LeftCompositionCancellationException (expected when composable leaves)
|
|
2538
|
+
if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
|
|
2539
|
+
// Expected when composable leaves composition - not an error
|
|
2540
|
+
} else {
|
|
2541
|
+
// Other cancellation - also expected
|
|
2542
|
+
}
|
|
2543
|
+
throw e
|
|
2544
|
+
} catch (e: Exception) {
|
|
2545
|
+
// Only log actual errors, not cancellations
|
|
2546
|
+
e.printStackTrace()
|
|
2547
|
+
}
|
|
2548
|
+
} else {
|
|
2549
|
+
val reason = when {
|
|
2550
|
+
isZoomInProgress -> "zoom in progress"
|
|
2551
|
+
isPinchZoomActive -> "pinch zoom active"
|
|
2552
|
+
recentPinchZoom -> "recent pinch zoom (keeping pages at max zoom)"
|
|
2553
|
+
else -> "unknown"
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// Force reload visible pages when cache cleanup requested it (e.g., after aggressive cleanup at max zoom)
|
|
2559
|
+
LaunchedEffect(reloadVisiblePagesToken) {
|
|
2560
|
+
if (reloadVisiblePagesToken == 0) {
|
|
2561
|
+
return@LaunchedEffect
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
|
|
2565
|
+
// Wait briefly if zoom or pinch is still active to avoid racing with gestures
|
|
2566
|
+
var attempts = 0
|
|
2567
|
+
while ((isZoomInProgress || isPinchZoomActive) && attempts < 20) {
|
|
2568
|
+
delay(100)
|
|
2569
|
+
attempts++
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
val visiblePages = lazyListState.layoutInfo.visibleItemsInfo.map { it.index }
|
|
2573
|
+
if (visiblePages.isEmpty()) {
|
|
2574
|
+
return@LaunchedEffect
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
val targetScale = if (recentPinchZoom || isPinchZoomActive) {
|
|
2578
|
+
scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
|
|
2579
|
+
} else {
|
|
2580
|
+
lastRenderedScale
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
val scaleDiffThreshold = if (targetScale > 1.5f || (targetScale >= 1.0f && targetScale <= 1.5f) || targetScale <= 0.35f) {
|
|
2584
|
+
0.50f // CRITICAL FIX: Include moderate zoom (135-150%) in lenient threshold
|
|
2585
|
+
} else {
|
|
2586
|
+
0.08f
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
visiblePages.forEach { pageIndex ->
|
|
2590
|
+
val cached = pageCache[pageIndex]
|
|
2591
|
+
val needsRender = cached == null ||
|
|
2592
|
+
kotlin.math.abs(cached.second - targetScale) > scaleDiffThreshold
|
|
2593
|
+
|
|
2594
|
+
if (needsRender && !renderingPages.containsKey(pageIndex) && !queuedPages.value.contains(pageIndex)) {
|
|
2595
|
+
launch(Dispatchers.Default) {
|
|
2596
|
+
try {
|
|
2597
|
+
val request = RenderRequest(pageIndex, targetScale, priority = 0)
|
|
2598
|
+
renderQueue.send(request)
|
|
2599
|
+
withContext(Dispatchers.Main) {
|
|
2600
|
+
queuedPages.value = queuedPages.value + pageIndex
|
|
2601
|
+
}
|
|
2602
|
+
} catch (e: Exception) {
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// LaunchedEffect to detect when pinch zoom gesture ends (no updates for 500ms)
|
|
2610
|
+
// Use a single job that gets cancelled and restarted
|
|
2611
|
+
LaunchedEffect(lastPinchZoomTime) {
|
|
2612
|
+
try {
|
|
2613
|
+
delay(500)
|
|
2614
|
+
val currentTime = System.currentTimeMillis()
|
|
2615
|
+
val timeSinceLastUpdate = currentTime - lastPinchZoomTime
|
|
2616
|
+
if (timeSinceLastUpdate >= 500 && isPinchZoomActive) {
|
|
2617
|
+
isPinchZoomActive = false
|
|
2618
|
+
// CRITICAL FIX: Update lastRenderedScale immediately when pinch zoom ends
|
|
2619
|
+
// This ensures scroll handler loads pages at the correct (max) zoom scale
|
|
2620
|
+
try {
|
|
2621
|
+
lastRenderedScale = scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
|
|
2622
|
+
} catch (e: Exception) {
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// Set recentPinchZoom to true to keep pages visible while re-rendering
|
|
2626
|
+
recentPinchZoom = true
|
|
2627
|
+
Log.d("PdfRendererTag", "pinch zoom is deactivated")
|
|
2628
|
+
|
|
2629
|
+
// Keep recentPinchZoom true for 3 seconds to allow re-rendering to complete
|
|
2630
|
+
delay(3000)
|
|
2631
|
+
recentPinchZoom = false
|
|
2632
|
+
}
|
|
2633
|
+
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
2634
|
+
// Expected when new gesture starts
|
|
2635
|
+
throw e
|
|
2636
|
+
} catch (e: Exception) {
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
Box(
|
|
2641
|
+
modifier = Modifier
|
|
2642
|
+
.fillMaxSize()
|
|
2643
|
+
.then(
|
|
2644
|
+
if (!isFullScreenState) {
|
|
2645
|
+
// CRITICAL: Inset content by the full border stroke width
|
|
2646
|
+
// Border stroke is 20.dp wide, centered on path at 10.dp from edge
|
|
2647
|
+
// Inner edge of border = path (10.dp) + halfStroke (10.dp) = 20.dp
|
|
2648
|
+
// This ensures PDF content stays fully inside the gray border
|
|
2649
|
+
Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
|
|
2650
|
+
} else {
|
|
2651
|
+
Modifier // No padding in fullscreen
|
|
2652
|
+
}
|
|
2653
|
+
)
|
|
2654
|
+
// FULLY MANUAL GESTURE HANDLER: All scrolling is handled manually for consistency
|
|
2655
|
+
// - 1 finger: Manual scroll (both vertical and horizontal)
|
|
2656
|
+
// - 2 fingers: Pinch zoom ONLY (scroll disabled)
|
|
2657
|
+
// - 3+ fingers: Manual scroll (both vertical and horizontal)
|
|
2658
|
+
// - NO system scroll - LazyColumn has userScrollEnabled = false
|
|
2659
|
+
.pointerInput(lazyListState, horizontalScrollState) {
|
|
2660
|
+
// Track gesture state
|
|
2661
|
+
var isScrolling = false
|
|
2662
|
+
var lastScrollY = 0f
|
|
2663
|
+
var lastScrollX = 0f
|
|
2664
|
+
var scrollDirection: String? = null
|
|
2665
|
+
var refPointerId: Long = -1L
|
|
2666
|
+
var scrollMode = "NONE"
|
|
2667
|
+
|
|
2668
|
+
val directionLockThreshold = 15f
|
|
2669
|
+
|
|
2670
|
+
val flingDecay = exponentialDecay<Float>(frictionMultiplier = 1.2f)
|
|
2671
|
+
val velocityTracker = VelocityTracker()
|
|
2672
|
+
var flingJob: Job? = null
|
|
2673
|
+
var stopFling = false
|
|
2674
|
+
|
|
2675
|
+
var prevHorizontalDistance = -1f
|
|
2676
|
+
var twoFingerFrameCount = 0
|
|
2677
|
+
|
|
2678
|
+
val minStablePinchFrames = 3
|
|
2679
|
+
val horizontalZoomThreshold = 2f
|
|
2680
|
+
val horizontalDominanceRatio = 1.2f
|
|
2681
|
+
val maxZoomPerFrame = 1.05f
|
|
2682
|
+
val fullDistanceZoomThreshold = 10f
|
|
2683
|
+
|
|
2684
|
+
awaitPointerEventScope {
|
|
2685
|
+
while (true) {
|
|
2686
|
+
val event = awaitPointerEvent(PointerEventPass.Initial)
|
|
2687
|
+
val pointers = event.changes.filter { it.pressed }
|
|
2688
|
+
val pointerCount = pointers.size
|
|
2689
|
+
|
|
2690
|
+
event.changes.forEach { it.consume() }
|
|
2691
|
+
|
|
2692
|
+
when {
|
|
2693
|
+
// 1 finger OR 3+ fingers: AXIS-LOCKED SCROLL
|
|
2694
|
+
// Uses a single reference pointer (pointers[0] or
|
|
2695
|
+
// the previously tracked pointer), NOT centroid.
|
|
2696
|
+
pointerCount == 1 || pointerCount >= 3 -> {
|
|
2697
|
+
prevHorizontalDistance = -1f
|
|
2698
|
+
twoFingerFrameCount = 0
|
|
2699
|
+
|
|
2700
|
+
scrollMode = if (pointerCount == 1) "SINGLE_SCROLL" else "MULTI_SCROLL"
|
|
2701
|
+
|
|
2702
|
+
val refPointer = pointers.firstOrNull {
|
|
2703
|
+
it.id.value == refPointerId
|
|
2704
|
+
} ?: pointers[0]
|
|
2705
|
+
val refX = refPointer.position.x
|
|
2706
|
+
val refY = refPointer.position.y
|
|
2707
|
+
val refIdChanged = refPointer.id.value != refPointerId
|
|
2708
|
+
|
|
2709
|
+
val needsReAnchor = refIdChanged || !isScrolling
|
|
2710
|
+
|
|
2711
|
+
velocityTracker.addPosition(
|
|
2712
|
+
refPointer.uptimeMillis,
|
|
2713
|
+
Offset(refX, refY)
|
|
2714
|
+
)
|
|
2715
|
+
|
|
2716
|
+
if (needsReAnchor) {
|
|
2717
|
+
if (!isScrolling) {
|
|
2718
|
+
val hadActiveFling = flingJob?.isActive == true
|
|
2719
|
+
stopFling = true
|
|
2720
|
+
flingJob?.cancel()
|
|
2721
|
+
flingJob = null
|
|
2722
|
+
velocityTracker.resetTracking()
|
|
2723
|
+
velocityTracker.addPosition(
|
|
2724
|
+
refPointer.uptimeMillis,
|
|
2725
|
+
Offset(refX, refY)
|
|
2726
|
+
)
|
|
2727
|
+
if (!hadActiveFling) {
|
|
2728
|
+
scrollDirection = null
|
|
2729
|
+
}
|
|
2730
|
+
Log.d("PdfGesture",
|
|
2731
|
+
"GESTURE_START hadFling=$hadActiveFling " +
|
|
2732
|
+
"preservedDir=$scrollDirection"
|
|
2733
|
+
)
|
|
2734
|
+
}
|
|
2735
|
+
isScrolling = true
|
|
2736
|
+
lastScrollX = refX
|
|
2737
|
+
lastScrollY = refY
|
|
2738
|
+
refPointerId = refPointer.id.value
|
|
2739
|
+
Log.d("PdfGesture",
|
|
2740
|
+
"mode=$scrollMode " +
|
|
2741
|
+
"${if (refIdChanged) "RE-ANCHOR" else "START"} " +
|
|
2742
|
+
"refPtr=$refPointerId " +
|
|
2743
|
+
"X=${"%.1f".format(refX)} Y=${"%.1f".format(refY)} " +
|
|
2744
|
+
"dir=$scrollDirection"
|
|
2745
|
+
)
|
|
2746
|
+
} else {
|
|
2747
|
+
val rawDeltaX = lastScrollX - refX
|
|
2748
|
+
val rawDeltaY = lastScrollY - refY
|
|
2749
|
+
|
|
2750
|
+
if (scrollDirection == null) {
|
|
2751
|
+
val absX = kotlin.math.abs(rawDeltaX)
|
|
2752
|
+
val absY = kotlin.math.abs(rawDeltaY)
|
|
2753
|
+
|
|
2754
|
+
if (absX > directionLockThreshold || absY > directionLockThreshold) {
|
|
2755
|
+
scrollDirection = if (absX > absY) "horizontal" else "vertical"
|
|
2756
|
+
Log.d("PdfGesture",
|
|
2757
|
+
"mode=$scrollMode axis=$scrollDirection " +
|
|
2758
|
+
"refPtr=$refPointerId"
|
|
2759
|
+
)
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
when (scrollDirection) {
|
|
2764
|
+
"vertical" -> {
|
|
2765
|
+
val deltaY = rawDeltaY
|
|
2766
|
+
if (kotlin.math.abs(deltaY) > 0.1f) {
|
|
2767
|
+
lazyListState.dispatchRawDelta(deltaY)
|
|
2768
|
+
}
|
|
2769
|
+
lastScrollY = refY
|
|
2770
|
+
}
|
|
2771
|
+
"horizontal" -> {
|
|
2772
|
+
val deltaX = rawDeltaX
|
|
2773
|
+
if (kotlin.math.abs(deltaX) > 0.1f) {
|
|
2774
|
+
horizontalScrollState.dispatchRawDelta(deltaX)
|
|
2775
|
+
}
|
|
2776
|
+
lastScrollX = refX
|
|
2777
|
+
}
|
|
2778
|
+
null -> {
|
|
2779
|
+
lastScrollX = refX
|
|
2780
|
+
lastScrollY = refY
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// 2 fingers: HORIZONTAL-ONLY PINCH ZOOM
|
|
2787
|
+
// During settling, scroll state is preserved so that brief
|
|
2788
|
+
// 3→2→3 pointer-count jitter doesn't destroy momentum.
|
|
2789
|
+
pointerCount == 2 -> {
|
|
2790
|
+
twoFingerFrameCount++
|
|
2791
|
+
|
|
2792
|
+
// ── Settling + was scrolling → keep scrolling ─────
|
|
2793
|
+
if (twoFingerFrameCount <= minStablePinchFrames && isScrolling) {
|
|
2794
|
+
val refPointer = pointers.firstOrNull {
|
|
2795
|
+
it.id.value == refPointerId
|
|
2796
|
+
} ?: pointers[0]
|
|
2797
|
+
val refX = refPointer.position.x
|
|
2798
|
+
val refY = refPointer.position.y
|
|
2799
|
+
val refIdChanged = refPointer.id.value != refPointerId
|
|
2800
|
+
|
|
2801
|
+
velocityTracker.addPosition(
|
|
2802
|
+
refPointer.uptimeMillis,
|
|
2803
|
+
Offset(refX, refY)
|
|
2804
|
+
)
|
|
2805
|
+
|
|
2806
|
+
if (refIdChanged) {
|
|
2807
|
+
lastScrollX = refX
|
|
2808
|
+
lastScrollY = refY
|
|
2809
|
+
refPointerId = refPointer.id.value
|
|
2810
|
+
} else {
|
|
2811
|
+
val rawDeltaX = lastScrollX - refX
|
|
2812
|
+
val rawDeltaY = lastScrollY - refY
|
|
2813
|
+
when (scrollDirection) {
|
|
2814
|
+
"vertical" -> {
|
|
2815
|
+
if (kotlin.math.abs(rawDeltaY) > 0.1f) {
|
|
2816
|
+
lazyListState.dispatchRawDelta(rawDeltaY)
|
|
2817
|
+
}
|
|
2818
|
+
lastScrollY = refY
|
|
2819
|
+
}
|
|
2820
|
+
"horizontal" -> {
|
|
2821
|
+
if (kotlin.math.abs(rawDeltaX) > 0.1f) {
|
|
2822
|
+
horizontalScrollState.dispatchRawDelta(rawDeltaX)
|
|
2823
|
+
}
|
|
2824
|
+
lastScrollX = refX
|
|
2825
|
+
}
|
|
2826
|
+
null -> {
|
|
2827
|
+
lastScrollX = refX
|
|
2828
|
+
lastScrollY = refY
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
prevHorizontalDistance = kotlin.math.abs(
|
|
2834
|
+
pointers[1].position.x - pointers[0].position.x
|
|
2835
|
+
)
|
|
2836
|
+
Log.d("PdfGesture",
|
|
2837
|
+
"2-finger SETTLING (scroll preserved) " +
|
|
2838
|
+
"frame=$twoFingerFrameCount/$minStablePinchFrames " +
|
|
2839
|
+
"mode=$scrollMode refPtr=$refPointerId"
|
|
2840
|
+
)
|
|
2841
|
+
|
|
2842
|
+
// ── Settling complete or wasn't scrolling → zoom path ─
|
|
2843
|
+
} else {
|
|
2844
|
+
isScrolling = false
|
|
2845
|
+
scrollDirection = null
|
|
2846
|
+
refPointerId = -1L
|
|
2847
|
+
scrollMode = "PINCH_ZOOM"
|
|
2848
|
+
velocityTracker.resetTracking()
|
|
2849
|
+
|
|
2850
|
+
val pointer1 = pointers[0]
|
|
2851
|
+
val pointer2 = pointers[1]
|
|
2852
|
+
|
|
2853
|
+
val pos1 = pointer1.position
|
|
2854
|
+
val pos2 = pointer2.position
|
|
2855
|
+
val prevPos1 = pointer1.previousPosition
|
|
2856
|
+
val prevPos2 = pointer2.previousPosition
|
|
2857
|
+
|
|
2858
|
+
val currentDx = pos2.x - pos1.x
|
|
2859
|
+
val currentDy = pos2.y - pos1.y
|
|
2860
|
+
val currentDistance = kotlin.math.sqrt(currentDx * currentDx + currentDy * currentDy)
|
|
2861
|
+
|
|
2862
|
+
val prevDx = prevPos2.x - prevPos1.x
|
|
2863
|
+
val prevDy = prevPos2.y - prevPos1.y
|
|
2864
|
+
val previousDistance = kotlin.math.sqrt(prevDx * prevDx + prevDy * prevDy)
|
|
2865
|
+
|
|
2866
|
+
val currentHorizDist = kotlin.math.abs(pos2.x - pos1.x)
|
|
2867
|
+
val currentVertDist = kotlin.math.abs(pos2.y - pos1.y)
|
|
2868
|
+
|
|
2869
|
+
val horizDelta =
|
|
2870
|
+
if (prevHorizontalDistance >= 0f)
|
|
2871
|
+
kotlin.math.abs(currentHorizDist - prevHorizontalDistance)
|
|
2872
|
+
else 0f
|
|
2873
|
+
|
|
2874
|
+
Log.d("PdfGesture",
|
|
2875
|
+
"PinchZoom | pointers=2 | " +
|
|
2876
|
+
"frame=$twoFingerFrameCount | " +
|
|
2877
|
+
"fullDist=${currentDistance.toInt()} | " +
|
|
2878
|
+
"horizDist=${currentHorizDist.toInt()} | " +
|
|
2879
|
+
"vertDist=${currentVertDist.toInt()} | " +
|
|
2880
|
+
"horizDelta=${"%.1f".format(horizDelta)} | " +
|
|
2881
|
+
"prevHorizDist=${prevHorizontalDistance.toInt()}"
|
|
2882
|
+
)
|
|
2883
|
+
|
|
2884
|
+
if (prevHorizontalDistance < 0f) {
|
|
2885
|
+
Log.d("PdfGesture", "PinchZoom SKIP — setting horizontal reference (safety)")
|
|
2886
|
+
prevHorizontalDistance = currentHorizDist
|
|
2887
|
+
|
|
2888
|
+
} else {
|
|
2889
|
+
val fullDistanceDelta = if (previousDistance > 0f) kotlin.math.abs(currentDistance - previousDistance) else 0f
|
|
2890
|
+
val fullDistanceOverride = fullDistanceDelta >= fullDistanceZoomThreshold
|
|
2891
|
+
|
|
2892
|
+
val isHorizDominant = currentHorizDist >= currentVertDist * horizontalDominanceRatio
|
|
2893
|
+
|
|
2894
|
+
val hasMeaningfulHorizChange = horizDelta >= horizontalZoomThreshold
|
|
2895
|
+
|
|
2896
|
+
if (!isHorizDominant && !fullDistanceOverride) {
|
|
2897
|
+
Log.d("PdfGesture",
|
|
2898
|
+
"PinchZoom BLOCKED — vertical dominant " +
|
|
2899
|
+
"(horizDist=${currentHorizDist.toInt()} " +
|
|
2900
|
+
"vertDist=${currentVertDist.toInt()})"
|
|
2901
|
+
)
|
|
2902
|
+
} else if (!fullDistanceOverride && !hasMeaningfulHorizChange) {
|
|
2903
|
+
Log.d("PdfGesture",
|
|
2904
|
+
"PinchZoom BLOCKED — horizDelta ${"%.1f".format(horizDelta)} < $horizontalZoomThreshold px"
|
|
2905
|
+
)
|
|
2906
|
+
} else if (previousDistance > 0f && currentDistance > 0f) {
|
|
2907
|
+
val rawZoom = currentDistance / previousDistance
|
|
2908
|
+
val zoom = rawZoom.coerceIn(1f / maxZoomPerFrame, maxZoomPerFrame)
|
|
2909
|
+
|
|
2910
|
+
Log.d("PdfGesture",
|
|
2911
|
+
"PinchZoom ALLOWED | " +
|
|
2912
|
+
"rawZoom=${"%.4f".format(rawZoom)} | " +
|
|
2913
|
+
"zoom=${"%.4f".format(zoom)} | " +
|
|
2914
|
+
"horizDelta=${"%.1f".format(horizDelta)} | " +
|
|
2915
|
+
"fullDelta=${"%.1f".format(fullDistanceDelta)}" +
|
|
2916
|
+
if (fullDistanceOverride) " [full-dist override]" else ""
|
|
2917
|
+
)
|
|
2918
|
+
|
|
2919
|
+
touchGestureCount++
|
|
2920
|
+
|
|
2921
|
+
try {
|
|
2922
|
+
if (!zoom.isNaN() && !zoom.isInfinite() && zoom > 0f) {
|
|
2923
|
+
if (kotlin.math.abs(zoom - 1.0f) >= 0.005f) {
|
|
2924
|
+
val currentTime = System.currentTimeMillis()
|
|
2925
|
+
isPinchZoomActive = true
|
|
2926
|
+
lastPinchZoomTime = currentTime
|
|
2927
|
+
|
|
2928
|
+
val timeSinceLastUpdate = currentTime - lastScaleUpdateTime
|
|
2929
|
+
val throttleTime = 16L
|
|
2930
|
+
|
|
2931
|
+
if (timeSinceLastUpdate >= throttleTime) {
|
|
2932
|
+
val currentScale = scale
|
|
2933
|
+
if (!currentScale.isNaN() && !currentScale.isInfinite() && currentScale > 0f) {
|
|
2934
|
+
val newScale = (currentScale * zoom).coerceIn(MIN_ZOOM, effectiveMaxZoom)
|
|
2935
|
+
if (!newScale.isNaN() && !newScale.isInfinite()) {
|
|
2936
|
+
if (kotlin.math.abs(currentScale - newScale) > 0.005f) {
|
|
2937
|
+
scale = newScale
|
|
2938
|
+
lastScaleUpdateTime = currentTime
|
|
2939
|
+
Log.d("PdfGesture",
|
|
2940
|
+
"Scale updated: ${"%.3f".format(newScale)} " +
|
|
2941
|
+
"(${(newScale * 100).toInt()}%)"
|
|
2942
|
+
)
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
} else {
|
|
2946
|
+
scale = MIN_ZOOM
|
|
2947
|
+
lastScaleUpdateTime = currentTime
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
} catch (e: OutOfMemoryError) {
|
|
2953
|
+
System.gc()
|
|
2954
|
+
} catch (e: Exception) {
|
|
2955
|
+
// skip update on error
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
prevHorizontalDistance = currentHorizDist
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// 0 fingers: all pointers lifted — launch fling if scrolling
|
|
2965
|
+
else -> {
|
|
2966
|
+
prevHorizontalDistance = -1f
|
|
2967
|
+
twoFingerFrameCount = 0
|
|
2968
|
+
refPointerId = -1L
|
|
2969
|
+
|
|
2970
|
+
val launchedFling = isScrolling && scrollDirection != null
|
|
2971
|
+
if (launchedFling) {
|
|
2972
|
+
val velocity = velocityTracker.calculateVelocity()
|
|
2973
|
+
val capturedDirection = scrollDirection!!
|
|
2974
|
+
Log.d("PdfGesture",
|
|
2975
|
+
"FLING_LAUNCH dir=$capturedDirection " +
|
|
2976
|
+
"vx=${"%.0f".format(velocity.x)} " +
|
|
2977
|
+
"vy=${"%.0f".format(velocity.y)}"
|
|
2978
|
+
)
|
|
2979
|
+
flingJob?.cancel()
|
|
2980
|
+
stopFling = false
|
|
2981
|
+
flingJob = gestureScope.launch {
|
|
2982
|
+
try {
|
|
2983
|
+
when (capturedDirection) {
|
|
2984
|
+
"vertical" -> {
|
|
2985
|
+
val initialVelocity = -velocity.y
|
|
2986
|
+
if (kotlin.math.abs(initialVelocity) > 50f) {
|
|
2987
|
+
lazyListState.scroll {
|
|
2988
|
+
val scrollScope = this
|
|
2989
|
+
var lastValue = 0f
|
|
2990
|
+
AnimationState(
|
|
2991
|
+
initialValue = 0f,
|
|
2992
|
+
initialVelocity = initialVelocity
|
|
2993
|
+
).animateDecay(flingDecay) {
|
|
2994
|
+
if (stopFling) {
|
|
2995
|
+
Log.d("PdfGesture", "FLING_KILLED by flag")
|
|
2996
|
+
cancelAnimation()
|
|
2997
|
+
return@animateDecay
|
|
2998
|
+
}
|
|
2999
|
+
val delta = value - lastValue
|
|
3000
|
+
lastValue = value
|
|
3001
|
+
val consumed = scrollScope.scrollBy(delta)
|
|
3002
|
+
if (kotlin.math.abs(consumed) < kotlin.math.abs(delta) * 0.5f) {
|
|
3003
|
+
Log.d("PdfGesture", "FLING_END boundary hit")
|
|
3004
|
+
cancelAnimation()
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
} else {
|
|
3009
|
+
Log.d("PdfGesture", "FLING_SKIP velocity too low: ${"%.0f".format(initialVelocity)}")
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
"horizontal" -> {
|
|
3013
|
+
val initialVelocity = -velocity.x
|
|
3014
|
+
if (kotlin.math.abs(initialVelocity) > 50f) {
|
|
3015
|
+
horizontalScrollState.scroll {
|
|
3016
|
+
val scrollScope = this
|
|
3017
|
+
var lastValue = 0f
|
|
3018
|
+
AnimationState(
|
|
3019
|
+
initialValue = 0f,
|
|
3020
|
+
initialVelocity = initialVelocity
|
|
3021
|
+
).animateDecay(flingDecay) {
|
|
3022
|
+
if (stopFling) {
|
|
3023
|
+
Log.d("PdfGesture", "FLING_KILLED by flag")
|
|
3024
|
+
cancelAnimation()
|
|
3025
|
+
return@animateDecay
|
|
3026
|
+
}
|
|
3027
|
+
val delta = value - lastValue
|
|
3028
|
+
lastValue = value
|
|
3029
|
+
scrollScope.scrollBy(delta)
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
} else {
|
|
3033
|
+
Log.d("PdfGesture", "FLING_SKIP velocity too low: ${"%.0f".format(initialVelocity)}")
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
Log.d("PdfGesture", "FLING_COMPLETE dir=$capturedDirection")
|
|
3038
|
+
} catch (e: Exception) {
|
|
3039
|
+
if (e !is kotlinx.coroutines.CancellationException) {
|
|
3040
|
+
Log.e("PdfGesture", "Fling error: ${e.message}")
|
|
3041
|
+
} else {
|
|
3042
|
+
Log.d("PdfGesture", "FLING_CANCELLED dir=$capturedDirection")
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
isScrolling = false
|
|
3049
|
+
lastScrollX = 0f
|
|
3050
|
+
lastScrollY = 0f
|
|
3051
|
+
if (!launchedFling) scrollDirection = null
|
|
3052
|
+
scrollMode = "NONE"
|
|
3053
|
+
velocityTracker.resetTracking()
|
|
3054
|
+
|
|
3055
|
+
Log.d("PdfGesture",
|
|
3056
|
+
"ALL_UP launchedFling=$launchedFling " +
|
|
3057
|
+
"preservedDir=$scrollDirection"
|
|
3058
|
+
)
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
) {
|
|
3065
|
+
LazyColumn(
|
|
3066
|
+
state = lazyListState,
|
|
3067
|
+
modifier = Modifier
|
|
3068
|
+
.fillMaxSize()
|
|
3069
|
+
.horizontalScroll(horizontalScrollState, enabled = false) // Disabled - fully manual control
|
|
3070
|
+
.padding(start = 0.dp, end = 0.dp, bottom = 16.dp, top = 0.dp), // No side padding (parent Box handles border inset)
|
|
3071
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
3072
|
+
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
3073
|
+
// DISABLE system scroll - all scrolling (both vertical and horizontal) is handled manually in pointerInput
|
|
3074
|
+
userScrollEnabled = false
|
|
3075
|
+
) {
|
|
3076
|
+
items(pageCount) { index ->
|
|
3077
|
+
val cachedPage = pageCache[index]
|
|
3078
|
+
val bitmap = cachedPage?.first
|
|
3079
|
+
val bitmapScale = cachedPage?.second ?: scale
|
|
3080
|
+
|
|
3081
|
+
Column(
|
|
3082
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
3083
|
+
modifier = Modifier.wrapContentSize()
|
|
3084
|
+
) {
|
|
3085
|
+
// Check if scale difference is significant (for zoom re-rendering)
|
|
3086
|
+
val scaleDifference = kotlin.math.abs(scale - bitmapScale)
|
|
3087
|
+
// Reduced threshold from 0.08f to 0.05f (5%) for more responsive re-rendering
|
|
3088
|
+
val needsRerender = scaleDifference > 0.05f
|
|
3089
|
+
|
|
3090
|
+
// CRITICAL: Validate bitmap is not null and not recycled RIGHT BEFORE USE
|
|
3091
|
+
// This prevents race condition where bitmap is recycled between check and use
|
|
3092
|
+
val isValidBitmap = try {
|
|
3093
|
+
bitmap != null && !bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0
|
|
3094
|
+
} catch (e: Exception) {
|
|
3095
|
+
false
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// FIX: Show pages with visual scaling for smooth zoom experience
|
|
3099
|
+
// CRITICAL: During pinch zoom, always allow visual scaling (no hiding pages)
|
|
3100
|
+
// For zoom out: be more lenient - allow showing larger pages (can scale down)
|
|
3101
|
+
// For zoom in: only show pages within reasonable range
|
|
3102
|
+
val isZoomingOut = bitmapScale > scale
|
|
3103
|
+
val maxVisualScaleDiff = if (isZoomingOut) {
|
|
3104
|
+
// CRITICAL FIX: For minimum zoom, allow much larger visual scaling
|
|
3105
|
+
// This prevents white screen when zooming out to minimum
|
|
3106
|
+
if (scale <= 1.1f) {
|
|
3107
|
+
10.0f // Allow up to 10x visual scaling for minimum zoom (e.g., 2.5f -> 1.0f)
|
|
3108
|
+
} else {
|
|
3109
|
+
0.50f // For normal zoom out, allow up to 50% difference
|
|
3110
|
+
}
|
|
3111
|
+
} else {
|
|
3112
|
+
// CRITICAL FIX: For zoom in, increase threshold to handle 100% -> 150% (50% difference)
|
|
3113
|
+
// Also handle moderate zoom levels (135-150%) properly
|
|
3114
|
+
if (scale >= 1.0f && scale <= 1.5f) {
|
|
3115
|
+
0.60f // Allow up to 60% difference for moderate zoom (handles 100% -> 150%)
|
|
3116
|
+
} else {
|
|
3117
|
+
0.40f // Stricter for other zoom in - 40% difference
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
// CRITICAL FIX: During pinch zoom AND recent pinch zoom, ALWAYS show pages regardless of scale difference
|
|
3121
|
+
// This prevents white screen during pinch zoom, especially at max zoom
|
|
3122
|
+
// recentPinchZoom keeps pages visible for 3 seconds after pinch ends to allow re-rendering
|
|
3123
|
+
// For button clicks, also allow visual scaling for smooth experience
|
|
3124
|
+
val isScaleAcceptable = if (isPinchZoomActive || recentPinchZoom) {
|
|
3125
|
+
true // Always show during pinch zoom and recent pinch zoom - visual scaling handles it, even at max zoom
|
|
3126
|
+
} else {
|
|
3127
|
+
// For button clicks, also allow visual scaling for smooth experience
|
|
3128
|
+
// Only hide if scale difference is too large AND we're not actively zooming
|
|
3129
|
+
scaleDifference < maxVisualScaleDiff || isZoomInProgress
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
// CRITICAL: During pinch zoom or recent pinch zoom, always show something (bitmap or placeholder)
|
|
3133
|
+
// This prevents white screen at max zoom
|
|
3134
|
+
// CRITICAL FIX: During pinch zoom, ALWAYS show bitmap if it exists, even if being re-rendered
|
|
3135
|
+
// This prevents white screen on the page being zoomed
|
|
3136
|
+
val shouldShowBitmap = if (isPinchZoomActive || recentPinchZoom) {
|
|
3137
|
+
// During pinch zoom, show bitmap if it exists (even if scale doesn't match perfectly)
|
|
3138
|
+
isValidBitmap
|
|
3139
|
+
} else {
|
|
3140
|
+
// Normal case: show bitmap if valid and scale is acceptable
|
|
3141
|
+
isValidBitmap && (isScaleAcceptable || isZoomInProgress)
|
|
3142
|
+
}
|
|
3143
|
+
val shouldShowPlaceholder = (isPinchZoomActive || recentPinchZoom) && !isValidBitmap
|
|
3144
|
+
|
|
3145
|
+
if (shouldShowBitmap || shouldShowPlaceholder) {
|
|
3146
|
+
// Page is loaded - show it with visual scaling if needed
|
|
3147
|
+
// OR show placeholder during pinch zoom if bitmap not available
|
|
3148
|
+
// CRITICAL: Re-check bitmap validity right before using it
|
|
3149
|
+
val safeBitmap = try {
|
|
3150
|
+
if (bitmap != null && !bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0) {
|
|
3151
|
+
bitmap
|
|
3152
|
+
} else {
|
|
3153
|
+
null
|
|
3154
|
+
}
|
|
3155
|
+
} catch (e: Exception) {
|
|
3156
|
+
null
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
if (safeBitmap == null) {
|
|
3160
|
+
// Bitmap was recycled or not available - show placeholder
|
|
3161
|
+
// During pinch zoom, always show placeholder to prevent white screen
|
|
3162
|
+
Box(
|
|
3163
|
+
modifier = Modifier
|
|
3164
|
+
.width((screenWidthDp - 32.dp) * scale)
|
|
3165
|
+
.height((screenWidthDp - 32.dp) * scale * 1.414f), // A4 aspect ratio
|
|
3166
|
+
contentAlignment = Alignment.Center
|
|
3167
|
+
) {
|
|
3168
|
+
CircularProgressIndicator()
|
|
3169
|
+
}
|
|
3170
|
+
} else {
|
|
3171
|
+
// CRITICAL: Final validation right before asImageBitmap()
|
|
3172
|
+
val finalBitmap = try {
|
|
3173
|
+
if (!safeBitmap.isRecycled && safeBitmap.width > 0 && safeBitmap.height > 0) {
|
|
3174
|
+
safeBitmap
|
|
3175
|
+
} else {
|
|
3176
|
+
null
|
|
3177
|
+
}
|
|
3178
|
+
} catch (e: Exception) {
|
|
3179
|
+
null
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
if (finalBitmap == null) {
|
|
3183
|
+
// Show placeholder if bitmap became invalid
|
|
3184
|
+
Box(
|
|
3185
|
+
modifier = Modifier
|
|
3186
|
+
.width((screenWidthDp - 32.dp) * scale)
|
|
3187
|
+
.height((screenWidthDp - 32.dp) * scale * 1.414f),
|
|
3188
|
+
contentAlignment = Alignment.Center
|
|
3189
|
+
) {
|
|
3190
|
+
CircularProgressIndicator()
|
|
3191
|
+
}
|
|
3192
|
+
} else {
|
|
3193
|
+
val aspectRatio = finalBitmap.width.toFloat() / finalBitmap.height.toFloat()
|
|
3194
|
+
|
|
3195
|
+
// FIX: Prevent double-scaling issue
|
|
3196
|
+
// Problem: When zooming in, displayWidth was set to new scale AND graphicsLayer scaled again
|
|
3197
|
+
// Solution: Calculate displayWidth based on bitmapScale, then apply visual scaling factor
|
|
3198
|
+
|
|
3199
|
+
// Base display width should match the bitmap's actual rendered scale
|
|
3200
|
+
val baseDisplayWidth = (screenWidthDp - 32.dp) * bitmapScale
|
|
3201
|
+
|
|
3202
|
+
// Calculate visual scale factor to match current scale
|
|
3203
|
+
// CRITICAL: During pinch zoom AND recent pinch zoom, allow MUCH larger visual scaling
|
|
3204
|
+
// This prevents white screen when zooming to max zoom (e.g., from 1.0f to 2.5f)
|
|
3205
|
+
// Apply visual scaling for all differences during pinch zoom and recent pinch zoom
|
|
3206
|
+
val effectiveMaxDiff = if (isPinchZoomActive || recentPinchZoom) {
|
|
3207
|
+
// During pinch zoom and recent pinch zoom, allow ANY scale difference - no limit
|
|
3208
|
+
// This ensures pages are always shown during pinch zoom, even at max zoom
|
|
3209
|
+
Float.MAX_VALUE // No limit during pinch zoom and recent pinch zoom
|
|
3210
|
+
} else if (isZoomInProgress) {
|
|
3211
|
+
// During button zoom, use the same threshold as maxVisualScaleDiff for consistency
|
|
3212
|
+
// This ensures pages stay visible during zoom transitions at moderate zoom levels
|
|
3213
|
+
maxVisualScaleDiff // Use dynamic threshold based on zoom level
|
|
3214
|
+
} else {
|
|
3215
|
+
maxVisualScaleDiff
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
val visualScaleFactor = if (isPinchZoomActive || recentPinchZoom) {
|
|
3219
|
+
// During pinch zoom and recent pinch zoom, always apply visual scaling regardless of difference
|
|
3220
|
+
// This prevents white screen at max zoom AND minimum zoom
|
|
3221
|
+
// Cap visual scaling factor to prevent extreme distortion (max 10x scaling)
|
|
3222
|
+
val rawFactor = scale / bitmapScale
|
|
3223
|
+
// CRITICAL FIX: For zoom out to minimum, allow larger visual scaling
|
|
3224
|
+
val maxVisualScale = if (scale <= 1.1f && bitmapScale > scale) {
|
|
3225
|
+
10f // Allow up to 10x for zoom out to minimum (e.g., 2.5f -> 1.0f)
|
|
3226
|
+
} else {
|
|
3227
|
+
10f // Standard cap at 10x
|
|
3228
|
+
}
|
|
3229
|
+
kotlin.math.min(rawFactor, maxVisualScale) // Cap to prevent rendering issues
|
|
3230
|
+
} else if (scaleDifference > 0.05f && scaleDifference < effectiveMaxDiff) {
|
|
3231
|
+
scale / bitmapScale // Scale factor to match current scale
|
|
3232
|
+
} else if (scaleDifference >= effectiveMaxDiff) {
|
|
3233
|
+
// For very large differences during button zoom, cap visual scaling
|
|
3234
|
+
val maxAllowedScale = bitmapScale + maxVisualScaleDiff
|
|
3235
|
+
kotlin.math.min(scale, maxAllowedScale) / bitmapScale
|
|
3236
|
+
} else {
|
|
3237
|
+
// No scaling needed if scale matches closely (< 5%)
|
|
3238
|
+
1f
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// Final display width: base width * visual scale factor
|
|
3242
|
+
val displayWidth = baseDisplayWidth * visualScaleFactor
|
|
3243
|
+
|
|
3244
|
+
Box {
|
|
3245
|
+
Image(
|
|
3246
|
+
bitmap = finalBitmap.asImageBitmap(),
|
|
3247
|
+
contentDescription = "PDF Page ${index + 1}",
|
|
3248
|
+
contentScale = ContentScale.Fit,
|
|
3249
|
+
modifier = Modifier
|
|
3250
|
+
.width(displayWidth)
|
|
3251
|
+
.aspectRatio(aspectRatio)
|
|
3252
|
+
// Remove graphicsLayer scaling - width modifier already handles the scaling
|
|
3253
|
+
// This prevents double-scaling that caused 50% to appear as 70%
|
|
3254
|
+
)
|
|
3255
|
+
|
|
3256
|
+
// Show small indicator if actively re-rendering at new zoom level (not stale)
|
|
3257
|
+
val renderStartTime = renderingPages[index]
|
|
3258
|
+
val currentTime = System.currentTimeMillis()
|
|
3259
|
+
val isActivelyRendering = renderStartTime != null &&
|
|
3260
|
+
(currentTime - renderStartTime) < RENDERING_TIMEOUT_MS
|
|
3261
|
+
|
|
3262
|
+
if (needsRerender && isActivelyRendering && !isZoomInProgress) {
|
|
3263
|
+
Card(
|
|
3264
|
+
modifier = Modifier
|
|
3265
|
+
.align(Alignment.TopEnd)
|
|
3266
|
+
.padding(8.dp),
|
|
3267
|
+
elevation = 4.dp,
|
|
3268
|
+
backgroundColor = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f)
|
|
3269
|
+
) {
|
|
3270
|
+
CircularProgressIndicator(
|
|
3271
|
+
modifier = Modifier
|
|
3272
|
+
.size(20.dp)
|
|
3273
|
+
.padding(4.dp),
|
|
3274
|
+
strokeWidth = 2.dp
|
|
3275
|
+
)
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
} else if (shouldShowPlaceholder) {
|
|
3282
|
+
// Show placeholder during pinch zoom if bitmap not available
|
|
3283
|
+
Box(
|
|
3284
|
+
modifier = Modifier
|
|
3285
|
+
.width((screenWidthDp - 32.dp) * scale)
|
|
3286
|
+
.height((screenWidthDp - 32.dp) * scale * 1.414f),
|
|
3287
|
+
contentAlignment = Alignment.Center
|
|
3288
|
+
) {
|
|
3289
|
+
CircularProgressIndicator()
|
|
3290
|
+
}
|
|
3291
|
+
} else if (!isScaleAcceptable && isValidBitmap && !(isPinchZoomActive || recentPinchZoom)) {
|
|
3292
|
+
// CRITICAL FIX: Page is at wrong scale during rapid zoom - show loading indicator
|
|
3293
|
+
// BUT only if NOT during pinch zoom or recent pinch zoom
|
|
3294
|
+
// This prevents showing pages at old scales (e.g., 40% when zoomed to 60%)
|
|
3295
|
+
Box(
|
|
3296
|
+
modifier = Modifier
|
|
3297
|
+
.width((screenWidthDp - 32.dp) * scale)
|
|
3298
|
+
.height((screenWidthDp - 32.dp) * scale * 1.4f)
|
|
3299
|
+
.padding(8.dp),
|
|
3300
|
+
contentAlignment = Alignment.Center
|
|
3301
|
+
) {
|
|
3302
|
+
Column(
|
|
3303
|
+
horizontalAlignment = Alignment.CenterHorizontally
|
|
3304
|
+
) {
|
|
3305
|
+
CircularProgressIndicator(
|
|
3306
|
+
modifier = Modifier.size(32.dp)
|
|
3307
|
+
)
|
|
3308
|
+
Spacer(modifier = Modifier.height(8.dp))
|
|
3309
|
+
Text(
|
|
3310
|
+
// text = " ${(scale * 100).toInt()}%...",
|
|
3311
|
+
text = "",
|
|
3312
|
+
style = MaterialTheme.typography.caption
|
|
3313
|
+
)
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
} else {
|
|
3317
|
+
// Page not loaded yet - check if actively rendering or queued
|
|
3318
|
+
val renderStartTime = renderingPages[index]
|
|
3319
|
+
val currentTime = System.currentTimeMillis()
|
|
3320
|
+
val isActivelyRendering = renderStartTime != null &&
|
|
3321
|
+
(currentTime - renderStartTime) < RENDERING_TIMEOUT_MS
|
|
3322
|
+
val isQueued = queuedPages.value.contains(index)
|
|
3323
|
+
|
|
3324
|
+
// Only show loading if actively rendering (not stale) or queued
|
|
3325
|
+
if (isActivelyRendering || isQueued) {
|
|
3326
|
+
Box(
|
|
3327
|
+
modifier = Modifier
|
|
3328
|
+
.width((screenWidthDp - 32.dp) * scale)
|
|
3329
|
+
.height((screenWidthDp - 32.dp) * scale * 1.4f) // Approximate A4 ratio
|
|
3330
|
+
.padding(8.dp),
|
|
3331
|
+
contentAlignment = Alignment.Center
|
|
3332
|
+
) {
|
|
3333
|
+
Column(
|
|
3334
|
+
horizontalAlignment = Alignment.CenterHorizontally
|
|
3335
|
+
) {
|
|
3336
|
+
CircularProgressIndicator(
|
|
3337
|
+
modifier = Modifier.size(32.dp)
|
|
3338
|
+
)
|
|
3339
|
+
Spacer(modifier = Modifier.height(8.dp))
|
|
3340
|
+
Text(
|
|
3341
|
+
text = "Loading page ${index + 1}...",
|
|
3342
|
+
style = MaterialTheme.typography.caption
|
|
3343
|
+
)
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
} else {
|
|
3347
|
+
// Page not loading (might be stuck) - show empty space
|
|
3348
|
+
// This prevents stuck loaders
|
|
3349
|
+
Box(
|
|
3350
|
+
modifier = Modifier
|
|
3351
|
+
.width((screenWidthDp - 32.dp) * scale)
|
|
3352
|
+
.height((screenWidthDp - 32.dp) * scale * 1.4f)
|
|
3353
|
+
.padding(8.dp),
|
|
3354
|
+
contentAlignment = Alignment.Center
|
|
3355
|
+
) {
|
|
3356
|
+
// Empty - page will load when scrolled into view or retried
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
// Add page number label
|
|
3362
|
+
Text(
|
|
3363
|
+
text = "Page ${index + 1} of $pageCount",
|
|
3364
|
+
modifier = Modifier.padding(vertical = 8.dp),
|
|
3365
|
+
style = MaterialTheme.typography.caption
|
|
3366
|
+
)
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
// Current page indicator (floating badge) - bottom-right corner
|
|
3372
|
+
Card(
|
|
3373
|
+
modifier = Modifier
|
|
3374
|
+
.align(Alignment.BottomEnd)
|
|
3375
|
+
.padding(16.dp),
|
|
3376
|
+
elevation = 4.dp,
|
|
3377
|
+
backgroundColor = androidx.compose.ui.graphics.Color(0xFFF5F5F5), // Light grey background
|
|
3378
|
+
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
|
|
3379
|
+
) {
|
|
3380
|
+
Text(
|
|
3381
|
+
text = "Page ${currentPage.value} of $pageCount",
|
|
3382
|
+
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
|
3383
|
+
style = MaterialTheme.typography.body2,
|
|
3384
|
+
color = androidx.compose.ui.graphics.Color.Black
|
|
3385
|
+
)
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
} // Close Column
|
|
3389
|
+
} else {
|
|
3390
|
+
// Show loading progress during initial load
|
|
3391
|
+
Box(
|
|
3392
|
+
modifier = Modifier.fillMaxSize(),
|
|
3393
|
+
contentAlignment = Alignment.Center
|
|
3394
|
+
) {
|
|
3395
|
+
Column(
|
|
3396
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
3397
|
+
verticalArrangement = Arrangement.Center,
|
|
3398
|
+
modifier = Modifier.padding(32.dp)
|
|
3399
|
+
) {
|
|
3400
|
+
CircularProgressIndicator(
|
|
3401
|
+
modifier = Modifier.size(64.dp),
|
|
3402
|
+
color = androidx.compose.ui.graphics.Color(0xFF9C27B0) // Purple color
|
|
3403
|
+
)
|
|
3404
|
+
Spacer(modifier = Modifier.height(24.dp))
|
|
3405
|
+
Text(
|
|
3406
|
+
// text = "${(scale * 100).toInt()}%...",
|
|
3407
|
+
text = "",
|
|
3408
|
+
style = MaterialTheme.typography.h6,
|
|
3409
|
+
color = androidx.compose.ui.graphics.Color.Black
|
|
3410
|
+
)
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
// Page info in bottom-right corner (even during loading)
|
|
3414
|
+
if (pageCount > 0) {
|
|
3415
|
+
Card(
|
|
3416
|
+
modifier = Modifier
|
|
3417
|
+
.align(Alignment.BottomEnd)
|
|
3418
|
+
.padding(16.dp),
|
|
3419
|
+
elevation = 4.dp,
|
|
3420
|
+
backgroundColor = androidx.compose.ui.graphics.Color(0xFFF5F5F5), // Light grey background
|
|
3421
|
+
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
|
|
3422
|
+
) {
|
|
3423
|
+
Text(
|
|
3424
|
+
text = "Page 1 of $pageCount",
|
|
3425
|
+
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
|
3426
|
+
style = MaterialTheme.typography.body2,
|
|
3427
|
+
color = androidx.compose.ui.graphics.Color.Black
|
|
3428
|
+
)
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
// ============================================================================
|
|
3438
|
+
// PREVIEW FUNCTION
|
|
3439
|
+
// ============================================================================
|
|
3440
|
+
|
|
3441
|
+
@Preview(showBackground = true, name = "PDF Viewer Preview")
|
|
3442
|
+
@Composable
|
|
3443
|
+
fun PdfRendererViewPreview() {
|
|
3444
|
+
MaterialTheme {
|
|
3445
|
+
Box(
|
|
3446
|
+
modifier = Modifier
|
|
3447
|
+
.fillMaxSize()
|
|
3448
|
+
.background(Color.White)
|
|
3449
|
+
) {
|
|
3450
|
+
PdfRendererView(
|
|
3451
|
+
uri = Uri.EMPTY,
|
|
3452
|
+
initialPageIndex = -1,
|
|
3453
|
+
maxWidthDp = null,
|
|
3454
|
+
maxZoom = PdfViewerConfig.MAX_SCALE_SMALL_SCREEN,
|
|
3455
|
+
screenWidthPercentage = 100f,
|
|
3456
|
+
iconUrls = null,
|
|
3457
|
+
isFullScreen = false,
|
|
3458
|
+
backButtonText = "Back",
|
|
3459
|
+
headerText = "PDF Preview",
|
|
3460
|
+
onFullScreenChange = null,
|
|
3461
|
+
onLeftScreenChange = null,
|
|
3462
|
+
onRightScreenChange = null,
|
|
3463
|
+
onZoomChange = null
|
|
3464
|
+
)
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
}
|