吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 272|回复: 5
上一主题 下一主题
收起左侧

[其他转载] 安卓阅读APP,啥广告都没,新出生的毛孩子一样

[复制链接]
跳转到指定楼层
楼主
再贱就再见 发表于 2026-10-19 22:21 回帖奖励
本帖最后由 再贱就再见 于 2026-10-19 22:22 编辑


https://wwbmj.lanzouu.com/iN9no4ckkrwb
密码:cu8p









[Java] 纯文本查看 复制代码
package com.etye.reader.reader

import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.sin

internal data class CurlPoint(val x: Float, val y: Float)

internal data class CurlFrame(
  val touch: CurlPoint,
  val corner: CurlPoint,
  val start1: CurlPoint,
  val control1: CurlPoint,
  val end1: CurlPoint,
  val vertex1: CurlPoint,
  val start2: CurlPoint,
  val control2: CurlPoint,
  val end2: CurlPoint,
  val vertex2: CurlPoint,
)

/**
 * Bezier page curl used by simulated page turns.
 * The touch point is where the finger holds the sheet; the corner is the
 * page corner being lifted (right/left, and top or bottom).
 */
internal object CurlGeometry {
  fun touch(
    progress: Float,
    forward: Boolean,
    width: Float,
    height: Float,
    fromTop: Boolean = false,
  ): CurlPoint {
    val p = progress.coerceIn(0f, 1f)
    val lift = sin(p * Math.PI).toFloat() * height * 0.14f
    val travel = p * width * 1.65f
    val x = if (forward) width - 2f - travel else 2f + travel
    val y = if (fromTop) 2f + lift + p * height * 0.06f else height - 2f - lift - p * height * 0.06f
    return CurlPoint(x, y)
  }

  fun corner(forward: Boolean, width: Float, height: Float, fromTop: Boolean = false): CurlPoint {
    return CurlPoint(if (forward) width else 0f, if (fromTop) 0f else height)
  }

  fun frame(touch: CurlPoint, corner: CurlPoint, width: Float, height: Float): CurlFrame? {
    var ax = touch.x
    var ay = touch.y
    if (abs(corner.x - ax) < 1f) ax = corner.x + if (corner.x > width / 2f) -1f else 1f
    if (abs(corner.y - ay) < 1f) {
      ay = if (corner.y <= height / 2f) corner.y + 1f else corner.y - 1f
    }
    val adjusted = limitTouch(CurlPoint(ax, ay), corner, width, height)
    return points(adjusted, corner)
  }

  private fun limitTouch(touch: CurlPoint, corner: CurlPoint, width: Float, height: Float): CurlPoint {
    if (touch.x <= 0f || touch.x >= width) return touch
    val first = points(touch, corner) ?: return touch
    if (first.start1.x in 0f..width) return touch
    val reflected = if (first.start1.x < 0f) width - first.start1.x else first.start1.x
    if (reflected < 1f) return touch
    val horizontal = abs(corner.x - touch.x)
    if (horizontal < 1f) return touch
    val nextX = abs(corner.x - width * horizontal / reflected)
    val nextY = abs(corner.y - abs(corner.x - nextX) * abs(corner.y - touch.y) / horizontal)
    val maxY = (height - 1f).coerceAtLeast(1f)
    return CurlPoint(nextX, nextY.coerceIn(1f, maxY))
  }

  private fun points(touch: CurlPoint, corner: CurlPoint): CurlFrame? {
    val middleX = (touch.x + corner.x) / 2f
    val middleY = (touch.y + corner.y) / 2f
    val dx = corner.x - middleX
    val dy = corner.y - middleY
    if (abs(dx) < 0.5f || abs(dy) < 0.5f) return null
    val control1 = CurlPoint(middleX - dy * dy / dx, corner.y)
    val control2 = CurlPoint(corner.x, middleY - dx * dx / dy)
    val start1 = CurlPoint(control1.x - (corner.x - control1.x) / 2f, corner.y)
    val start2 = CurlPoint(corner.x, control2.y - (corner.y - control2.y) / 2f)
    val end1 = cross(touch, control1, start1, start2)
    val end2 = cross(touch, control2, start1, start2)
    if (!end1.finite() || !end2.finite()) return null
    val vertex1 = CurlPoint(
      (start1.x + 2f * control1.x + end1.x) / 4f,
      (start1.y + 2f * control1.y + end1.y) / 4f,
    )
    val vertex2 = CurlPoint(
      (start2.x + 2f * control2.x + end2.x) / 4f,
      (start2.y + 2f * control2.y + end2.y) / 4f,
    )
    if (!vertex1.finite() || !vertex2.finite()) return null
    return CurlFrame(touch, corner, start1, control1, end1, vertex1, start2, control2, end2, vertex2)
  }

  private fun cross(a: CurlPoint, b: CurlPoint, c: CurlPoint, d: CurlPoint): CurlPoint {
    val denom = (d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y)
    if (abs(denom) < 0.001f) return b
    val ua = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / denom
    return CurlPoint(a.x + ua * (b.x - a.x), a.y + ua * (b.y - a.y))
  }

  private fun CurlPoint.finite(): Boolean = x.isFinite() && y.isFinite()

  fun peelDistance(frame: CurlFrame): Float {
    return hypot(frame.touch.x - frame.corner.x, frame.touch.y - frame.corner.y)
  }
}


//////// FILE: app/src/main/java/com/etye/reader/reader/SlideSettle.kt ////////

package com.etye.reader.reader

import kotlin.math.abs
import kotlin.math.roundToInt

/**
 * Decides whether releasing a slide should finish the turn.
 *
 * [progress] is how far the finger has pulled the page, as a fraction of the page.
 * [velocityPxPerSec] follows the finger: negative moves toward the next page.
 * [towardNext] is the side that is open when the finger lifts.
 * A short pull still finishes when the flick is fast. A pull past the midpoint
 * finishes even if the finger starts back. A clear pull back below the midpoint rebounds.
 */
internal fun slideShouldFinish(
  progress: Float,
  velocityPxPerSec: Float,
  towardNext: Boolean,
  flingPxPerSec: Float = 1100f,
): Boolean {
  if (progress >= 0.5f) return true
  val speed = abs(velocityPxPerSec)
  val fling = flingPxPerSec.coerceAtLeast(1f)
  val opposite = if (towardNext) velocityPxPerSec > 0f else velocityPxPerSec < 0f
  val sameWay = if (towardNext) velocityPxPerSec < 0f else velocityPxPerSec > 0f
  if (opposite && speed >= fling * 0.65f) return false
  if (progress >= 0.16f) return true
  if (sameWay && speed >= fling && progress > 0.04f) return true
  return false
}

/**
 * Whole-pixel position of the page that is sliding away.
 * The incoming page is placed exactly one span away, so the two edges meet
 * and never share a pixel. During the settle, the final pixel snaps off
 * instead of sitting on screen as a fractional sliver.
 */
internal fun slideDrawOffset(offset: Float, span: Int, settling: Boolean): Int {
  val distance = span.coerceAtLeast(1)
  if (settling && abs(offset) > 1f && abs(distance - abs(offset)) <= 1f) {
    return if (offset < 0f) -distance else distance
  }
  return offset.roundToInt().coerceIn(-distance, distance)
}


//////// FILE: app/src/main/java/com/etye/reader/reader/Paginator.kt ////////

package com.etye.reader.reader

import android.graphics.Paint
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import java.util.Locale

internal fun readerLayout(
  text: CharSequence,
  start: Int,
  end: Int,
  paint: TextPaint,
  widthPx: Int,
  lineSpacing: Float,
): StaticLayout {
  val safeStart = start.coerceIn(0, text.length)
  val safeEnd = end.coerceIn(safeStart, text.length)
  val builder = StaticLayout.Builder.obtain(text, safeStart, safeEnd, paint, widthPx.coerceAtLeast(1))
    .setAlignment(Layout.Alignment.ALIGN_NORMAL)
    .setIncludePad(false)
    .setLineSpacing(0f, lineSpacing)
    .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE)
    .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE)
  if (Build.VERSION.SDK_INT >= 28) {
    builder.setUseLineSpacingFromFallbacks(false)
  }
  return builder.build()
}

/** Height the last line's box must stay inside, leaving a gap above the footer. */
internal fun pageUsableHeight(heightPx: Int, fontPx: Float): Int {
  val guard = (fontPx * 0.2f).toInt().coerceAtLeast(1)
  return (heightPx - guard).coerceAtLeast(1)
}

/** Bottom of the last line whose box sits fully inside [maxHeight]. */
internal fun fittedLineBottom(layout: StaticLayout, maxHeight: Int): Int {
  if (maxHeight <= 0 || layout.lineCount == 0) return 0
  var fitted = 0
  for (line in 0 until layout.lineCount) {
    val bottom = layout.getLineBottom(line)
    if (bottom <= maxHeight) fitted = bottom else break
  }
  return if (fitted > 0) fitted else layout.getLineBottom(0).coerceAtMost(maxHeight)
}

object Paginator {
  /**
   * Pages are cut with the same StaticLayout the page views draw, so the
   * last line's box ends above the footer instead of being clipped through
   * the glyphs. Each layout is only about one page long.
   */
  fun paginate(
    text: String,
    widthPx: Int,
    heightPx: Int,
    fontPx: Float,
    lineSpacing: Float,
  ): List<Int> {
    if (text.isEmpty() || widthPx <= 0 || heightPx <= 0) return listOf(0)
    val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
      textSize = fontPx
      isElegantTextHeight = false
      textLocale = Locale.SIMPLIFIED_CHINESE
    }
    val usableHeight = pageUsableHeight(heightPx, fontPx)
    val probe = readerLayout("字\n字", 0, 3, paint, widthPx, lineSpacing)
    val lineHeight = if (probe.lineCount >= 2) {
      (probe.getLineBottom(1) - probe.getLineBottom(0)).coerceAtLeast(1)
    } else {
      probe.getLineBottom(0).coerceAtLeast(1)
    }
    val linesGuess = (usableHeight / lineHeight).coerceAtLeast(1)
    val charsPerLine = (widthPx / fontPx.coerceAtLeast(1f)).toInt().coerceAtLeast(8)
    var span = (linesGuess * charsPerLine + charsPerLine).coerceIn(80, 2000)
    val starts = ArrayList<Int>(text.length / (linesGuess * charsPerLine).coerceAtLeast(32) + 1)
    var index = 0
    var guardCount = 0
    while (index < text.length && guardCount++ < text.length + 2) {
      starts += index
      var pageEnd = minOf(text.length, index + 1)
      while (true) {
        val windowEnd = minOf(text.length, index + span)
        val layout = readerLayout(text, index, windowEnd, paint, widthPx, lineSpacing)
        if (layout.lineCount == 0) break
        val entireFits = layout.getLineBottom(layout.lineCount - 1) <= usableHeight
        if (entireFits && windowEnd < text.length) {
          val grown = (span * 2).coerceAtMost((text.length - index).coerceAtLeast(span + 1))
          if (grown <= span) {
            pageEnd = windowEnd
            break
          }
          span = grown
          continue
        }
        var lastFit = -1
        for (line in 0 until layout.lineCount) {
          if (layout.getLineBottom(line) <= usableHeight) lastFit = line else break
        }
        pageEnd = if (lastFit >= 0) {
          absoluteLineEnd(layout, lastFit, index)
        } else {
          val forced = absoluteLineEnd(layout, 0, index)
          if (forced > index) forced else minOf(text.length, index + 1)
        }
        break
      }
      if (pageEnd <= index) pageEnd = minOf(text.length, index + 1)
      index = pageEnd
    }
    return starts.ifEmpty { listOf(0) }
  }

  private fun absoluteLineEnd(layout: StaticLayout, line: Int, rangeStart: Int): Int {
    val end = layout.getLineEnd(line)
    return if (rangeStart > 0 && layout.getLineStart(0) == 0) rangeStart + end else end
  }
}


