From 16b89c43f33554309c4be9db7482e525ce3ea88b Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Wed, 26 Nov 2025 02:34:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E5=8A=A0=E6=99=BA=E8=83=BD=E7=9A=84?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E8=AE=B0=E5=BD=95=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/data/dao/ReadRecordDao.kt | 13 ++ .../data/repository/ReadRecordRepository.kt | 93 +++++++++++-- .../main/java/io/legado/app/model/ReadBook.kt | 128 +++++++++--------- .../java/io/legado/app/model/ReadManga.kt | 127 ++++++++--------- .../app/ui/book/manga/ReadMangaActivity.kt | 3 + .../app/ui/book/read/ReadBookActivity.kt | 3 + .../ui/book/readRecord/ReadRecordActivity.kt | 7 +- .../app/ui/widget/compose/EmptyMessageView.kt | 85 ++++++++++++ 8 files changed, 322 insertions(+), 137 deletions(-) create mode 100644 app/src/main/java/io/legado/app/ui/widget/compose/EmptyMessageView.kt diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index d9860687d..0bb4470b8 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -92,6 +92,19 @@ interface ReadRecordDao { @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName") fun deleteDetailByName(bookName: String) + /** + * 获取指定书籍的最后一条阅读会话 + * 用于判断是否可以合并 + */ + @Query("SELECT * FROM readRecordSession WHERE bookName = :bookName ORDER BY endTime DESC LIMIT 1") + suspend fun getLatestSessionByBook(bookName: String): ReadRecordSession? + + /** + * 更新现有的会话 + */ + @Update + suspend fun updateSession(session: ReadRecordSession) + /** * 插入阅读会话记录。 */ diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index ebd76dd1a..379a57ef4 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -15,10 +15,64 @@ class ReadRecordRepository( private val dao: ReadRecordDao ) { + companion object { + // 2分钟内重新打开书本,视为同一次阅读 + private const val CONTINUE_THRESHOLD = 120 * 1000L + // 会话总时长小于10秒则不记录 + private const val MIN_READ_DURATION = 0 * 1000L + } + private fun getCurrentDeviceId(): String = "" /** - * 保存一个完整的阅读会话,并同步更新 ReadRecordDetail 和 ReadRecord。 + * 智能保存阅读会话。 + * 自动判断是插入新会话还是合并到上一次会话 + */ + @Transaction + suspend fun saveOrMergeReadSession(newSession: ReadRecordSession) { + + val segmentDuration = newSession.endTime - newSession.startTime + + if (segmentDuration < MIN_READ_DURATION) { + return + } + + val latestSession = dao.getLatestSessionByBook(newSession.bookName) + + if (latestSession != null) { + val timeGap = newSession.startTime - latestSession.endTime + + if (timeGap <= CONTINUE_THRESHOLD && timeGap >= 0) { + + val durationDelta = segmentDuration + val wordsDelta = newSession.words + + val mergedSession = latestSession.copy( + endTime = newSession.endTime, + words = latestSession.words + wordsDelta + ) + + dao.updateSession(mergedSession) + + val dateString = DateUtil.format(Date(mergedSession.startTime), DatePattern.NORM_DATE_PATTERN) + updateReadRecordDetail(mergedSession, durationDelta, wordsDelta, dateString) + updateReadRecord(mergedSession, durationDelta) + + return + } + } + + // 处理新会话 + dao.insertSession(newSession) + + val dateString = DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN) + updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString) + updateReadRecord(newSession, segmentDuration) + } + + /** + * 保存一个完整的阅读会话 + * 没几把用 */ @Transaction suspend fun saveReadSession(session: ReadRecordSession) { @@ -26,17 +80,23 @@ class ReadRecordRepository( val sessionDuration = session.endTime - session.startTime val dateString = DateUtil.format(Date(session.startTime), DatePattern.NORM_DATE_PATTERN) + updateReadRecordDetail(session, sessionDuration, session.words, dateString) - updateReadRecordDetail(session, sessionDuration, dateString) updateReadRecord(session, sessionDuration) } - private suspend fun updateReadRecord(session: ReadRecordSession, sessionDuration: Long) { + /** + * 更新总记录表 (ReadRecord) + * @param durationDelta 增加的时长 + */ + private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) { + if (durationDelta <= 0) return + val existingRecord = dao.getReadRecord(session.deviceId, session.bookName) if (existingRecord != null) { val updatedRecord = existingRecord.copy( - readTime = existingRecord.readTime + sessionDuration, + readTime = existingRecord.readTime + durationDelta, lastRead = session.endTime ) dao.update(updatedRecord) @@ -44,19 +104,31 @@ class ReadRecordRepository( val newRecord = ReadRecord( deviceId = session.deviceId, bookName = session.bookName, - readTime = sessionDuration, + readTime = durationDelta, lastRead = session.endTime ) dao.insert(newRecord) } } - private suspend fun updateReadRecordDetail(session: ReadRecordSession, sessionDuration: Long, dateString: String) { + /** + * 更新每日详情表 (ReadRecordDetail) + * @param durationDelta 增加的时长 + * @param wordsDelta 增加的字数 + */ + private suspend fun updateReadRecordDetail( + session: ReadRecordSession, + durationDelta: Long, + wordsDelta: Long, + dateString: String + ) { + if (durationDelta <= 0 && wordsDelta <= 0) return + val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString) if (existingDetail != null) { - existingDetail.readTime += sessionDuration - existingDetail.readWords += session.words + existingDetail.readTime += durationDelta + existingDetail.readWords += wordsDelta existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime) existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime) dao.insertDetail(existingDetail) @@ -65,8 +137,8 @@ class ReadRecordRepository( deviceId = session.deviceId, bookName = session.bookName, date = dateString, - readTime = sessionDuration, - readWords = session.words, + readTime = durationDelta, + readWords = wordsDelta, firstReadTime = session.startTime, lastReadTime = session.endTime ) @@ -115,5 +187,4 @@ class ReadRecordRepository( // 暴露总时长 val allTime: Long get() = dao.allTime - } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/ReadBook.kt b/app/src/main/java/io/legado/app/model/ReadBook.kt index e349dd870..f08c8b25e 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore @@ -99,8 +100,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { var contentProcessor: ContentProcessor? = null val downloadScope = CoroutineScope(SupervisorJob() + IO) val preDownloadSemaphore = Semaphore(2) + val executor = globalExecutor + private val ioScope = CoroutineScope(IO) + + private var autoSaveJob: Job? = null + + private var currentActiveSession: ReadRecordSession? = null + //占位 + private var currentReadLength: Long = 10L + private const val AUTO_SAVE_INTERVAL = 120 * 1000L + + private const val MIN_READ_DURATION = 10 * 1000L + fun resetData(book: Book) { ReadBook.book = book readRecord.bookName = book.name @@ -286,91 +299,84 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { } } - // 阅读暂停的阈值 - private const val PAUSE_THRESHOLD = 5 * 60 * 1000L - // 存储当前正在累积的阅读会话对象 - private var currentActiveSession: ReadRecordSession? = null - //占位 - private var currentReadLength: Long = 10L - - fun upReadTime() { - val currentLength = currentReadLength - if (!AppConfig.enableReadRecord) { - return - } + fun initReadTime() { val currentBookName = book?.name ?: return - val endTime = System.currentTimeMillis() - - //计算本次片段的时长和字数 - val duration = endTime - readStartTime - var wordChange = currentLength - lastReadLength - if (wordChange < 0) wordChange = 0 - - //过滤无效记录 - if (duration < 1000L && wordChange == 0L) { - readStartTime = endTime - lastReadLength = currentLength - return + if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) { + commitReadSession() } - //检查是否中断 - if (currentActiveSession != null) { - // 计算从上一个片段结束到当前片段开始的时间间隔 - val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime - - if (timeSinceLastUpdate > PAUSE_THRESHOLD) { - // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话 - commitReadSession() - } - } - - - //累加或创建新会话 if (currentActiveSession == null) { - // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间 + lastReadLength = currentReadLength currentActiveSession = ReadRecordSession( deviceId = "", bookName = currentBookName, startTime = readStartTime, - endTime = endTime, + endTime = readStartTime, words = 0 ) - } else { - // 累加数据:只更新结束时间和总字数 - currentActiveSession = currentActiveSession!!.copy( - endTime = endTime, - words = currentActiveSession!!.words + wordChange - ) } + } + + fun upReadTime() { + val currentLength = currentReadLength + val currentBookName = book?.name ?: return + val endTime = System.currentTimeMillis() + + if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) { + initReadTime() + return + } + + var wordChange = currentLength - lastReadLength + if (wordChange < 0) wordChange = 0 + + currentActiveSession = currentActiveSession!!.copy( + endTime = endTime, + words = currentActiveSession!!.words + wordChange + ) readStartTime = endTime lastReadLength = currentLength } - /** - * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。 - * 此方法必须在后台线程上调用。 - */ - fun commitReadSession() { - // 检查是否有需要保存的数据 - val sessionToSave = currentActiveSession ?: return + fun startAutoSaveSession() { + autoSaveJob?.cancel() + autoSaveJob = ioScope.launch { + while (isActive) { + delay(AUTO_SAVE_INTERVAL) + commitSessionInternal() + } + } + } - // 确保时长和字数有效 - if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) { + fun stopAutoSaveSession() { + autoSaveJob?.cancel() + autoSaveJob = null + } + + fun commitReadSession() { + ioScope.launch { + commitSessionInternal() + } + } + + /** + * 内部提交逻辑 + */ + private suspend fun commitSessionInternal() { + val sessionToSave = currentActiveSession ?: return + val sessionDuration = sessionToSave.endTime - sessionToSave.startTime + if (sessionDuration < MIN_READ_DURATION) { currentActiveSession = null return } - - //使用 runBlocking 确保数据库保存操作在当前线程上同步完成 try { - kotlinx.coroutines.runBlocking { - readRecordRepository.saveReadSession(sessionToSave) - } + readRecordRepository.saveOrMergeReadSession(sessionToSave) } catch (e: Exception) { AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e) + } finally { + currentActiveSession = null } - - currentActiveSession = null } fun upMsg(msg: String?) { diff --git a/app/src/main/java/io/legado/app/model/ReadManga.kt b/app/src/main/java/io/legado/app/model/ReadManga.kt index c21a0b9f4..3ecb6d3bf 100644 --- a/app/src/main/java/io/legado/app/model/ReadManga.kt +++ b/app/src/main/java/io/legado/app/model/ReadManga.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import org.koin.core.component.KoinComponent @@ -75,6 +76,16 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{ private val readRecordRepository: ReadRecordRepository by inject() + private val ioScope = CoroutineScope(IO) + + private var autoSaveJob: Job? = null + + private var currentActiveSession: ReadRecordSession? = null + private var currentReadLength: Long = 10L + private const val AUTO_SAVE_INTERVAL = 120 * 1000L + + private const val MIN_READ_DURATION = 10 * 1000L + private var lastReadLength: Long = 0 fun resetData(book: Book) { ReadManga.book = book @@ -134,92 +145,84 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{ nextMangaChapter = null } - //每次切换章节更新阅读记录 - // 阅读暂停的阈值 - private const val PAUSE_THRESHOLD = 5 * 60 * 1000L - // 存储当前正在累积的阅读会话对象 - private var currentActiveSession: ReadRecordSession? = null - //占位 - private var currentReadLength: Long = 10L - - fun upReadTime() { - val currentLength = currentReadLength - if (!AppConfig.enableReadRecord) { - return - } + fun initReadTime() { val currentBookName = book?.name ?: return - val endTime = System.currentTimeMillis() - - //计算本次片段的时长和字数 - val duration = endTime - readStartTime - var wordChange = currentLength - lastReadLength - if (wordChange < 0) wordChange = 0 - - //过滤无效记录 - if (duration < 1000L && wordChange == 0L) { - readStartTime = endTime - lastReadLength = currentLength - return + if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) { + commitReadSession() } - //检查是否中断 - if (currentActiveSession != null) { - // 计算从上一个片段结束到当前片段开始的时间间隔 - val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime - - if (timeSinceLastUpdate > PAUSE_THRESHOLD) { - // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话 - commitReadSession() - } - } - - - //累加或创建新会话 if (currentActiveSession == null) { - // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间 + lastReadLength = currentReadLength currentActiveSession = ReadRecordSession( deviceId = "", bookName = currentBookName, startTime = readStartTime, - endTime = endTime, + endTime = readStartTime, words = 0 ) - } else { - // 累加数据:只更新结束时间和总字数 - currentActiveSession = currentActiveSession!!.copy( - endTime = endTime, - words = currentActiveSession!!.words + wordChange - ) } + } + + fun upReadTime() { + val currentLength = currentReadLength + val currentBookName = book?.name ?: return + val endTime = System.currentTimeMillis() + + if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) { + initReadTime() + return + } + + var wordChange = currentLength - lastReadLength + if (wordChange < 0) wordChange = 0 + + currentActiveSession = currentActiveSession!!.copy( + endTime = endTime, + words = currentActiveSession!!.words + wordChange + ) readStartTime = endTime lastReadLength = currentLength } - /** - * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。 - * 此方法必须在后台线程上调用。 - */ - fun commitReadSession() { - // 检查是否有需要保存的数据 - val sessionToSave = currentActiveSession ?: return + fun startAutoSaveSession() { + autoSaveJob?.cancel() + autoSaveJob = ioScope.launch { + while (isActive) { + delay(AUTO_SAVE_INTERVAL) + commitSessionInternal() + } + } + } - // 确保时长和字数有效 - if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) { + fun stopAutoSaveSession() { + autoSaveJob?.cancel() + autoSaveJob = null + } + + fun commitReadSession() { + ioScope.launch { + commitSessionInternal() + } + } + + /** + * 内部提交逻辑 + */ + private suspend fun commitSessionInternal() { + val sessionToSave = currentActiveSession ?: return + val sessionDuration = sessionToSave.endTime - sessionToSave.startTime + if (sessionDuration < MIN_READ_DURATION) { currentActiveSession = null return } - - //使用 runBlocking 确保数据库保存操作在当前线程上同步完成 try { - kotlinx.coroutines.runBlocking { - readRecordRepository.saveReadSession(sessionToSave) - } + readRecordRepository.saveOrMergeReadSession(sessionToSave) } catch (e: Exception) { AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e) + } finally { + currentActiveSession = null } - - currentActiveSession = null } @Synchronized diff --git a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt index 6a63a21a3..17692b043 100644 --- a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt @@ -429,6 +429,8 @@ class ReadMangaActivity : VMBaseActivity = listOf( + "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)", + "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)" + ), + faceTextSize: TextUnit = 32.sp, + onFaceClick: (() -> Unit)? = null +) { + var currentFace by remember { mutableStateOf(faces.random()) } + + Column( + modifier = modifier + .wrapContentSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AnimatedTextLine( + text = currentFace, + fontSize = faceTextSize, + textAlign = TextAlign.Center, + modifier = Modifier + .clickable { + currentFace = faces.random() + onFaceClick?.invoke() + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + AnimatedTextLine( + text = message, + textAlign = TextAlign.Center, + maxLines = 2, + softWrap = true, + modifier = Modifier.widthIn(max = 240.dp) + ) + } +} + +@Composable +fun EmptyMessageView( + @StringRes messageResId: Int, + modifier: Modifier = Modifier, + faces: List = listOf( + "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)", + "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)" + ), + faceTextSize: TextUnit = 32.sp, + onFaceClick: (() -> Unit)? = null +) { + val message = stringResource(id = messageResId) + EmptyMessageView( + message = message, + modifier = modifier, + faces = faces, + faceTextSize = faceTextSize, + onFaceClick = onFaceClick + ) +} \ No newline at end of file