浏览代码

refactor: align manga reader state with UDF

HapeLee 3 月之前
父节点
当前提交
173f5527cd

+ 36 - 21
app/src/main/java/io/legado/app/data/repository/MangaReaderSessionRepository.kt

@@ -45,7 +45,9 @@ import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.onEmpty
 import kotlinx.coroutines.flow.take
 import kotlinx.coroutines.withContext
@@ -69,7 +71,6 @@ sealed interface MangaPaymentResult {
 }
 
 sealed interface MangaReaderSessionEvent {
-    data object ContentUpdated : MangaReaderSessionEvent
     data class LoadFailed(val message: String) : MangaReaderSessionEvent
     data class ConfirmProgress(val progress: BookProgress) : MangaReaderSessionEvent
     data object Loading : MangaReaderSessionEvent
@@ -77,6 +78,20 @@ sealed interface MangaReaderSessionEvent {
     data class Message(val message: String) : MangaReaderSessionEvent
 }
 
+data class MangaReaderSessionState(
+    val content: MangaContent,
+    val bookName: String,
+    val bookAuthor: String,
+    val bookUrl: String,
+    val chapterName: String,
+    val chapterUrl: String?,
+    val sourceName: String,
+    val sourceUrl: String?,
+    val sourceType: Int?,
+    val chapterIndex: Int,
+    val chapterCount: Int,
+)
+
 /** Data/session boundary around the legacy ReadManga engine. */
 class MangaReaderSessionRepository(
     private val application: Application,
@@ -90,10 +105,12 @@ class MangaReaderSessionRepository(
 ) {
     private val _events = MutableSharedFlow<MangaReaderSessionEvent>(extraBufferCapacity = 16)
     val events = _events.asSharedFlow()
+    private val _sessionState = MutableStateFlow<MangaReaderSessionState?>(null)
+    val sessionState = _sessionState.asStateFlow()
 
     private val engineCallback = object : ReadManga.Callback {
         override fun upContent() {
-            _events.tryEmit(MangaReaderSessionEvent.ContentUpdated)
+            publishSessionState()
         }
 
         override fun loadFail(msg: String) {
@@ -117,32 +134,30 @@ class MangaReaderSessionRepository(
 
     fun closeSession() = ReadManga.unregister(engineCallback)
 
-    fun content(): MangaContent = ReadManga.mangaContents
-
-    fun currentBookName(): String = ReadManga.book?.name.orEmpty()
-
-    fun currentBookAuthor(): String = ReadManga.book?.author.orEmpty()
-
-    fun currentBookUrl(): String = ReadManga.book?.bookUrl.orEmpty()
+    fun currentSessionState(): MangaReaderSessionState = MangaReaderSessionState(
+        content = ReadManga.mangaContents.copy(items = ReadManga.mangaContents.items.toList()),
+        bookName = ReadManga.book?.name.orEmpty(),
+        bookAuthor = ReadManga.book?.author.orEmpty(),
+        bookUrl = ReadManga.book?.bookUrl.orEmpty(),
+        chapterName = ReadManga.book?.durChapterTitle.orEmpty(),
+        chapterUrl = ReadManga.curMangaChapter?.chapter?.url,
+        sourceName = ReadManga.bookSource?.bookSourceName.orEmpty(),
+        sourceUrl = ReadManga.bookSource?.bookSourceUrl,
+        sourceType = ReadManga.bookSource?.getSourceType(),
+        chapterIndex = ReadManga.durChapterIndex,
+        chapterCount = ReadManga.simulatedChapterSize,
+    )
+
+    private fun publishSessionState() {
+        _sessionState.value = currentSessionState()
+    }
 
     fun currentBook(): Book? = ReadManga.book
 
-    fun currentChapterName(): String = ReadManga.book?.durChapterTitle.orEmpty()
-
-    fun currentSourceName(): String = ReadManga.bookSource?.bookSourceName.orEmpty()
-
     fun currentSourceOrigin(): String? = ReadManga.book?.origin
 
-    fun currentSourceUrl(): String? = ReadManga.bookSource?.bookSourceUrl
-
-    fun currentSourceType(): Int? = ReadManga.bookSource?.getSourceType()
-
-    fun currentChapterUrl(): String? = ReadManga.curMangaChapter?.chapter?.url
-
     fun currentChapterIndex(): Int = ReadManga.durChapterIndex
 
-    fun chapterCount(): Int = ReadManga.simulatedChapterSize
-
     fun inBookshelf(): Boolean = ReadManga.inBookshelf
 
     fun hasBook(): Boolean = ReadManga.book != null

+ 181 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaBookSnapshot.kt

@@ -0,0 +1,181 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.runtime.Immutable
+import io.legado.app.data.entities.Book
+
+@Immutable
+data class MangaBookSnapshot(
+    val bookUrl: String,
+    val tocUrl: String,
+    val origin: String,
+    val originName: String,
+    val name: String,
+    val author: String,
+    val kind: String?,
+    val customTag: String?,
+    val coverUrl: String?,
+    val customCoverUrl: String?,
+    val intro: String?,
+    val customIntro: String?,
+    val remark: String?,
+    val charset: String?,
+    val type: Int,
+    val group: Long,
+    val latestChapterTitle: String?,
+    val latestChapterTime: Long,
+    val lastCheckTime: Long,
+    val lastCheckCount: Int,
+    val totalChapterNum: Int,
+    val durChapterTitle: String?,
+    val durChapterIndex: Int,
+    val durChapterPos: Int,
+    val durChapterTime: Long,
+    val wordCount: String?,
+    val canUpdate: Boolean,
+    val order: Int,
+    val originOrder: Int,
+    val variable: String?,
+    val readConfig: MangaReadConfigSnapshot?,
+    val syncTime: Long,
+    val listIntro: String?,
+) {
+    fun toBook() = Book(
+        bookUrl = bookUrl,
+        tocUrl = tocUrl,
+        origin = origin,
+        originName = originName,
+        name = name,
+        author = author,
+        kind = kind,
+        customTag = customTag,
+        coverUrl = coverUrl,
+        customCoverUrl = customCoverUrl,
+        intro = intro,
+        customIntro = customIntro,
+        remark = remark,
+        charset = charset,
+        type = type,
+        group = group,
+        latestChapterTitle = latestChapterTitle,
+        latestChapterTime = latestChapterTime,
+        lastCheckTime = lastCheckTime,
+        lastCheckCount = lastCheckCount,
+        totalChapterNum = totalChapterNum,
+        durChapterTitle = durChapterTitle,
+        durChapterIndex = durChapterIndex,
+        durChapterPos = durChapterPos,
+        durChapterTime = durChapterTime,
+        wordCount = wordCount,
+        canUpdate = canUpdate,
+        order = order,
+        originOrder = originOrder,
+        variable = variable,
+        readConfig = readConfig?.toReadConfig(),
+        syncTime = syncTime,
+        listIntro = listIntro,
+    )
+
+    companion object {
+        fun from(book: Book) = MangaBookSnapshot(
+            bookUrl = book.bookUrl,
+            tocUrl = book.tocUrl,
+            origin = book.origin,
+            originName = book.originName,
+            name = book.name,
+            author = book.author,
+            kind = book.kind,
+            customTag = book.customTag,
+            coverUrl = book.coverUrl,
+            customCoverUrl = book.customCoverUrl,
+            intro = book.intro,
+            customIntro = book.customIntro,
+            remark = book.remark,
+            charset = book.charset,
+            type = book.type,
+            group = book.group,
+            latestChapterTitle = book.latestChapterTitle,
+            latestChapterTime = book.latestChapterTime,
+            lastCheckTime = book.lastCheckTime,
+            lastCheckCount = book.lastCheckCount,
+            totalChapterNum = book.totalChapterNum,
+            durChapterTitle = book.durChapterTitle,
+            durChapterIndex = book.durChapterIndex,
+            durChapterPos = book.durChapterPos,
+            durChapterTime = book.durChapterTime,
+            wordCount = book.wordCount,
+            canUpdate = book.canUpdate,
+            order = book.order,
+            originOrder = book.originOrder,
+            variable = book.variable,
+            readConfig = book.readConfig?.let(MangaReadConfigSnapshot::from),
+            syncTime = book.syncTime,
+            listIntro = book.listIntro,
+        )
+    }
+}
+
+@Immutable
+data class MangaReadConfigSnapshot(
+    val reverseToc: Boolean,
+    val pageAnim: Int?,
+    val reSegment: Boolean,
+    val imageStyle: String?,
+    val useReplaceRule: Boolean?,
+    val delTag: Long,
+    val ttsEngine: String?,
+    val splitLongChapter: Boolean,
+    val readSimulating: Boolean,
+    val startDate: String?,
+    val startChapter: Int?,
+    val dailyChapters: Int,
+    val mangaColorFilter: String?,
+    val mangaScrollMode: Int?,
+    val webtoonSidePaddingDp: Int?,
+    val mangaBackground: String?,
+    val fixedType: Boolean,
+    val translationMode: Boolean,
+) {
+    fun toReadConfig() = Book.ReadConfig(
+        reverseToc = reverseToc,
+        pageAnim = pageAnim,
+        reSegment = reSegment,
+        imageStyle = imageStyle,
+        useReplaceRule = useReplaceRule,
+        delTag = delTag,
+        ttsEngine = ttsEngine,
+        splitLongChapter = splitLongChapter,
+        readSimulating = readSimulating,
+        startDate = startDate,
+        startChapter = startChapter,
+        dailyChapters = dailyChapters,
+        mangaColorFilter = mangaColorFilter,
+        mangaScrollMode = mangaScrollMode,
+        webtoonSidePaddingDp = webtoonSidePaddingDp,
+        mangaBackground = mangaBackground,
+        fixedType = fixedType,
+        translationMode = translationMode,
+    )
+
+    companion object {
+        fun from(config: Book.ReadConfig) = MangaReadConfigSnapshot(
+            reverseToc = config.reverseToc,
+            pageAnim = config.pageAnim,
+            reSegment = config.reSegment,
+            imageStyle = config.imageStyle,
+            useReplaceRule = config.useReplaceRule,
+            delTag = config.delTag,
+            ttsEngine = config.ttsEngine,
+            splitLongChapter = config.splitLongChapter,
+            readSimulating = config.readSimulating,
+            startDate = config.startDate,
+            startChapter = config.startChapter,
+            dailyChapters = config.dailyChapters,
+            mangaColorFilter = config.mangaColorFilter,
+            mangaScrollMode = config.mangaScrollMode,
+            webtoonSidePaddingDp = config.webtoonSidePaddingDp,
+            mangaBackground = config.mangaBackground,
+            fixedType = config.fixedType,
+            translationMode = config.translationMode,
+        )
+    }
+}

+ 18 - 4
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt

@@ -2,6 +2,7 @@ package io.legado.app.ui.book.manga
 
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
+import androidx.annotation.StringRes
 import androidx.compose.ui.graphics.Color
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookProgress
@@ -28,7 +29,8 @@ data class MangaReaderUiState(
     val chapterIndex: Int = 0,
     val chapterCount: Int = 0,
     val isLoading: Boolean = true,
-    val errorMessage: String? = null,
+    val errorMessage: MangaReaderText? = null,
+    val pendingMessages: ImmutableList<MangaReaderMessage> = persistentListOf(),
     val menuVisible: Boolean = false,
     val autoReadEnabled: Boolean = false,
     val activeSheet: MangaReaderSheet? = null,
@@ -102,8 +104,20 @@ data class MangaReaderSettings(
 data class MangaScrollRequest(val id: Long, val itemIndex: Int, val animated: Boolean)
 
 @Immutable
-@JvmInline
-value class MangaBookSnapshot(val json: String)
+data class MangaReaderMessage(
+    val id: Long,
+    val content: MangaReaderText,
+)
+
+@Immutable
+sealed interface MangaReaderText {
+    data class Resource(
+        @StringRes val resId: Int,
+        val args: ImmutableList<String> = persistentListOf(),
+    ) : MangaReaderText
+
+    data class Dynamic(val value: String) : MangaReaderText
+}
 
 sealed interface MangaReaderIntent {
     data class Initialize(
@@ -162,6 +176,7 @@ sealed interface MangaReaderIntent {
     data class SeekToPage(val pageIndex: Int) : MangaReaderIntent
     data class VisibleItemChanged(val itemIndex: Int) : MangaReaderIntent
     data class LongPressPage(val imageUrl: String) : MangaReaderIntent
+    data class MessageShown(val id: Long) : MangaReaderIntent
 }
 
 sealed interface MangaReaderSheet {
@@ -222,7 +237,6 @@ sealed interface MangaReaderEffect {
     data class OpenChapterUrl(val externalBrowser: Boolean) : MangaReaderEffect
     data class OpenSourceLogin(val sourceUrl: String) : MangaReaderEffect
     data class OpenSourceEdit(val sourceUrl: String) : MangaReaderEffect
-    data class ShowMessage(val message: String) : MangaReaderEffect
     data class OpenPaymentUrl(
         val url: String,
         val sourceOrigin: String?,

+ 8 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt

@@ -217,12 +217,19 @@ internal fun BoxScope.ReaderStatusOverlay(
         }
     }
     state.errorMessage?.let { message ->
+        val errorText = when (message) {
+            is MangaReaderText.Dynamic -> message.value
+            is MangaReaderText.Resource -> stringResource(
+                message.resId,
+                *message.args.toTypedArray(),
+            )
+        }
         Surface(modifier = Modifier.fillMaxSize(), color = LegadoTheme.colorScheme.surface) {
             Column(
                 horizontalAlignment = Alignment.CenterHorizontally,
                 verticalArrangement = Arrangement.Center,
             ) {
-                Text(message, modifier = Modifier.padding(24.dp))
+                Text(errorText, modifier = Modifier.padding(24.dp))
                 Button(onClick = { onIntent(MangaReaderIntent.Retry) }) { Text(stringResource(R.string.retry)) }
             }
         }

+ 0 - 2
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt

@@ -18,7 +18,6 @@ import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.openUrl
-import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toggleSystemBar
 import kotlinx.coroutines.flow.collectLatest
 
@@ -85,7 +84,6 @@ fun MangaReaderRouteScreen(
                         currentState.sourceType,
                     )
                 }
-                is MangaReaderEffect.ShowMessage -> activity.toastOnUi(effect.message)
                 is MangaReaderEffect.OpenSourceLogin -> onOpenSourceLogin(effect.sourceUrl)
                 is MangaReaderEffect.OpenSourceEdit -> onOpenSourceEdit(effect.sourceUrl)
                 is MangaReaderEffect.OpenPaymentUrl -> onOpenWebView(

+ 23 - 5
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -50,6 +50,10 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.IntSize
+import android.widget.Toast
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.compose.ui.layout.onSizeChanged
 import coil3.ImageLoader
 import coil3.compose.AsyncImage
@@ -58,12 +62,9 @@ import coil3.request.crossfade
 import coil3.request.transformations
 import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.R
-import io.legado.app.data.entities.Book
 import io.legado.app.help.coil.CoverExtras
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
-import io.legado.app.utils.GSON
-import io.legado.app.utils.fromJsonObject
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.distinctUntilChanged
 import org.koin.compose.koinInject
@@ -103,6 +104,19 @@ fun MangaReaderScreen(
     }
 
     val context = LocalContext.current
+    val lifecycleOwner = LocalLifecycleOwner.current
+    val pendingMessage = state.pendingMessages.firstOrNull()
+    LaunchedEffect(pendingMessage?.id, context, lifecycleOwner) {
+        val message = pendingMessage ?: return@LaunchedEffect
+        lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+            Toast.makeText(
+                context,
+                message.content.resolve(context),
+                Toast.LENGTH_SHORT,
+            ).show()
+            onIntent(MangaReaderIntent.MessageShown(message.id))
+        }
+    }
     LaunchedEffect(state.currentItemIndex, state.settings.preDownloadCount, state.pages) {
         val preloadCount = state.settings.preDownloadCount.coerceAtLeast(0)
         val start = (state.currentItemIndex - preloadCount).coerceAtLeast(0)
@@ -144,8 +158,7 @@ fun MangaReaderScreen(
     }
     if (state.activeSheet == MangaReaderSheet.ChangeSource) {
         val oldBook = remember(state.changeSourceBook) {
-            state.changeSourceBook
-                ?.let { GSON.fromJsonObject<Book>(it.json).getOrNull() }
+            state.changeSourceBook?.toBook()
         }
         oldBook?.let {
             ChangeSourceSheet(
@@ -202,6 +215,11 @@ fun MangaReaderScreen(
     )
 }
 
+private fun MangaReaderText.resolve(context: android.content.Context): String = when (this) {
+    is MangaReaderText.Dynamic -> value
+    is MangaReaderText.Resource -> context.getString(resId, *args.toTypedArray())
+}
+
 @Composable
 private fun WebtoonMangaList(
     state: MangaReaderUiState,

+ 85 - 30
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -1,6 +1,5 @@
 package io.legado.app.ui.book.manga
 
-import android.app.Application
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
 import androidx.lifecycle.ViewModel
@@ -10,6 +9,7 @@ import io.legado.app.data.repository.MangaReaderSessionRepository
 import io.legado.app.data.repository.MangaReaderLaunchRequest
 import io.legado.app.data.repository.MangaPaymentResult
 import io.legado.app.data.repository.MangaReaderSessionEvent
+import io.legado.app.data.repository.MangaReaderSessionState
 import io.legado.app.model.manga.MangaPage
 import io.legado.app.model.manga.ReaderLoading
 import io.legado.app.data.model.MangaFooterConfig
@@ -20,6 +20,7 @@ import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
+import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -35,7 +36,6 @@ import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 
 class MangaReaderViewModel(
-    private val application: Application,
     private val mangaSettingsGateway: MangaSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val readSettingsGateway: ReadSettingsGateway,
@@ -57,7 +57,6 @@ class MangaReaderViewModel(
         viewModelScope.launch {
             sessionRepository.events.collect { event ->
                 when (event) {
-                    MangaReaderSessionEvent.ContentUpdated -> refreshContent()
                     is MangaReaderSessionEvent.LoadFailed -> showError(event.message)
                     is MangaReaderSessionEvent.ConfirmProgress -> _uiState.update {
                         it.copy(activeDialog = MangaReaderDialog.ConfirmProgress(event.progress))
@@ -65,11 +64,14 @@ class MangaReaderViewModel(
                     MangaReaderSessionEvent.Loading,
                     MangaReaderSessionEvent.LoadStarted -> showLoading()
                     is MangaReaderSessionEvent.Message -> {
-                        _effects.tryEmit(MangaReaderEffect.ShowMessage(event.message))
+                        enqueueMessage(text = event.message)
                     }
                 }
             }
         }
+        viewModelScope.launch {
+            sessionRepository.sessionState.collect(::refreshContent)
+        }
         viewModelScope.launch {
             mangaSettingsGateway.settings.collect { settings ->
                 latestMangaSettings = settings
@@ -106,7 +108,7 @@ class MangaReaderViewModel(
                 sessionRepository.changeSource(intent.book, intent.toc)
             }
             is MangaReaderIntent.AddExternalBookToShelf -> launchAction(
-                successMessage = application.getString(R.string.manga_reader_added_to_shelf),
+                successMessageRes = R.string.manga_reader_added_to_shelf,
             ) { sessionRepository.addToShelf(intent.book, intent.toc) }
             MangaReaderIntent.AddCurrentBookToShelf -> launchAction {
                 _uiState.update { it.copy(activeDialog = null) }
@@ -180,7 +182,7 @@ class MangaReaderViewModel(
                     it.copy(
                         activeSheet = MangaReaderSheet.ChangeSource,
                         changeSourceBook = sessionRepository.currentBook()?.let {
-                            MangaBookSnapshot(GSON.toJson(it))
+                            MangaBookSnapshot.from(it)
                         },
                     )
                 }
@@ -214,6 +216,13 @@ class MangaReaderViewModel(
                     saveImage(intent.imageUrl)
                 }
             }
+            is MangaReaderIntent.MessageShown -> _uiState.update { state ->
+                state.copy(
+                    pendingMessages = state.pendingMessages
+                        .filterNot { it.id == intent.id }
+                        .toImmutableList()
+                )
+            }
         }
     }
 
@@ -237,7 +246,10 @@ class MangaReaderViewModel(
                 }
                 refreshContent()
             }.onFailure { error ->
-                showError(application.getString(R.string.manga_reader_init_failed, error.localizedMessage))
+                showError(
+                    error.localizedMessage ?: "",
+                    fallbackRes = R.string.manga_reader_init_failed,
+                )
             }
         }
     }
@@ -259,36 +271,41 @@ class MangaReaderViewModel(
     }
 
     private fun saveImage(url: String) {
-        launchAction(successMessage = application.getString(R.string.manga_reader_image_saved)) {
+        launchAction(
+            successMessageRes = R.string.manga_reader_image_saved,
+            failureMessageRes = R.string.manga_reader_save_failed,
+        ) {
             check(sessionRepository.saveImage(url)) {
-                application.getString(R.string.manga_reader_save_failed)
+                ""
             }
         }
     }
 
     private fun launchAction(
-        successMessage: String? = null,
+        @androidx.annotation.StringRes successMessageRes: Int? = null,
+        @androidx.annotation.StringRes failureMessageRes: Int = R.string.manga_reader_action_failed,
         action: suspend () -> Unit,
     ) {
         viewModelScope.launch {
             runCatching { action() }
-                .onSuccess { successMessage?.let { message ->
-                    _effects.tryEmit(MangaReaderEffect.ShowMessage(message))
+                .onSuccess { successMessageRes?.let { resId ->
+                    enqueueMessage(resId = resId)
                 } }
                 .onFailure { error ->
-                    _effects.tryEmit(
-                        MangaReaderEffect.ShowMessage(
-                            error.localizedMessage ?: application.getString(R.string.manga_reader_action_failed)
-                        )
-                    )
+                    val message = error.localizedMessage?.takeIf(String::isNotBlank)
+                    if (message != null) enqueueMessage(text = message)
+                    else enqueueMessage(resId = failureMessageRes)
                 }
         }
     }
 
-    fun refreshContent() {
+    fun refreshContent() = refreshContent(sessionRepository.currentSessionState())
+
+    private fun refreshContent(session: MangaReaderSessionState?) {
+        if (session == null) return
         refreshContentJob?.cancel()
         refreshContentJob = viewModelScope.launch {
-            val content = withContext(Dispatchers.IO) { sessionRepository.content() }
+            val content = session.content
             val items = content.items.mapIndexedNotNull { index, item ->
                 when (item) {
                     is MangaPage -> MangaReaderItemUi.Page(
@@ -308,26 +325,26 @@ class MangaReaderViewModel(
                 }
             }.toImmutableList()
             val safePosition = content.position.coerceIn(0, (items.size - 1).coerceAtLeast(0))
-            val bookName = sessionRepository.currentBookName()
+            val bookName = session.bookName
             val oldState = _uiState.value
             val shouldPosition = oldState.pages.isEmpty() || oldState.isLoading ||
                     oldState.bookName != bookName
             _uiState.update { old ->
                 old.copy(
                     bookName = bookName,
-                    bookAuthor = sessionRepository.currentBookAuthor(),
-                    bookUrl = sessionRepository.currentBookUrl(),
-                    chapterName = sessionRepository.currentChapterName(),
-                    chapterUrl = sessionRepository.currentChapterUrl(),
-                    sourceName = sessionRepository.currentSourceName(),
-                    sourceUrl = sessionRepository.currentSourceUrl(),
-                    sourceType = sessionRepository.currentSourceType(),
+                    bookAuthor = session.bookAuthor,
+                    bookUrl = session.bookUrl,
+                    chapterName = session.chapterName,
+                    chapterUrl = session.chapterUrl,
+                    sourceName = session.sourceName,
+                    sourceUrl = session.sourceUrl,
+                    sourceType = session.sourceType,
                     pages = items,
                     currentItemIndex = if (shouldPosition) safePosition else {
                         old.currentItemIndex.coerceIn(0, (items.size - 1).coerceAtLeast(0))
                     },
-                    chapterIndex = sessionRepository.currentChapterIndex(),
-                    chapterCount = sessionRepository.chapterCount(),
+                    chapterIndex = session.chapterIndex,
+                    chapterCount = session.chapterCount,
                     isLoading = false,
                     errorMessage = null,
                     settings = readSettings(latestMangaSettings),
@@ -349,7 +366,45 @@ class MangaReaderViewModel(
     }
 
     fun showError(message: String) {
-        _uiState.update { it.copy(isLoading = false, errorMessage = message) }
+        _uiState.update {
+            it.copy(isLoading = false, errorMessage = MangaReaderText.Dynamic(message))
+        }
+    }
+
+    private fun showError(message: String, @androidx.annotation.StringRes fallbackRes: Int) {
+        if (message.isNotBlank()) showError(message)
+        else {
+            _uiState.update {
+                it.copy(
+                    isLoading = false,
+                    errorMessage = MangaReaderText.Resource(
+                        fallbackRes,
+                        persistentListOf(""),
+                    ),
+                )
+            }
+        }
+    }
+
+    private fun enqueueMessage(
+        @androidx.annotation.StringRes resId: Int? = null,
+        text: String? = null,
+        args: kotlinx.collections.immutable.ImmutableList<String> = persistentListOf(),
+    ) {
+        _uiState.update { state ->
+            state.copy(
+                pendingMessages = (state.pendingMessages +
+                    MangaReaderMessage(
+                        id = System.nanoTime(),
+                        content = if (resId != null) {
+                            MangaReaderText.Resource(resId, args)
+                        } else {
+                            MangaReaderText.Dynamic(requireNotNull(text))
+                        },
+                    )
+                ).toImmutableList()
+            )
+        }
     }
 
     override fun onCleared() {

+ 0 - 2
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -19,7 +19,6 @@ import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.openUrl
-import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toggleSystemBar
 import org.koin.androidx.viewmodel.ext.android.viewModel
 import kotlinx.coroutines.flow.collectLatest
@@ -88,7 +87,6 @@ class ReadMangaActivity : BaseComposeActivity(imageBg = false) {
             MangaReaderEffect.OpenCatalog -> openCatalog()
             MangaReaderEffect.OpenBookInfo -> openBookInfoActivity()
             is MangaReaderEffect.OpenChapterUrl -> openCurrentChapterUrl(effect.externalBrowser)
-            is MangaReaderEffect.ShowMessage -> toastOnUi(effect.message)
             is MangaReaderEffect.OpenSourceLogin -> startActivity(
                 MainActivity.createSourceLoginIntent(
                     this,

+ 33 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaBookSnapshotTest.kt

@@ -0,0 +1,33 @@
+package io.legado.app.ui.book.manga
+
+import io.legado.app.data.entities.Book
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Test
+
+class MangaBookSnapshotTest {
+
+    @Test
+    fun `snapshot recreates an independent book for change source`() {
+        val original = Book(
+            bookUrl = "book-url",
+            tocUrl = "toc-url",
+            origin = "source-url",
+            originName = "Source",
+            name = "Book",
+            author = "Author",
+            type = 2,
+            durChapterIndex = 7,
+            readConfig = Book.ReadConfig(
+                mangaScrollMode = 3,
+                webtoonSidePaddingDp = 12,
+            ),
+        )
+
+        val restored = MangaBookSnapshot.from(original).toBook()
+
+        assertEquals(original, restored)
+        assertNotSame(original, restored)
+        assertNotSame(original.readConfig, restored.readConfig)
+    }
+}