//////// FILE: app/src/main/java/com/etye/reader/reader/PageCurlView.kt ////////

package com.etye.reader.reader

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.LinearGradient
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Shader
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import android.view.animation.PathInterpolator
import java.util.Locale
import kotlin.math.hypot

/**
 * Full-page curl. The bitmap is the whole reading sheet, margins included,
 * and the corner follows the finger. The next page shows through the opening,
 * and the lifted flap is a mirrored copy of this page. Releasing past the
 * threshold finishes the turn, otherwise the sheet springs back.
 */
class PageCurlView @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
) : View(context, attrs) {
  var isTurning: Boolean = false
    private set

  private var pageText: String = ""
  private var pendingText: String = ""
  private var textColor: Int = 0xFF3A3228.toInt()
  private var pageColor: Int = 0xFFF6F1E3.toInt()
  private var fontPx: Float = 48f
  private var spacing: Float = 1.75f
  private var insetLeft: Int = 0
  private var insetTop: Int = 0
  private var insetRight: Int = 0
  private var insetBottom: Int = 0
  private var forward: Boolean = true
  private var fromTop: Boolean = false
  private var scripted: Boolean = false
  private var dragging: Boolean = false
  private var commitOnEnd: Boolean = true
  private var progress: Float = 0f
  private var touchX: Float = 0f
  private var touchY: Float = 0f
  private var turnToken: Int = 0
  private var currentBitmap: Bitmap? = null
  private var nextBitmap: Bitmap? = null
  private var turnCallback: (() -> Unit)? = null
  private var animator: ValueAnimator? = null
  private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
  private val backPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
    colorFilter = ColorMatrixColorFilter(
      ColorMatrix(
        floatArrayOf(
          0.55f, 0.04f, 0.02f, 0f, 38f,
          0.02f, 0.50f, 0.02f, 0f, 32f,
          0.01f, 0.03f, 0.42f, 0f, 24f,
          0f, 0f, 0f, 1f, 0f,
        ),
      ),
    )
  }
  private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
  private val shadePaint = Paint(Paint.ANTI_ALIAS_FLAG)
  private val peelPath = Path()
  private val backPath = Path()
  private val mirrorMatrix = Matrix()
  private val matrixValues = FloatArray(9)

  fun show(text: String, textColor: Int, pageColor: Int, fontPx: Float, spacing: Float) {
    val changed = text != pageText || textColor != this.textColor || pageColor != this.pageColor ||
      fontPx != this.fontPx || spacing != this.spacing
    this.textColor = textColor
    this.pageColor = pageColor
    this.fontPx = fontPx
    this.spacing = spacing
    if (isTurning) return
    if (!changed && text == pageText) return
    pageText = text
    redraw()
  }

  /** Paper margins inside the sheet. They curl with the page instead of framing it. */
  fun contentInset(left: Int, top: Int, right: Int, bottom: Int) {
    if (left == insetLeft && top == insetTop && right == insetRight && bottom == insetBottom) return
    insetLeft = left.coerceAtLeast(0)
    insetTop = top.coerceAtLeast(0)
    insetRight = right.coerceAtLeast(0)
    insetBottom = bottom.coerceAtLeast(0)
    if (!isTurning) redraw()
  }

  /** Compose on some Harmony builds drops a child invalidate, so the parent must be marked too. */
  private fun redraw() {
    invalidate()
    val host = parent
    if (host is View) host.invalidate()
  }

  fun turn(
    toText: String,
    forward: Boolean,
    alignEnd: Boolean = false,
    fromTop: Boolean = false,
    onEnd: () -> Unit,
  ): Boolean {
    if (!arm(toText, forward, alignEnd, onEnd)) return !isTurning
    scripted = true
    dragging = false
    commitOnEnd = true
    this.fromTop = fromTop
    progress = 0f
    animate(0f, 1f, 560L) { value ->
      progress = value
      redraw()
    }
    return true
  }

  /** Finger-down after the drag is clearly horizontal. [x] and [y] are the virtual touch point. */
  fun grab(
    toText: String,
    forward: Boolean,
    alignEnd: Boolean,
    x: Float,
    y: Float,
    onCommit: () -> Unit,
  ): Boolean {
    if (!arm(toText, forward, alignEnd, onCommit)) return !isTurning
    scripted = false
    dragging = true
    commitOnEnd = false
    this.fromTop = y < height * 0.5f
    touchX = x
    touchY = y
    redraw()
    return true
  }

  fun follow(x: Float, y: Float) {
    if (!dragging) return
    touchX = x
    touchY = y
    val heightF = height.toFloat()
    fromTop = when {
      y < heightF * 0.42f -> true
      y > heightF * 0.58f -> false
      else -> fromTop
    }
    redraw()
  }

  fun release() {
    if (!dragging) return
    dragging = false
    val widthF = width.toFloat().coerceAtLeast(1f)
    val heightF = height.toFloat().coerceAtLeast(1f)
    val traveled = if (forward) (widthF - touchX) / widthF else touchX / widthF
    val finish = traveled >= 0.25f
    commitOnEnd = finish
    val startX = touchX
    val startY = touchY
    val end = if (finish) exitPoint(widthF, heightF) else restPoint(widthF, heightF)
    animate(0f, 1f, if (finish) 380L else 260L) { t ->
      touchX = startX + (end.x - startX) * t
      touchY = startY + (end.y - startY) * t
      redraw()
    }
  }

  override fun onDetachedFromWindow() {
    turnToken += 1
    animator?.cancel()
    animator = null
    dragging = false
    isTurning = false
    turnCallback = null
    recycleBitmaps()
    super.onDetachedFromWindow()
  }

  override fun onDraw(canvas: Canvas) {
    val current = currentBitmap
    val next = nextBitmap
    if (!isTurning || current == null || next == null) {
      canvas.drawColor(pageColor)
      drawText(canvas, pageText)
      return
    }
    val widthF = width.toFloat()
    val heightF = height.toFloat()
    val touch = if (scripted) {
      CurlGeometry.touch(progress, forward, widthF, heightF, fromTop)
    } else {
      CurlPoint(touchX, touchY)
    }
    val corner = CurlGeometry.corner(forward, widthF, heightF, fromTop)
    val frame = CurlGeometry.frame(touch, corner, widthF, heightF)
    val opened = if (scripted) progress > 0.92f else horizontalProgress(touch, widthF) > 0.92f
    if (frame == null || CurlGeometry.peelDistance(frame) < 12f) {
      canvas.drawColor(pageColor)
      canvas.drawBitmap(if (opened) next else current, 0f, 0f, bitmapPaint)
      return
    }
    buildPeel(frame)
    buildBack(frame)
    val density = resources.displayMetrics.density
    // clipPath stays on the GPU. clipOutPath falls back to software on many
    // Harmony GPUs, which drops the curl and makes the turn look disabled.
    canvas.drawBitmap(current, 0f, 0f, bitmapPaint)
    canvas.save()
    canvas.clipPath(peelPath)
    canvas.drawBitmap(next, 0f, 0f, bitmapPaint)
    drawCastShadow(canvas, frame.vertex1, frame.vertex2, frame.corner, 78, 36f * density)
    canvas.restore()
    drawMirroredBack(canvas, frame, current)
    drawCastShadow(canvas, frame.vertex1, frame.vertex2, frame.touch, 64, 20f * density)
    drawRidge(canvas, frame)
  }

  private fun arm(toText: String, forward: Boolean, alignEnd: Boolean, onEnd: () -> Unit): Boolean {
    if (isTurning) return false
    if (width <= 1 || height <= 1) {
      pageText = toText
      redraw()
      onEnd()
      return false
    }
    val from = render(pageText, alignEnd = false)
    val to = render(toText, alignEnd = alignEnd)
    if (from == null || to == null) {
      from?.recycle()
      to?.recycle()
      pageText = toText
      redraw()
      onEnd()
      return false
    }
    isTurning = true
    this.forward = forward
    pendingText = toText
    currentBitmap = from
    nextBitmap = to
    turnCallback = onEnd
    return true
  }

  private fun animate(from: Float, to: Float, duration: Long, onFrame: (Float) -> Unit) {
    val token = turnToken
    animator?.cancel()
    animator = ValueAnimator.ofFloat(from, to).apply {
      this.duration = duration
      interpolator = PathInterpolator(0.22f, 0.08f, 0.18f, 1f)
      addUpdateListener { value -> onFrame(value.animatedValue as Float) }
      addListener(object : AnimatorListenerAdapter() {
        private var cancelled = false

        override fun onAnimationCancel(animation: Animator) {
          cancelled = true
        }

        override fun onAnimationEnd(animation: Animator) {
          if (cancelled || token != turnToken || !isTurning) return
          if (commitOnEnd) completeTurn() else revertTurn()
        }
      })
      start()
    }
  }

  private fun exitPoint(widthF: Float, heightF: Float): CurlPoint {
    val x = if (forward) -widthF * 0.35f else widthF * 1.35f
    val y = if (fromTop) heightF * 0.38f else heightF * 0.62f
    return CurlPoint(x, y)
  }

  private fun restPoint(widthF: Float, heightF: Float): CurlPoint {
    val x = if (forward) widthF - 1.5f else 1.5f
    val y = if (fromTop) 1.5f else heightF - 1.5f
    return CurlPoint(x, y)
  }

  private fun horizontalProgress(touch: CurlPoint, widthF: Float): Float {
    if (widthF <= 1f) return 0f
    return if (forward) (widthF - touch.x) / widthF else touch.x / widthF
  }

  private fun buildPeel(frame: CurlFrame) {
    peelPath.reset()
    peelPath.moveTo(frame.start1.x, frame.start1.y)
    peelPath.quadTo(frame.control1.x, frame.control1.y, frame.end1.x, frame.end1.y)
    peelPath.lineTo(frame.touch.x, frame.touch.y)
    peelPath.lineTo(frame.end2.x, frame.end2.y)
    peelPath.quadTo(frame.control2.x, frame.control2.y, frame.start2.x, frame.start2.y)
    peelPath.lineTo(frame.corner.x, frame.corner.y)
    peelPath.close()
  }

  private fun buildBack(frame: CurlFrame) {
    backPath.reset()
    backPath.moveTo(frame.vertex2.x, frame.vertex2.y)
    backPath.lineTo(frame.vertex1.x, frame.vertex1.y)
    backPath.lineTo(frame.end1.x, frame.end1.y)
    backPath.lineTo(frame.touch.x, frame.touch.y)
    backPath.lineTo(frame.end2.x, frame.end2.y)
    backPath.close()
  }

  private fun drawMirroredBack(canvas: Canvas, frame: CurlFrame, bitmap: Bitmap) {
    val dis = hypot(frame.corner.x - frame.control1.x, frame.control2.y - frame.corner.y)
    if (dis < 1f) return
    val f8 = (frame.corner.x - frame.control1.x) / dis
    val f9 = (frame.control2.y - frame.corner.y) / dis
    matrixValues[0] = 1f - 2f * f9 * f9
    matrixValues[1] = 2f * f8 * f9
    matrixValues[2] = 0f
    matrixValues[3] = matrixValues[1]
    matrixValues[4] = 1f - 2f * f8 * f8
    matrixValues[5] = 0f
    matrixValues[6] = 0f
    matrixValues[7] = 0f
    matrixValues[8] = 1f
    mirrorMatrix.setValues(matrixValues)
    mirrorMatrix.preTranslate(-frame.control1.x, -frame.control1.y)
    mirrorMatrix.postTranslate(frame.control1.x, frame.control1.y)
    canvas.save()
    canvas.clipPath(backPath)
    canvas.drawBitmap(bitmap, mirrorMatrix, backPaint)
    val crestX = (frame.vertex1.x + frame.vertex2.x) / 2f
    val crestY = (frame.vertex1.y + frame.vertex2.y) / 2f
    fillPaint.shader = LinearGradient(
      crestX,
      crestY,
      frame.touch.x,
      frame.touch.y,
      Color.argb(28, 255, 248, 236),
      Color.argb(96, 42, 30, 18),
      Shader.TileMode.CLAMP,
    )
    canvas.drawPath(backPath, fillPaint)
    fillPaint.shader = null
    canvas.restore()
  }

  private fun drawCastShadow(
    canvas: Canvas,
    from: CurlPoint,
    to: CurlPoint,
    toward: CurlPoint,
    alpha: Int,
    reach: Float,
  ) {
    val dx = to.x - from.x
    val dy = to.y - from.y
    val length = hypot(dx, dy)
    if (length < 2f || reach < 1f) return
    var px = -dy / length
    var py = dx / length
    val midX = (from.x + to.x) / 2f
    val midY = (from.y + to.y) / 2f
    if (px * (toward.x - midX) + py * (toward.y - midY) < 0f) {
      px = -px
      py = -py
    }
    val path = Path()
    path.moveTo(from.x, from.y)
    path.lineTo(to.x, to.y)
    path.lineTo(to.x + px * reach, to.y + py * reach)
    path.lineTo(from.x + px * reach, from.y + py * reach)
    path.close()
    shadePaint.shader = LinearGradient(
      midX,
      midY,
      midX + px * reach,
      midY + py * reach,
      Color.argb(alpha, 32, 24, 16),
      Color.TRANSPARENT,
      Shader.TileMode.CLAMP,
    )
    canvas.drawPath(path, shadePaint)
    shadePaint.shader = null
  }

  private fun drawRidge(canvas: Canvas, frame: CurlFrame) {
    shadePaint.shader = null
    shadePaint.color = Color.argb(110, 255, 250, 242)
    shadePaint.strokeWidth = 1.5f * resources.displayMetrics.density
    shadePaint.style = Paint.Style.STROKE
    canvas.drawLine(frame.vertex1.x, frame.vertex1.y, frame.vertex2.x, frame.vertex2.y, shadePaint)
    shadePaint.style = Paint.Style.FILL
  }

  private fun completeTurn() {
    val callback = turnCallback
    turnCallback = null
    isTurning = false
    dragging = false
    pageText = pendingText
    recycleBitmaps()
    animator = null
    redraw()
    callback?.invoke()
  }

  private fun revertTurn() {
    turnCallback = null
    isTurning = false
    dragging = false
    recycleBitmaps()
    animator = null
    redraw()
  }

  private fun recycleBitmaps() {
    currentBitmap?.let { if (!it.isRecycled) it.recycle() }
    nextBitmap?.let { if (!it.isRecycled) it.recycle() }
    currentBitmap = null
    nextBitmap = null
  }

  private fun render(text: String, alignEnd: Boolean): Bitmap? {
    return try {
      val bitmap = PageSheet.bitmap(width, height)
      val canvas = Canvas(bitmap)
      canvas.drawColor(pageColor)
      drawPageContent(canvas, text, alignEnd)
      bitmap
    } catch (_: OutOfMemoryError) {
      null
    }
  }

  private fun drawText(canvas: Canvas, text: String) {
    if (text.isEmpty() || width <= 1) return
    drawPageContent(canvas, text, alignEnd = false)
  }

  private fun drawPageContent(canvas: Canvas, text: String, alignEnd: Boolean) {
    val textWidth = (width - insetLeft - insetRight).coerceAtLeast(1)
    val textHeight = (height - insetTop - insetBottom).coerceAtLeast(1)
    val layout = layoutOf(PageSheet.textWindow(text, alignEnd), textWidth)
    val limit = pageUsableHeight(textHeight, fontPx)
    canvas.save()
    canvas.translate(insetLeft.toFloat(), insetTop.toFloat())
    if (alignEnd) {
      // Clip the box first, then slide the tail up inside it, so the last
      // line ends on the box edge instead of being sliced into the footer.
      canvas.clipRect(0f, 0f, textWidth.toFloat(), limit.toFloat())
      val shift = (limit - layout.height).coerceAtMost(0).toFloat()
      if (shift != 0f) canvas.translate(0f, shift)
    } else {
      canvas.clipRect(0f, 0f, textWidth.toFloat(), fittedLineBottom(layout, limit).toFloat())
    }
    layout.draw(canvas)
    canvas.restore()
  }

  private fun layoutOf(text: String, widthPx: Int): StaticLayout {
    val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
      color = textColor
      textSize = fontPx
      isElegantTextHeight = false
      textLocale = Locale.SIMPLIFIED_CHINESE
    }
    return readerLayout(text, 0, text.length, paint, widthPx, spacing)
  }
}


//////// FILE: app/src/main/java/com/etye/reader/reader/PageSlideView.kt ////////

package com.etye.reader.reader

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import android.view.animation.DecelerateInterpolator
import java.util.Locale
import kotlin.math.abs

/**
 * Plain pager slide. The bitmap is the whole reading page, including the
 * paper margins around the text, and that sheet moves with the finger.
 * No cover and no edge shadow. Either neighbor can be pulled, including
 * reversing past the start. A missing side rubber-bands and always returns.
 */
class PageSlideView @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
) : View(context, attrs) {
  enum class Axis { HORIZONTAL, VERTICAL }

  var axis: Axis = Axis.HORIZONTAL

  var isTurning: Boolean = false
    private set

  private var pageText: String = ""
  private var pendingNext: String = ""
  private var pendingPrev: String = ""
  private var textColor: Int = 0xFF3A3228.toInt()
  private var pageColor: Int = 0xFFF6F1E3.toInt()
  private var fontPx: Float = 48f
  private var spacing: Float = 1.75f
  private var insetLeft: Int = 0
  private var insetTop: Int = 0
  private var insetRight: Int = 0
  private var insetBottom: Int = 0
  private var dragging: Boolean = false
  private var commitOnEnd: Boolean = true
  private var settledNext: Boolean = true
  private var offset: Float = 0f
  private var rawOffset: Float = 0f
  private var hasNext: Boolean = false
  private var hasPrev: Boolean = false
  private var sheetDirty: Boolean = true
  private var preparedDirty: Boolean = false
  private var restyleAfterTurn: Boolean = false
  private var preparedNext: String? = null
  private var preparedPrev: String? = null
  private var preparedPrevAlign: Boolean = false
  private var paintPosted: Boolean = false
  private var holdPaint: Boolean = false
  private var turnToken: Int = 0
  private var missForward: Boolean? = null
  private var sheet: Bitmap? = null
  private var ahead: Bitmap? = null
  private var behind: Bitmap? = null
  private var onNext: (() -> Unit)? = null
  private var onPrev: (() -> Unit)? = null
  private var onMiss: ((Boolean) -> Unit)? = null
  private val retiring = ArrayList<Bitmap>(2)
  private var animator: ValueAnimator? = null
  private val bitmapPaint = Paint().apply { isFilterBitmap = false }
  private val decelerate = DecelerateInterpolator(1.8f)

  fun show(text: String, textColor: Int, pageColor: Int, fontPx: Float, spacing: Float) {
    val changed = text != pageText || textColor != this.textColor || pageColor != this.pageColor ||
      fontPx != this.fontPx || spacing != this.spacing
    this.textColor = textColor
    this.pageColor = pageColor
    this.fontPx = fontPx
    this.spacing = spacing
    if (isTurning) {
      if (changed) restyleAfterTurn = true
      return
    }
    if (!changed && !restyleAfterTurn) return
    restyleAfterTurn = false
    pageText = text
    sheetDirty = true
    preparedDirty = true
    schedulePaint()
    redraw()
  }

  /**
   * Paper margins inside the page bitmap. The view itself is the full
   * reading area, so these insets slide with the text instead of staying
   * put as a frame around a smaller sheet.
   */
  fun contentInset(left: Int, top: Int, right: Int, bottom: Int) {
    if (left == insetLeft && top == insetTop && right == insetRight && bottom == insetBottom) return
    insetLeft = left.coerceAtLeast(0)
    insetTop = top.coerceAtLeast(0)
    insetRight = right.coerceAtLeast(0)
    insetBottom = bottom.coerceAtLeast(0)
    if (isTurning) {
      restyleAfterTurn = true
      return
    }
    sheetDirty = true
    preparedDirty = true
    schedulePaint()
    redraw()
  }

  /** Compose on some Harmony builds drops a child invalidate, so the parent must be marked too. */
  private fun redraw() {
    invalidate()
    val host = parent
    if (host is View) host.invalidate()
  }

  /**
   * Renders the neighbor pages ahead of the gesture so the first move
   * does not allocate bitmaps on the pointer frame.
   */
  fun prepare(nextText: String?, prevText: String?, prevAlignEnd: Boolean) {
    if (nextText == preparedNext && prevText == preparedPrev && prevAlignEnd == preparedPrevAlign && !preparedDirty) {
      return
    }
    preparedNext = nextText
    preparedPrev = prevText
    preparedPrevAlign = prevAlignEnd
    preparedDirty = true
    if (!isTurning) schedulePaint()
  }

  fun turn(toText: String, forward: Boolean, alignEnd: Boolean = false, onEnd: () -> Unit): Boolean {
    if (isTurning) return false
    if (width <= 1 || height <= 1) {
      pageText = toText
      sheetDirty = true
      redraw()
      onEnd()
      return false
    }
    ensureSheet()
    val dest = paintInto(if (forward) ahead else behind, toText, alignEnd)
    if (sheet == null || dest == null) {
      pageText = toText
      sheetDirty = true
      redraw()
      onEnd()
      return false
    }
    if (forward) {
      ahead = dest
      hasNext = true
      pendingNext = toText
      onNext = onEnd
      onPrev = null
    } else {
      behind = dest
      hasPrev = true
      pendingPrev = toText
      onPrev = onEnd
      onNext = null
    }
    isTurning = true
    dragging = false
    commitOnEnd = true
    settledNext = forward
    missForward = null
    rawOffset = 0f
    offset = 0f
    animate(0f, if (forward) -span() else span(), 280L)
    return true
  }

  /**
   * Starts a finger slide that can go to the next page, the previous page,
   * or rubber-band when that side does not exist. [offsetPx] is the drag
   * since the finger went down, so the page catches the finger immediately.
   */
  fun grabSides(
    nextText: String?,
    prevText: String?,
    prevAlignEnd: Boolean,
    offsetPx: Float,
    onNext: (() -> Unit)?,
    onPrev: (() -> Unit)?,
  ): Boolean {
    if (isTurning) return false
    if (width <= 1 || height <= 1) return false
    ensureSheet()
    if (sheet == null) return false
    if (preparedNext != nextText || (ahead == null && nextText != null)) {
      preparedNext = nextText
      ahead = nextText?.let { paintInto(ahead, it, false) }
    }
    if (preparedPrev != prevText || preparedPrevAlign != prevAlignEnd || (behind == null && prevText != null)) {
      preparedPrev = prevText
      preparedPrevAlign = prevAlignEnd
      behind = prevText?.let { paintInto(behind, it, prevAlignEnd) }
    }
    preparedDirty = false
    hasNext = nextText != null && ahead != null
    hasPrev = prevText != null && behind != null
    pendingNext = nextText.orEmpty()
    pendingPrev = prevText.orEmpty()
    this.onNext = onNext
    this.onPrev = onPrev
    isTurning = true
    dragging = true
    commitOnEnd = false
    missForward = null
    rawOffset = offsetPx
    offset = presented(offsetPx)
    redraw()
    return true
  }

  fun follow(offsetPx: Float) {
    if (!dragging) return
    rawOffset = offsetPx
    offset = presented(offsetPx)
    redraw()
  }

  fun release(velocityPxPerSec: Float = 0f, onMiss: ((forward: Boolean) -> Unit)? = null) {
    if (!dragging) return
    dragging = false
    val distance = span()
    val towardNext = when {
      rawOffset < -1f -> true
      rawOffset > 1f -> false
      else -> velocityPxPerSec < 0f
    }
    val available = if (towardNext) hasNext else hasPrev
    val progress = abs(rawOffset) / distance
    val fling = (400f * resources.displayMetrics.density).coerceAtLeast(900f)
    val finish = available && slideShouldFinish(progress, velocityPxPerSec, towardNext, fling)
    commitOnEnd = finish
    settledNext = towardNext
    this.onMiss = onMiss
    missForward = if (!finish && !available && abs(rawOffset) >= missPx()) towardNext else null
    val target = when {
      finish && towardNext -> -distance
      finish -> distance
      else -> 0f
    }
    animate(offset, target, settleDuration(offset, target, abs(velocityPxPerSec), finish))
  }

  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    if (w == oldw && h == oldh) return
    if (isTurning) {
      turnToken += 1
      animator?.cancel()
      animator = null
      isTurning = false
      dragging = false
      onNext = null
      onPrev = null
      onMiss = null
      offset = 0f
      rawOffset = 0f
    }
    sheetDirty = true
    preparedDirty = true
    schedulePaint()
  }

  override fun onDetachedFromWindow() {
    turnToken += 1
    animator?.cancel()
    animator = null
    dragging = false
    isTurning = false
    onNext = null
    onPrev = null
    onMiss = null
    paintPosted = false
    holdPaint = false
    retiring.forEach { bitmap ->
      if (bitmap !== sheet && bitmap !== ahead && bitmap !== behind) recycle(bitmap)
    }
    retiring.clear()
    recycle(sheet)
    recycle(ahead)
    recycle(behind)
    sheet = null
    ahead = null
    behind = null
    super.onDetachedFromWindow()
  }

  override fun onDraw(canvas: Canvas) {
    // A filtered, fractional blit blends the departing page's edge with the
    // transparent pixels outside the bitmap. That fringe sits on top of the
    // incoming page and reads as a ghost, especially while the settle eases
    // through the last pixel. Draw opaque bitmaps on whole pixels instead.
    val savedFilter = canvas.drawFilter
    canvas.drawFilter = null
    canvas.drawColor(pageColor)
    if (!isTurning) {
      val cached = sheet
      if (!sheetDirty && cached != null && !cached.isRecycled) {
        canvas.drawBitmap(cached, 0f, 0f, bitmapPaint)
      } else {
        drawText(canvas, pageText)
      }
      canvas.drawFilter = savedFilter
      return
    }
    val horizontal = axis == Axis.HORIZONTAL
    val distance = if (horizontal) width else height
    val current = slideDrawOffset(offset, distance, settling = !dragging)
    if (current == 0) {
      drawSheet(canvas, sheet, 0, 0)
      canvas.drawFilter = savedFilter
      return
    }
    val neighbor = if (current < 0) ahead else behind
    val neighborAt = if (current < 0) current + distance else current - distance
    val movingVisible = if (current < 0) current + distance > 0 else current < distance
    canvas.save()
    canvas.clipRect(0, 0, width, height)
    if (horizontal) {
      drawSheet(canvas, neighbor, neighborAt, 0)
      if (movingVisible) drawSheet(canvas, sheet, current, 0)
    } else {
      drawSheet(canvas, neighbor, 0, neighborAt)
      if (movingVisible) drawSheet(canvas, sheet, 0, current)
    }
    canvas.restore()
    canvas.drawFilter = savedFilter
  }

  private fun drawSheet(canvas: Canvas, bitmap: Bitmap?, x: Int, y: Int) {
    if (bitmap == null || bitmap.isRecycled) return
    canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), bitmapPaint)
  }

  private fun span(): Float {
    val value = if (axis == Axis.HORIZONTAL) width.toFloat() else height.toFloat()
    return value.coerceAtLeast(1f)
  }

  private fun missPx(): Float = 48f * resources.displayMetrics.density

  private fun presented(raw: Float): Float {
    val distance = span()
    val limited = raw.coerceIn(-distance, distance)
    if (limited == 0f) return 0f
    val available = if (limited < 0f) hasNext else hasPrev
    return if (available) limited else limited * 0.22f
  }

  private fun settleDuration(from: Float, to: Float, speed: Float, finishing: Boolean): Long {
    val remaining = abs(to - from).coerceAtLeast(1f)
    val velocity = speed.coerceAtLeast(if (finishing) 1400f else 1800f)
    val millis = (remaining / velocity * 1000f).toLong()
    return if (finishing) millis.coerceIn(160L, 320L) else millis.coerceIn(140L, 200L)
  }

  private fun schedulePaint() {
    if (paintPosted) return
    paintPosted = true
    post {
      paintPosted = false
      if (!isAttachedToWindow) return@post
      // The frame still on screen references these bitmaps. Rewriting or
      // recycling them in the same turn as the settle corrupts that frame
      // and leaves a ghost of the old page. Wait one pass.
      if (holdPaint || isTurning) {
        if (holdPaint && !isTurning) holdPaint = false
        if (sheetDirty || preparedDirty || retiring.isNotEmpty()) schedulePaint()
        return@post
      }
      if (width <= 1 || height <= 1) return@post
      if (retiring.isNotEmpty()) {
        val doomed = retiring.toList()
        retiring.clear()
        doomed.forEach { recycle(it) }
      }
      if (sheetDirty) {
        sheet = paintInto(sheet, pageText, false)
        sheetDirty = sheet == null
      }
      if (preparedDirty) {
        val nextText = preparedNext
        if (nextText == null) {
          recycle(ahead)
          ahead = null
        } else {
          ahead = paintInto(ahead, nextText, false)
        }
        val prevText = preparedPrev
        if (prevText == null) {
          recycle(behind)
          behind = null
        } else {
          behind = paintInto(behind, prevText, preparedPrevAlign)
        }
        preparedDirty = (nextText != null && ahead == null) || (prevText != null && behind == null)
        hasNext = ahead != null
        hasPrev = behind != null
      }
      redraw()
    }
  }

  private fun ensureSheet() {
    if (!sheetDirty && sheet != null && sheet?.isRecycled == false && sheet?.width == width && sheet?.height == height) {
      return
    }
    sheet = paintInto(sheet, pageText, false)
    sheetDirty = sheet == null
  }

  private fun animate(from: Float, to: Float, duration: Long) {
    val token = turnToken
    animator?.cancel()
    animator = ValueAnimator.ofFloat(from, to).apply {
      this.duration = duration
      interpolator = decelerate
      addUpdateListener { value ->
        offset = value.animatedValue as Float
        redraw()
      }
      addListener(object : AnimatorListenerAdapter() {
        private var cancelled = false

        override fun onAnimationCancel(animation: Animator) {
          cancelled = true
        }

        override fun onAnimationEnd(animation: Animator) {
          if (cancelled || token != turnToken || !isTurning) return
          if (commitOnEnd) completeTurn() else revertTurn()
        }
      })
      start()
    }
  }

  private fun completeTurn() {
    val callback = if (settledNext) onNext else onPrev
    val revealed = if (settledNext) ahead else behind
    val unused = if (settledNext) behind else ahead
    val leftText = pageText
    val text = if (settledNext) pendingNext else pendingPrev
    onNext = null
    onPrev = null
    onMiss = null
    missForward = null
    pageText = text
    val old = sheet
    if (revealed != null && !revealed.isRecycled) {
      sheet = revealed
      sheetDirty = false
    } else {
      sheetDirty = true
    }
    // Keep the page that just left. It is still referenced by the frame on
    // screen, and it is the neighbor on the way back, so it must not be recycled.
    if (settledNext) {
      ahead = null
      preparedNext = null
      if (old != null && old !== sheet && !old.isRecycled) {
        behind = old
        preparedPrev = leftText
        preparedPrevAlign = false
        hasPrev = true
      }
    } else {
      behind = null
      preparedPrev = null
      if (old != null && old !== sheet && !old.isRecycled) {
        ahead = old
        preparedNext = leftText
        hasNext = true
      }
    }
    if (unused != null && unused !== sheet && unused !== ahead && unused !== behind && !unused.isRecycled) {
      retiring.add(unused)
    }
    holdPaint = true
    isTurning = false
    dragging = false
    offset = 0f
    rawOffset = 0f
    animator = null
    redraw()
    callback?.invoke()
    schedulePaint()
  }

  private fun revertTurn() {
    val miss = missForward
    val missCallback = onMiss
    missForward = null
    onMiss = null
    onNext = null
    onPrev = null
    isTurning = false
    dragging = false
    offset = 0f
    rawOffset = 0f
    animator = null
    redraw()
    if (miss != null) missCallback?.invoke(miss)
  }

  private fun recycle(bitmap: Bitmap?) {
    if (bitmap != null && !bitmap.isRecycled) bitmap.recycle()
  }

  private fun paintInto(existing: Bitmap?, text: String, alignEnd: Boolean): Bitmap? {
    return try {
      val bitmap = if (
        existing != null &&
        !existing.isRecycled &&
        existing.width == width &&
        existing.height == height
      ) {
        existing
      } else {
        recycle(existing)
        PageSheet.bitmap(width, height)
      }
      val canvas = Canvas(bitmap)
      canvas.drawColor(pageColor)
      drawPageContent(canvas, text, alignEnd)
      bitmap
    } catch (_: OutOfMemoryError) {
      null
    }
  }

  private fun drawText(canvas: Canvas, text: String) {
    if (text.isEmpty() || width <= 1) return
    drawPageContent(canvas, text, alignEnd = false)
  }

  private fun drawPageContent(canvas: Canvas, text: String, alignEnd: Boolean) {
    val textWidth = (width - insetLeft - insetRight).coerceAtLeast(1)
    val textHeight = (height - insetTop - insetBottom).coerceAtLeast(1)
    val layout = layoutOf(PageSheet.textWindow(text, alignEnd), textWidth)
    val limit = pageUsableHeight(textHeight, fontPx)
    canvas.save()
    canvas.translate(insetLeft.toFloat(), insetTop.toFloat())
    if (alignEnd) {
      // Clip the box first, then slide the tail up inside it, so the last
      // line ends on the box edge instead of being sliced into the footer.
      canvas.clipRect(0f, 0f, textWidth.toFloat(), limit.toFloat())
      val shift = (limit - layout.height).coerceAtMost(0).toFloat()
      if (shift != 0f) canvas.translate(0f, shift)
    } else {
      canvas.clipRect(0f, 0f, textWidth.toFloat(), fittedLineBottom(layout, limit).toFloat())
    }
    layout.draw(canvas)
    canvas.restore()
  }

  private fun layoutOf(text: String, widthPx: Int): StaticLayout {
    val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
      color = textColor
      textSize = fontPx
      isElegantTextHeight = false
      textLocale = Locale.SIMPLIFIED_CHINESE
    }
    return readerLayout(text, 0, text.length, paint, widthPx, spacing)
  }
}


//////// FILE: app/src/main/java/com/etye/reader/ui/reader/ReaderViewModel.kt ////////

package com.etye.reader.ui.reader

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.etye.reader.ReaderApp
import com.etye.reader.data.BookRecord
import com.etye.reader.data.ReadMode
import com.etye.reader.data.ReaderSettings
import com.etye.reader.data.ThemeMode
import com.etye.reader.parse.ChapterCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

data class ReadingPosition(val chapterIndex: Int, val charOffset: Int)

sealed interface ReaderLoad {
  data object Loading : ReaderLoad
  data class Ready(val book: BookRecord, val catalog: ChapterCatalog) : ReaderLoad
  data class Failed(val message: String) : ReaderLoad
}

class ReaderViewModel(app: Application, private val bookId: String) : AndroidViewModel(app) {
  private val repository = (app as ReaderApp).bookRepository
  private val settingsRepository = (app as ReaderApp).settingsRepository

  val settings: StateFlow<ReaderSettings> = settingsRepository.settings

  private val _load = MutableStateFlow<ReaderLoad>(ReaderLoad.Loading)
  val load: StateFlow<ReaderLoad> = _load.asStateFlow()

  private val _position = MutableStateFlow(ReadingPosition(0, 0))
  val position: StateFlow<ReadingPosition> = _position.asStateFlow()

  private val _jump = MutableStateFlow(0)
  val jump: StateFlow<Int> = _jump.asStateFlow()

  private val _warm = MutableStateFlow(false)
  val warm: StateFlow<Boolean> = _warm.asStateFlow()

  init {
    viewModelScope.launch {
      val record = repository.find(bookId) ?: repository.awaitRecord(bookId)
      if (record == null) {
        _load.value = ReaderLoad.Failed("找不到这本书,可能已经从书架删除")
        return@launch
      }
      _position.value = ReadingPosition(record.chapterIndex, record.charOffset)
      try {
        val catalog = withContext(Dispatchers.IO) { repository.openCatalog(record) }
        if (catalog.size == 0) {
          _load.value = ReaderLoad.Failed("这本书没有可阅读的正文")
          return@launch
        }
        val chapter = record.chapterIndex.coerceIn(0, catalog.size - 1)
        val scroll = settingsRepository.settings.value.mode == ReadMode.SCROLL
        withContext(Dispatchers.Default) {
          if (scroll) catalog.warmCooperative() else catalog.ensureAround(chapter)
        }
        val max = catalog.chapter(chapter).displayText.length
        val offset = record.charOffset.coerceIn(0, max)
        _position.value = ReadingPosition(chapter, offset)
        _load.value = ReaderLoad.Ready(record, catalog)
        _warm.value = catalog.lengthsReady
        if (!scroll) {
          launch(Dispatchers.Default) {
            delay(400)
            catalog.warmCooperative()
            _warm.value = true
            val current = _position.value
            val progress = catalog.bookProgress(current.chapterIndex, current.charOffset)
            repository.updateProgress(bookId, current.chapterIndex, current.charOffset, progress)
          }
        }
        launch(Dispatchers.IO) { repository.touch(bookId) }
      } catch (error: Exception) {
        _load.value = ReaderLoad.Failed(error.message ?: "无法打开这本书")
      }
    }
  }

  fun commit(chapterIndex: Int, charOffset: Int) {
    val ready = _load.value as? ReaderLoad.Ready ?: return
    val catalog = ready.catalog
    if (catalog.size == 0) return
    val chapter = chapterIndex.coerceIn(0, catalog.size - 1)
    val max = catalog.chapter(chapter).displayText.length
    val offset = charOffset.coerceIn(0, max)
    val current = _position.value
    if (current.chapterIndex == chapter && current.charOffset == offset) return
    _position.value = ReadingPosition(chapter, offset)
    val progress = catalog.bookProgress(chapter, offset)
    repository.updateProgress(bookId, chapter, offset, progress)
  }

  fun jumpTo(chapterIndex: Int, charOffset: Int) {
    commit(chapterIndex, charOffset)
    _jump.value = _jump.value + 1
  }

  fun setFont(sp: Float) {
    settingsRepository.update { it.copy(fontSp = sp.coerceIn(14f, 28f)) }
  }

  fun setSpacing(value: Float) {
    settingsRepository.update { it.copy(lineSpacing = value.coerceIn(1.3f, 2.2f)) }
  }

  fun setTheme(mode: ThemeMode) {
    settingsRepository.update { it.copy(theme = mode) }
  }

  fun setReadMode(mode: ReadMode) {
    settingsRepository.update { it.copy(mode = mode) }
  }

  fun setFullscreen(enabled: Boolean) {
    settingsRepository.update { it.copy(fullscreen = enabled) }
  }

  fun flush() {
    repository.flush()
  }

  override fun onCleared() {
    repository.flush()
  }

  companion object {
    fun factory(app: Application, bookId: String): ViewModelProvider.Factory {
      return object : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
          return ReaderViewModel(app, bookId) as T
        }
      }
    }
  }
}


//////// FILE: app/src/main/java/com/etye/reader/ui/reader/ReaderScreen.kt ////////

package com.etye.reader.ui.reader

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import android.text.format.DateFormat
import android.view.KeyEvent
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.etye.reader.MainActivity
import com.etye.reader.ReaderApp
import com.etye.reader.data.ReadMode
import com.etye.reader.data.ReaderSettings
import com.etye.reader.data.ThemeMode
import com.etye.reader.parse.BlockKind
import com.etye.reader.parse.ChapterCatalog
import com.etye.reader.parse.ChapterContent
import com.etye.reader.parse.pageIndexForOffset
import com.etye.reader.reader.PageCurlView
import com.etye.reader.reader.PageSlideView
import com.etye.reader.reader.Paginator
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

private data class FlatBlock(
  val chapterIndex: Int,
  val kind: BlockKind,
  val text: String,
  val start: Int,
)

private enum class DragBegin { STARTED, EDGE, IGNORED }

private data class PageDest(
  val text: String,
  val alignEnd: Boolean,
  val commit: () -> Unit,
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderScreen(bookId: String, onBack: () -> Unit) {
  val app = LocalContext.current.applicationContext as ReaderApp
  val viewModel: ReaderViewModel = viewModel(
    key = bookId,
    factory = remember(bookId) { ReaderViewModel.factory(app, bookId) },
  )
  val load by viewModel.load.collectAsStateWithLifecycle()
  val settings by viewModel.settings.collectAsStateWithLifecycle()
  val position by viewModel.position.collectAsStateWithLifecycle()
  var chrome by remember { mutableStateOf(false) }
  var showSettings by remember { mutableStateOf(false) }
  var showChapters by remember { mutableStateOf(false) }
  var showProgress by remember { mutableStateOf(false) }
  var edgeHint by remember { mutableStateOf<String?>(null) }
  val activity = LocalContext.current as MainActivity

  DisposableEffect(Unit) {
    activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
    onDispose {
      activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
      viewModel.flush()
    }
  }
  LaunchedEffect(edgeHint) {
    if (edgeHint != null) {
      delay(1200)
      edgeHint = null
    }
  }

  val background = MaterialTheme.colorScheme.background
  val onBackground = MaterialTheme.colorScheme.onBackground
  when (val state = load) {
    ReaderLoad.Loading -> CenterMessage(text = "正在打开…", loading = true, onBack = onBack)
    is ReaderLoad.Failed -> CenterMessage(text = state.message, loading = false, onBack = onBack)
    is ReaderLoad.Ready -> {
      val catalog = state.catalog
      val warm by viewModel.warm.collectAsStateWithLifecycle()
      val chapterIndex = position.chapterIndex.coerceIn(0, (catalog.size - 1).coerceAtLeast(0))
      val chapter = catalog.chapter(chapterIndex)
      val previousChapter = if (chapterIndex > 0) catalog.chapter(chapterIndex - 1) else null
      val nextChapter = if (chapterIndex + 1 < catalog.size) catalog.chapter(chapterIndex + 1) else null
      val progress = if (warm) {
        catalog.bookProgress(chapterIndex, position.charOffset)
      } else {
        catalog.bookProgress(chapterIndex, position.charOffset)
      }
      val chapterPercent = chapterPercent(position.charOffset, chapter.displayText.length)
      val view = LocalView.current
      val pageArgb = background.toArgb()
      val lightStatus = settings.theme != ThemeMode.NIGHT
      val safeTop = if (settings.fullscreen) {
        WindowInsets.displayCutout.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
      } else {
        WindowInsets.statusBars.union(WindowInsets.displayCutout)
          .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
      }
      DisposableEffect(Unit) {
        val window = activity.window
        val previousCutout = if (Build.VERSION.SDK_INT >= 28) {
          window.attributes.layoutInDisplayCutoutMode
        } else {
          0
        }
        if (Build.VERSION.SDK_INT >= 28) {
          val params = window.attributes
          params.layoutInDisplayCutoutMode = if (Build.VERSION.SDK_INT >= 30) {
            WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
          } else {
            WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
          }
          window.attributes = params
        }
        onDispose {
          if (Build.VERSION.SDK_INT >= 28) {
            val params = window.attributes
            params.layoutInDisplayCutoutMode = previousCutout
            window.attributes = params
          }
        }
      }
      DisposableEffect(settings.fullscreen, pageArgb, lightStatus) {
        val window = activity.window
        val controller = WindowCompat.getInsetsController(window, view)
        applyReaderBars(window, controller, settings.fullscreen, pageArgb, lightStatus)
        onDispose { restoreReaderBars(window, controller, lightStatus) }
      }
      fun jumpChapter(delta: Int) {
        val target = chapterIndex + delta
        if (target !in 0 until catalog.size) {
          edgeHint = if (delta > 0) "已经是最后一章" else "已经是第一章"
          return
        }
        viewModel.jumpTo(target, 0)
      }
      Box(Modifier.fillMaxSize().background(background)) {
        Column(
          Modifier
            .fillMaxSize()
            .windowInsetsPadding(safeTop)
            .navigationBarsPadding(),
        ) {
          Box(Modifier.weight(1f).fillMaxWidth()) {
            if (settings.mode == ReadMode.SCROLL) {
              ScrollPane(
                catalog = catalog,
                position = position,
                jump = viewModel.jump.collectAsStateWithLifecycle().value,
                settings = settings,
                onCommit = viewModel::commit,
                onToggleChrome = { chrome = !chrome },
              )
            } else {
              PagePane(
                mode = settings.mode,
                chapter = chapter,
                previousChapter = previousChapter,
                nextChapter = nextChapter,
                chapterIndex = chapterIndex,
                chapterCount = catalog.size,
                offset = position.charOffset,
                settings = settings,
                textColor = onBackground,
                pageColor = background,
                onCommit = viewModel::commit,
                onJump = viewModel::jumpTo,
                onToggleChrome = { chrome = !chrome },
                onEdge = { edgeHint = it },
              )
            }
            edgeHint?.let { hint ->
              Surface(
                modifier = Modifier.align(Alignment.Center),
                shape = RoundedCornerShape(20.dp),
                color = MaterialTheme.colorScheme.inverseSurface,
              ) {
                Text(
                  hint,
                  modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
                  color = MaterialTheme.colorScheme.inverseOnSurface,
                )
              }
            }
          }
          ReaderFooter(chapterPercent = chapterPercent, chapterTitle = chapter.title)
        }
        AnimatedVisibility(
          visible = chrome,
          modifier = Modifier.align(Alignment.TopCenter),
          enter = fadeIn() + slideInVertically { -it },
          exit = fadeOut() + slideOutVertically { -it },
        ) {
          Surface(
            modifier = Modifier.fillMaxWidth(),
            color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
            shadowElevation = 3.dp,
          ) {
            Row(
              Modifier.windowInsetsPadding(safeTop).height(52.dp).padding(end = 12.dp),
              verticalAlignment = Alignment.CenterVertically,
            ) {
              IconButton(onClick = onBack) {
                Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
              }
              Column(Modifier.weight(1f)) {
                Text(
                  chapter.title,
                  style = MaterialTheme.typography.titleMedium,
                  maxLines = 1,
                  overflow = TextOverflow.Ellipsis,
                )
                Text(
                  state.book.title,
                  style = MaterialTheme.typography.bodySmall,
                  color = MaterialTheme.colorScheme.onSurfaceVariant,
                  maxLines = 1,
                  overflow = TextOverflow.Ellipsis,
                )
              }
            }
          }
        }
        AnimatedVisibility(
          visible = chrome,
          modifier = Modifier.align(Alignment.BottomCenter),
          enter = fadeIn() + slideInVertically { it },
          exit = fadeOut() + slideOutVertically { it },
        ) {
          Surface(
            modifier = Modifier.fillMaxWidth(),
            color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
            shadowElevation = 6.dp,
          ) {
            Row(
              Modifier.navigationBarsPadding().height(64.dp).padding(horizontal = 4.dp),
              horizontalArrangement = Arrangement.SpaceEvenly,
              verticalAlignment = Alignment.CenterVertically,
            ) {
              ReaderBarButton("上一章", Icons.AutoMirrored.Filled.KeyboardArrowLeft) { jumpChapter(-1) }
              ReaderBarButton("目录", Icons.Filled.Menu) { showChapters = true }
              ProgressBarButton(progress) { showProgress = true }
              ReaderBarButton("设置", Icons.Filled.Settings) { showSettings = true }
              ReaderBarButton("下一章", Icons.AutoMirrored.Filled.KeyboardArrowRight) { jumpChapter(1) }
            }
          }
        }
      }
      if (showSettings) {
        SettingsSheet(settings = settings, viewModel = viewModel, onDismiss = { showSettings = false })
      }
      if (showChapters) {
        ChapterSheet(
          titles = catalog.titles,
          current = chapterIndex,
          onPick = { index ->
            viewModel.jumpTo(index, 0)
            showChapters = false
          },
          onDismiss = { showChapters = false },
        )
      }
      if (showProgress) {
        ProgressSheet(
          progress = progress,
          chapterTitle = chapter.title,
          onSeek = { fraction ->
            val (index, offset) = catalog.targetFor(fraction)
            viewModel.jumpTo(index, offset)
          },
          onDismiss = { showProgress = false },
        )
      }
    }
  }
}

@Composable
private fun ReaderBarButton(label: String, icon: ImageVector, onClick: () -> Unit) {
  Column(
    modifier = Modifier
      .widthIn(min = 56.dp)
      .clip(RoundedCornerShape(8.dp))
      .clickable(onClick = onClick)
      .padding(horizontal = 4.dp, vertical = 4.dp),
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp))
    Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
  }
}

@Composable
private fun ProgressBarButton(progress: Float, onClick: () -> Unit) {
  Column(
    modifier = Modifier
      .widthIn(min = 56.dp)
      .clip(RoundedCornerShape(8.dp))
      .clickable(onClick = onClick)
      .padding(horizontal = 4.dp, vertical = 4.dp),
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      "${(progress * 100).roundToInt()}%",
      style = MaterialTheme.typography.labelLarge,
      textAlign = TextAlign.Center,
    )
    Text("进度", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
  }
}

@Composable
private fun CenterMessage(text: String, loading: Boolean, onBack: () -> Unit) {
  Column(
    Modifier.fillMaxSize().statusBarsPadding().padding(24.dp),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    if (loading) {
      CircularProgressIndicator()
      Spacer(Modifier.height(16.dp))
    }
    Text(text, style = MaterialTheme.typography.bodyLarge)
    Spacer(Modifier.height(16.dp))
    TextButton(onClick = onBack) { Text("返回书架") }
  }
}

private class CurlSlot {
  var view: PageCurlView? = null
}

private class SlideSlot {
  var view: PageSlideView? = null
}

@Composable
private fun PagePane(
  mode: ReadMode,
  chapter: ChapterContent,
  previousChapter: ChapterContent?,
  nextChapter: ChapterContent?,
  chapterIndex: Int,
  chapterCount: Int,
  offset: Int,
  settings: ReaderSettings,
  textColor: Color,
  pageColor: Color,
  onCommit: (Int, Int) -> Unit,
  onJump: (Int, Int) -> Unit,
  onToggleChrome: () -> Unit,
  onEdge: (String) -> Unit,
) {
  val density = LocalDensity.current
  val fontPx = with(density) { settings.fontSp.sp.toPx() }
  val pagePadX = with(density) { 24.dp.roundToPx() }
  val pagePadTop = with(density) { 12.dp.roundToPx() }
  var size by remember { mutableStateOf(IntSize.Zero) }
  val sliding = mode == ReadMode.HORIZONTAL || mode == ReadMode.VERTICAL
  // The view is the whole sheet above the footer. Text stays in the inner box,
  // so the margins travel with the page instead of sitting outside it.
  val contentWidth = (size.width - pagePadX * 2).coerceAtLeast(0)
  val contentHeight = (size.height - pagePadTop).coerceAtLeast(0)
  // Reset only when the chapter text changes. Size and font must not blank the page,
  // or the page-number row appearing would shrink the viewport and paginate forever.
  var pages by remember(chapter.displayText) { mutableStateOf<List<Int>?>(null) }
  LaunchedEffect(chapter.displayText, contentWidth, contentHeight, fontPx, settings.lineSpacing) {
    val width = contentWidth
    val height = contentHeight
    if (width <= 0 || height <= 0) return@LaunchedEffect
    val text = chapter.displayText
    val spacing = settings.lineSpacing
    val measuredFont = fontPx
    pages = withContext(Dispatchers.Default) {
      Paginator.paginate(text, width, height, measuredFont, spacing)
    }
  }
  val pageStarts = pages
  val pageIndex = if (pageStarts == null) 0 else pageIndexForOffset(pageStarts, offset)
  val pageText = if (pageStarts == null) {
    ""
  } else {
    val start = pageStarts[pageIndex]
    val end = if (pageIndex == pageStarts.lastIndex) chapter.displayText.length else pageStarts[pageIndex + 1]
    chapter.displayText.substring(start.coerceIn(0, chapter.displayText.length), end.coerceIn(0, chapter.displayText.length))
  }

  val curl = remember { CurlSlot() }
  val slide = remember { SlideSlot() }
  val textArgb = textColor.toArgb()
  val pageArgb = pageColor.toArgb()

  fun turning(): Boolean {
    return if (sliding) slide.view?.isTurning == true else curl.view?.isTurning == true
  }

  fun slice(text: String, start: Int, end: Int): String {
    val safeStart = start.coerceIn(0, text.length)
    val safeEnd = end.coerceIn(safeStart, text.length)
    return text.substring(safeStart, safeEnd)
  }

  fun turn(toText: String, forward: Boolean, alignEnd: Boolean, fromTop: Boolean, commit: () -> Unit) {
    if (sliding) {
      val view = slide.view
      if (view == null) commit() else view.turn(toText, forward, alignEnd, commit)
    } else {
      val view = curl.view
      if (view == null) commit() else view.turn(toText, forward, alignEnd, fromTop, commit)
    }
  }

  fun goNext(fromTop: Boolean = false) {
    val starts = pageStarts ?: return
    if (turning()) return
    val index = pageIndexForOffset(starts, offset)
    if (index < starts.lastIndex) {
      val destStart = starts[index + 1]
      val destEnd = if (index + 1 == starts.lastIndex) chapter.displayText.length else starts[index + 2]
      turn(slice(chapter.displayText, destStart, destEnd), forward = true, alignEnd = false, fromTop = fromTop) {
        onCommit(chapterIndex, destStart)
      }
    } else if (chapterIndex < chapterCount - 1) {
      val preview = nextChapter?.displayText.orEmpty()
      turn(preview, forward = true, alignEnd = false, fromTop = fromTop) {
        onJump(chapterIndex + 1, 0)
      }
    } else {
      onCommit(chapterIndex, chapter.displayText.length)
      onEdge("已经是最后一页")
    }
  }

  fun goPrev(fromTop: Boolean = false) {
    val starts = pageStarts ?: return
    if (turning()) return
    val index = pageIndexForOffset(starts, offset)
    if (index > 0) {
      val destStart = starts[index - 1]
      val destEnd = starts[index]
      turn(slice(chapter.displayText, destStart, destEnd), forward = false, alignEnd = false, fromTop = fromTop) {
        onCommit(chapterIndex, destStart)
      }
    } else if (chapterIndex > 0) {
      val preview = previousChapter?.displayText.orEmpty()
      turn(preview, forward = false, alignEnd = true, fromTop = fromTop) {
        onJump(chapterIndex - 1, Int.MAX_VALUE)
      }
    } else {
      onEdge("已经是第一页")
    }
  }

  fun nextDest(): PageDest? {
    val starts = pageStarts ?: return null
    val index = pageIndexForOffset(starts, offset)
    if (index < starts.lastIndex) {
      val destStart = starts[index + 1]
      val destEnd = if (index + 1 == starts.lastIndex) chapter.displayText.length else starts[index + 2]
      return PageDest(slice(chapter.displayText, destStart, destEnd), false) { onCommit(chapterIndex, destStart) }
    }
    if (chapterIndex < chapterCount - 1) {
      return PageDest(nextChapter?.displayText.orEmpty(), false) { onJump(chapterIndex + 1, 0) }
    }
    return null
  }

  fun prevDest(): PageDest? {
    val starts = pageStarts ?: return null
    val index = pageIndexForOffset(starts, offset)
    if (index > 0) {
      val destStart = starts[index - 1]
      return PageDest(slice(chapter.displayText, destStart, starts[index]), false) { onCommit(chapterIndex, destStart) }
    }
    if (chapterIndex > 0) {
      return PageDest(previousChapter?.displayText.orEmpty(), true) { onJump(chapterIndex - 1, Int.MAX_VALUE) }
    }
    return null
  }

  fun beginSlide(primary: Float): DragBegin {
    if (pageStarts == null || turning()) return DragBegin.IGNORED
    val next = nextDest()
    val prev = prevDest()
    val started = slide.view?.grabSides(
      nextText = next?.text,
      prevText = prev?.text,
      prevAlignEnd = prev?.alignEnd == true,
      offsetPx = primary,
      onNext = next?.commit,
      onPrev = prev?.commit,
    ) == true
    return if (started) DragBegin.STARTED else DragBegin.IGNORED
  }

  fun beginDrag(forward: Boolean, x: Float, y: Float): DragBegin {
    val starts = pageStarts ?: return DragBegin.IGNORED
    if (turning()) return DragBegin.IGNORED
    val index = pageIndexForOffset(starts, offset)
    if (forward) {
      if (index < starts.lastIndex) {
        val destStart = starts[index + 1]
        val destEnd = if (index + 1 == starts.lastIndex) chapter.displayText.length else starts[index + 2]
        val text = slice(chapter.displayText, destStart, destEnd)
        val started = curl.view?.grab(text, true, false, x, y) { onCommit(chapterIndex, destStart) } == true
        return if (started) DragBegin.STARTED else DragBegin.IGNORED
      }
      if (chapterIndex < chapterCount - 1) {
        val text = nextChapter?.displayText.orEmpty()
        val started = curl.view?.grab(text, true, false, x, y) { onJump(chapterIndex + 1, 0) } == true
        return if (started) DragBegin.STARTED else DragBegin.IGNORED
      }
      return DragBegin.EDGE
    }
    if (index > 0) {
      val destStart = starts[index - 1]
      val text = slice(chapter.displayText, destStart, starts[index])
      val started = curl.view?.grab(text, false, false, x, y) { onCommit(chapterIndex, destStart) } == true
      return if (started) DragBegin.STARTED else DragBegin.IGNORED
    }
    if (chapterIndex > 0) {
      val text = previousChapter?.displayText.orEmpty()
      val started = curl.view?.grab(text, false, true, x, y) { onJump(chapterIndex - 1, Int.MAX_VALUE) } == true
      return if (started) DragBegin.STARTED else DragBegin.IGNORED
    }
    return DragBegin.EDGE
  }

  val activity = LocalContext.current as MainActivity
  DisposableEffect(pageStarts, offset, chapterIndex) {
    activity.volumeHandler = { keyCode ->
      if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) goNext() else goPrev()
      true
    }
    onDispose { activity.volumeHandler = null }
  }

  Box(
    Modifier
      .fillMaxSize()
      .onSizeChanged { next ->
          val widthDelta = abs(next.width - size.width)
          val heightDelta = abs(next.height - size.height)
          if (size == IntSize.Zero || widthDelta > 1 || heightDelta > 1) size = next
        }
        .pointerInput(pageStarts, offset, chapterIndex, mode) {
          awaitEachGesture {
            val down = awaitFirstDown(requireUnconsumed = false)
            val start = down.position
            var last = start
            var totalX = 0f
            var totalY = 0f
            var velocity = 0f
            var samplePos = start
            var sampleTime = down.uptimeMillis
            var dragged = false
            var forwardDrag = false
            var dragBegin = DragBegin.IGNORED
            val slop = viewConfiguration.touchSlop
            val widthPx = size.width.toFloat().coerceAtLeast(1f)
            val heightPx = size.height.toFloat().coerceAtLeast(1f)
            val vertical = mode == ReadMode.VERTICAL
            while (true) {
              val event = awaitPointerEvent()
              val change = event.changes.firstOrNull { it.id == down.id } ?: break
              val dt = change.uptimeMillis - sampleTime
              val delta = if (vertical) change.position.y - samplePos.y else change.position.x - samplePos.x
              if (change.pressed && dt in 1..48) {
                val sample = delta / dt.toFloat() * 1000f
                velocity = velocity * 0.6f + sample * 0.4f
              }
              if (change.pressed) {
                samplePos = change.position
                sampleTime = change.uptimeMillis
              }
              last = change.position
              totalX = last.x - start.x
              totalY = last.y - start.y
              val primary = if (vertical) totalY else totalX
              val cross = if (vertical) totalX else totalY
              if (!dragged && abs(primary) > slop && abs(primary) > abs(cross)) {
                dragged = true
                forwardDrag = primary < 0f
                if (sliding) {
                  dragBegin = beginSlide(primary)
                } else {
                  dragBegin = beginDrag(
                    forwardDrag,
                    last.x.coerceIn(0f, widthPx),
                    last.y.coerceIn(0f, heightPx),
                  )
                }
              } else if (dragged && dragBegin == DragBegin.STARTED) {
                if (sliding) {
                  slide.view?.follow(if (vertical) totalY else totalX)
                } else {
                  curl.view?.follow(
                    last.x.coerceIn(0f, widthPx),
                    last.y.coerceIn(0f, heightPx),
                  )
                }
              }
              if (dragged) change.consume()
              if (!change.pressed) break
            }
            when {
              dragged && dragBegin == DragBegin.STARTED -> {
                if (sliding) {
                  slide.view?.release(velocity) { forward ->
                    onEdge(if (forward) "已经是最后一页" else "已经是第一页")
                  }
                } else {
                  curl.view?.release()
                }
              }
              dragged && dragBegin == DragBegin.EDGE -> {
                onEdge(if (forwardDrag) "已经是最后一页" else "已经是第一页")
              }
              !dragged && abs(totalX) < slop * 2 && abs(totalY) < slop * 2 -> {
                if (vertical) {
                  when {
                    start.y < heightPx * 0.28f -> goPrev()
                    start.y > heightPx * 0.72f -> goNext()
                    else -> onToggleChrome()
                  }
                } else {
                  val fromTop = start.y < size.height * 0.45f
                  when {
                    start.x < widthPx * 0.28f -> goPrev(fromTop)
                    start.x > widthPx * 0.72f -> goNext(fromTop)
                    else -> onToggleChrome()
                  }
                }
              }
            }
          }
        },
    ) {
      if (pageStarts == null) {
        CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
      } else if (sliding) {
        AndroidView(
          modifier = Modifier.fillMaxSize(),
          factory = { context ->
            PageSlideView(context).also { slide.view = it }
          },
          update = { view ->
            slide.view = view
            view.axis = if (mode == ReadMode.VERTICAL) PageSlideView.Axis.VERTICAL else PageSlideView.Axis.HORIZONTAL
            view.contentInset(pagePadX, pagePadTop, pagePadX, 0)
            view.show(pageText, textArgb, pageArgb, fontPx, settings.lineSpacing)
            val next = nextDest()
            val prev = prevDest()
            view.prepare(next?.text, prev?.text, prev?.alignEnd == true)
          },
        )
      } else {
        AndroidView(
          modifier = Modifier.fillMaxSize(),
          factory = { context ->
            PageCurlView(context).also { curl.view = it }
          },
          update = { view ->
            curl.view = view
            view.contentInset(pagePadX, pagePadTop, pagePadX, 0)
            view.show(pageText, textArgb, pageArgb, fontPx, settings.lineSpacing)
          },
        )
      }
  }
}

@Composable
private fun ScrollPane(
  catalog: ChapterCatalog,
  position: ReadingPosition,
  jump: Int,
  settings: ReaderSettings,
  onCommit: (Int, Int) -> Unit,
  onToggleChrome: () -> Unit,
) {
  var blocks by remember(catalog) { mutableStateOf<List<FlatBlock>?>(null) }
  LaunchedEffect(catalog) {
    blocks = withContext(Dispatchers.Default) {
      if (!catalog.lengthsReady) catalog.warmCooperative()
      List(catalog.size) { index -> catalog.chapter(index) }.flatMapIndexed { index, chapter ->
        chapter.blocks.map { block -> FlatBlock(index, block.kind, block.text, block.start) }
      }
    }
  }
  val readyBlocks = blocks
  val listState = rememberLazyListState()
  var restored by remember(readyBlocks) { mutableStateOf(false) }
  LaunchedEffect(jump, readyBlocks) {
    val loaded = readyBlocks ?: return@LaunchedEffect
    if (loaded.isEmpty()) return@LaunchedEffect
    val index = blockIndex(loaded, position.chapterIndex, position.charOffset)
    listState.scrollToItem(index.coerceIn(0, loaded.lastIndex))
    restored = true
  }
  LaunchedEffect(restored, readyBlocks) {
    val loaded = readyBlocks ?: return@LaunchedEffect
    if (!restored) return@LaunchedEffect
    snapshotFlow { listState.firstVisibleItemIndex }
      .distinctUntilChanged()
      .drop(1)
      .collect { index ->
        val block = loaded.getOrNull(index) ?: return@collect
        onCommit(block.chapterIndex, block.start)
      }
  }
  val scope = rememberCoroutineScope()
  val activity = LocalContext.current as MainActivity
  DisposableEffect(readyBlocks) {
    val loaded = readyBlocks
    if (loaded == null) {
      onDispose { }
    } else {
      activity.volumeHandler = { keyCode ->
        val forward = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
        val target = (listState.firstVisibleItemIndex + if (forward) 8 else -8)
          .coerceIn(0, (loaded.size - 1).coerceAtLeast(0))
        scope.launch { listState.animateScrollToItem(target) }
        true
      }
      onDispose { activity.volumeHandler = null }
    }
  }
  if (readyBlocks == null) {
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
      CircularProgressIndicator()
    }
    return
  }
  Box(
    Modifier
      .fillMaxSize()
      .pointerInput(Unit) {
        awaitEachGesture {
          val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
          val pointerId = down.id
          val start = down.position
          val slop = viewConfiguration.touchSlop
          val longPress = viewConfiguration.longPressTimeoutMillis
          var moved = false
          while (true) {
            val event = awaitPointerEvent(PointerEventPass.Final)
            val change = event.changes.firstOrNull { it.id == pointerId } ?: break
            val dx = change.position.x - start.x
            val dy = change.position.y - start.y
            if (abs(dx) > slop || abs(dy) > slop) moved = true
            if (!change.pressed) {
              val brief = change.uptimeMillis - down.uptimeMillis < longPress
              val width = size.width.toFloat()
              if (!moved && brief && start.x > width * 0.28f && start.x < width * 0.72f) {
                onToggleChrome()
              }
              break
            }
          }
        }
      },
  ) {
    SelectionContainer {
      LazyColumn(
        state = listState,
        modifier = Modifier.fillMaxSize(),
        contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp, vertical = 16.dp),
      ) {
        itemsIndexed(readyBlocks, key = { index, block -> "$index-${block.chapterIndex}-${block.start}" }) { _, block ->
          if (block.kind == BlockKind.TITLE) {
            Text(
              block.text,
              modifier = Modifier.padding(top = 8.dp, bottom = 14.dp),
              color = MaterialTheme.colorScheme.onBackground,
              fontSize = (settings.fontSp + 2f).sp,
              fontWeight = FontWeight.SemiBold,
              lineHeight = ((settings.fontSp + 2f) * settings.lineSpacing).sp,
            )
          } else {
            Text(
              block.text,
              modifier = Modifier.padding(bottom = 16.dp),
              color = MaterialTheme.colorScheme.onBackground,
              fontSize = settings.fontSp.sp,
              lineHeight = (settings.fontSp * settings.lineSpacing).sp,
            )
          }
        }
      }
    }
  }
}

private fun blockIndex(blocks: List<FlatBlock>, chapterIndex: Int, offset: Int): Int {
  if (blocks.isEmpty()) return 0
  val target = if (offset == Int.MAX_VALUE) Int.MAX_VALUE else offset.coerceAtLeast(0)
  var best = 0
  for (index in blocks.indices) {
    val block = blocks[index]
    if (block.chapterIndex < chapterIndex) {
      best = index
    } else if (block.chapterIndex == chapterIndex && block.start <= target) {
      best = index
    } else if (block.chapterIndex > chapterIndex) {
      break
    }
  }
  return best
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsSheet(settings: ReaderSettings, viewModel: ReaderViewModel, onDismiss: () -> Unit) {
  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
  var dragFont by remember(settings.fontSp) { mutableFloatStateOf(settings.fontSp) }
  var dragSpacing by remember(settings.lineSpacing) { mutableFloatStateOf(settings.lineSpacing) }
  ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface) {
    Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {
      Text("阅读设置", style = MaterialTheme.typography.titleLarge)
      Spacer(Modifier.height(16.dp))
      Text("字号 ${dragFont.roundToInt()}", style = MaterialTheme.typography.titleSmall)
      Slider(
        value = dragFont,
        onValueChange = { dragFont = it },
        onValueChangeFinished = { viewModel.setFont(dragFont.roundToInt().toFloat()) },
        valueRange = 14f..28f,
        steps = 13,
      )
      Text("行距 ${"%.1f".format(dragSpacing)}", style = MaterialTheme.typography.titleSmall)
      Slider(
        value = dragSpacing,
        onValueChange = { dragSpacing = it },
        onValueChangeFinished = { viewModel.setSpacing((dragSpacing * 10f).roundToInt() / 10f) },
        valueRange = 1.3f..2.2f,
        steps = 8,
      )
      Spacer(Modifier.height(8.dp))
      Text("翻页", style = MaterialTheme.typography.titleSmall)
      Spacer(Modifier.height(8.dp))
      ModeGrid(
        labels = listOf("仿真", "左右", "上下", "滚屏"),
        selected = when (settings.mode) {
          ReadMode.CURL -> 0
          ReadMode.HORIZONTAL -> 1
          ReadMode.VERTICAL -> 2
          ReadMode.SCROLL -> 3
        },
        onSelect = { index ->
          viewModel.setReadMode(when (index) {
            1 -> ReadMode.HORIZONTAL
            2 -> ReadMode.VERTICAL
            3 -> ReadMode.SCROLL
            else -> ReadMode.CURL
          })
        },
      )
      Spacer(Modifier.height(16.dp))
      Text("屏幕", style = MaterialTheme.typography.titleSmall)
      Spacer(Modifier.height(8.dp))
      ChoiceRow(
        labels = listOf("全屏", "显示状态栏"),
        selected = if (settings.fullscreen) 0 else 1,
        onSelect = { viewModel.setFullscreen(it == 0) },
      )
      Spacer(Modifier.height(16.dp))
      Text("背景", style = MaterialTheme.typography.titleSmall)
      Spacer(Modifier.height(8.dp))
      ChoiceRow(
        labels = listOf("白天", "护眼", "夜间"),
        selected = when (settings.theme) {
          ThemeMode.DAY -> 0
          ThemeMode.EYE -> 1
          ThemeMode.NIGHT -> 2
        },
        onSelect = { index ->
          viewModel.setTheme(when (index) {
            1 -> ThemeMode.EYE
            2 -> ThemeMode.NIGHT
            else -> ThemeMode.DAY
          })
        },
      )
      Spacer(Modifier.height(18.dp))
      Text(
        "页读 1.4.7 · 书籍只保存在这台设备上",
        style = MaterialTheme.typography.bodySmall,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
      )
    }
  }
}

@Composable
private fun ModeGrid(labels: List<String>, selected: Int, onSelect: (Int) -> Unit) {
  Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
    labels.chunked(2).forEachIndexed { row, pair ->
      Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
        pair.forEachIndexed { column, label ->
          val index = row * 2 + column
          val on = index == selected
          Surface(
            modifier = Modifier.weight(1f).height(40.dp).clip(RoundedCornerShape(8.dp)).clickable { onSelect(index) },
            color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
          ) {
            Row(
              Modifier.fillMaxSize(),
              horizontalArrangement = Arrangement.Center,
              verticalAlignment = Alignment.CenterVertically,
            ) {
              if (on) {
                Icon(
                  Icons.Filled.Check,
                  contentDescription = "已选择",
                  modifier = Modifier.padding(end = 4.dp).size(16.dp),
                  tint = MaterialTheme.colorScheme.primary,
                )
              }
              Text(
                label,
                color = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
                fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
              )
            }
          }
        }
      }
    }
  }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChoiceRow(labels: List<String>, selected: Int, onSelect: (Int) -> Unit) {
  SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
    labels.forEachIndexed { index, label ->
      SegmentedButton(
        selected = selected == index,
        onClick = { onSelect(index) },
        shape = SegmentedButtonDefaults.itemShape(index, labels.size),
      ) { Text(label) }
    }
  }
}

@Suppress("DEPRECATION")
private fun applyReaderBars(
  window: android.view.Window,
  controller: WindowInsetsControllerCompat,
  fullscreen: Boolean,
  pageColor: Int,
  lightStatus: Boolean,
) {
  controller.isAppearanceLightStatusBars = lightStatus
  if (fullscreen) {
    window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
    window.statusBarColor = android.graphics.Color.TRANSPARENT
    controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
    controller.hide(WindowInsetsCompat.Type.statusBars())
  } else {
    window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
    controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
    controller.show(WindowInsetsCompat.Type.statusBars())
    window.statusBarColor = pageColor
  }
}

@Suppress("DEPRECATION")
private fun restoreReaderBars(
  window: android.view.Window,
  controller: WindowInsetsControllerCompat,
  lightStatus: Boolean,
) {
  window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
  controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
  controller.show(WindowInsetsCompat.Type.statusBars())
  window.statusBarColor = android.graphics.Color.TRANSPARENT
  controller.isAppearanceLightStatusBars = lightStatus
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChapterSheet(
  titles: List<String>,
  current: Int,
  onPick: (Int) -> Unit,
  onDismiss: () -> Unit,
) {
  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
  val listState = rememberLazyListState()
  LaunchedEffect(current) {
    if (current > 0) listState.scrollToItem((current - 1).coerceAtLeast(0))
  }
  ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface) {
    Column(Modifier.fillMaxWidth().navigationBarsPadding()) {
      Text("目录", modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), style = MaterialTheme.typography.titleLarge)
      Text(
        "共 ${titles.size} 章",
        modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp),
        style = MaterialTheme.typography.bodySmall,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
      )
      LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
        itemsIndexed(titles, key = { index, _ -> index }) { index, title ->
          val selected = index == current
          ListItem(
            headlineContent = {
              Text(
                title,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis,
                fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
                color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
              )
            },
            supportingContent = { Text(if (selected) "正在阅读" else "第 ${index + 1} 章") },
            colors = ListItemDefaults.colors(containerColor = Color.Transparent),
            modifier = Modifier.clickable { onPick(index) },
          )
        }
      }
    }
  }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProgressSheet(
  progress: Float,
  chapterTitle: String,
  onSeek: (Float) -> Unit,
  onDismiss: () -> Unit,
) {
  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
  var drag by remember(progress) { mutableFloatStateOf(progress) }
  ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface) {
    Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {
      Text("阅读进度", style = MaterialTheme.typography.titleLarge)
      Spacer(Modifier.height(8.dp))
      Text(
        chapterTitle,
        style = MaterialTheme.typography.bodyMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
      )
      Spacer(Modifier.height(12.dp))
      Text("${(drag * 100).roundToInt()}%", style = MaterialTheme.typography.titleMedium)
      Slider(
        value = drag,
        onValueChange = { drag = it },
        onValueChangeFinished = { onSeek(drag) },
      )
      LinearProgressIndicator(
        progress = { drag },
        modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)),
        drawStopIndicator = {},
      )
    }
  }
}

private val FooterHeight = 28.dp

@Composable
private fun ReaderFooter(chapterPercent: Int, chapterTitle: String) {
  val context = LocalContext.current
  var timeText by remember { mutableStateOf(formatReaderTime(context)) }
  var battery by remember { mutableIntStateOf(-1) }
  LaunchedEffect(context) {
    while (true) {
      timeText = formatReaderTime(context)
      val wait = 60_000L - (System.currentTimeMillis() % 60_000L)
      delay(wait.coerceAtLeast(1_000L))
    }
  }
  DisposableEffect(context) {
    val receiver = object : BroadcastReceiver() {
      override fun onReceive(ctx: Context?, intent: Intent?) {
        battery = batteryPercent(intent)
      }
    }
    val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
    val sticky = if (android.os.Build.VERSION.SDK_INT >= 33) {
      context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
    } else {
      @Suppress("UnspecifiedRegisterReceiverFlag")
      context.registerReceiver(receiver, filter)
    }
    battery = batteryPercent(sticky)
    onDispose {
      runCatching { context.unregisterReceiver(receiver) }
    }
  }
  val ink = MaterialTheme.colorScheme.onSurfaceVariant
  Row(
    Modifier.fillMaxWidth().height(FooterHeight).padding(horizontal = 12.dp),
    verticalAlignment = Alignment.CenterVertically,
  ) {
    Text(
      "本章 $chapterPercent%",
      modifier = Modifier.weight(0.28f),
      color = ink,
      fontSize = 11.sp,
      maxLines = 1,
      overflow = TextOverflow.Ellipsis,
    )
    Text(
      chapterTitle,
      modifier = Modifier.weight(0.40f),
      color = ink,
      fontSize = 11.sp,
      maxLines = 1,
      overflow = TextOverflow.Ellipsis,
      textAlign = TextAlign.Center,
    )
    Text(
      if (battery >= 0) "$timeText  $battery%" else timeText,
      modifier = Modifier.weight(0.32f),
      color = ink,
      fontSize = 11.sp,
      maxLines = 1,
      overflow = TextOverflow.Ellipsis,
      textAlign = TextAlign.End,
    )
  }
}

private fun chapterPercent(offset: Int, length: Int): Int {
  if (length <= 0) return 0
  return ((offset.coerceIn(0, length) * 100f) / length).roundToInt().coerceIn(0, 100)
}

private fun formatReaderTime(context: Context): String {
  val pattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm"
  return SimpleDateFormat(pattern, Locale.getDefault()).format(Date())
}

private fun batteryPercent(intent: Intent?): Int {
  if (intent == null) return -1
  val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
  val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
  if (level < 0 || scale <= 0) return -1
  return ((level * 100f) / scale).roundToInt().coerceIn(0, 100)
}

免费评分

参与人数 1热心值 +1 收起 理由
GS9452 + 1 谢谢@Thanks!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
SumKyo 发表于 2026-10-19 22:25
为老师点赞,支持。
3#
tnx2016 发表于 2026-10-19 23:28
4#
darklord 发表于 2026-10-20 01:40
5#
LiraVeyra 发表于 2026-10-20 01:52
感谢分享
6#
shumengzu6 发表于 2026-10-20 03:57
谢谢老师分享,辛苦
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-20 05:08

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表