[feat] Comic Resume

This commit is contained in:
acite
2025-09-02 19:08:11 +08:00
parent daa66a9ecc
commit 18d021a8e5
11 changed files with 427 additions and 129 deletions

View File

@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "1.9.0"
id("kotlin-kapt")
}
android {
@@ -42,6 +43,9 @@ android {
}
dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
kapt("androidx.room:room-compiler:2.7.2")
implementation(libs.androidx.datastore.preferences)
implementation(libs.bcprov.jdk15on)
implementation(libs.converter.gson)

View File

@@ -47,4 +47,22 @@ class Comic(
return -1
}
fun getPageChapterIndex(page: Int): Pair<BookMark, Int>?
{
var p = page
while(p >= 0 && !comic.bookmarks.any{ x -> x.page == comic.list[p] })
{
p--
}
for(i in comic.bookmarks)
{
if(i.page == comic.list[p])
{
return Pair(i, page - comic.list.indexOf(i.page) + 1)
}
}
return null
}
}

View File

@@ -0,0 +1,13 @@
package com.acitelight.aether.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class ComicRecord(
@PrimaryKey(autoGenerate = false) val id: Int = 0,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "position") val position: Int
)

View File

@@ -0,0 +1,27 @@
package com.acitelight.aether.model
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface ComicRecordDao {
@Query("SELECT * FROM comicrecord")
fun getAll(): Flow<List<ComicRecord>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(rec: ComicRecord)
@Update
suspend fun update(rec: ComicRecord)
@Delete
suspend fun delete(rec: ComicRecord)
@Query("SELECT * FROM comicrecord WHERE id = :id")
suspend fun getById(id: Int): ComicRecord?
}

View File

@@ -0,0 +1,29 @@
package com.acitelight.aether.model
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [ComicRecord::class], version = 1)
abstract class ComicRecordDatabase : RoomDatabase() {
abstract fun userDao(): ComicRecordDao
companion object {
@Volatile
private var INSTANCE: ComicRecordDatabase? = null
fun getDatabase(context: Context): ComicRecordDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ComicRecordDatabase::class.java,
"comicrecord_database"
).build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -1,6 +1,7 @@
package com.acitelight.aether.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,7 +21,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -37,28 +40,113 @@ import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global
import com.acitelight.aether.ToggleFullScreen
import com.acitelight.aether.model.BookMark
import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicRecordDatabase
import com.acitelight.aether.viewModel.ComicGridViewModel
import java.util.EnumSet.range
import java.util.stream.IntStream.range
@Composable
fun ComicGridView(comicId: String, navController: NavHostController, comicGridViewModel: ComicGridViewModel = viewModel())
{
fun ComicGridView(comicId: String, navController: NavHostController, comicGridViewModel: ComicGridViewModel = viewModel()) {
comicGridViewModel.SetupClient()
comicGridViewModel.Resolve(comicId.hexToString())
LazyColumn(modifier = Modifier.fillMaxWidth())
comicGridViewModel.updateProcess(comicId.hexToString()){}
ToggleFullScreen(false)
val context = LocalContext.current
val comic by comicGridViewModel.comic
val record by comicGridViewModel.record
if (comic != null) {
Column {
Box(
Modifier
.padding(horizontal = 16.dp).padding(top = 36.dp)
.background(Color.White.copy(alpha = 0.65f), shape = RoundedCornerShape(12.dp))
)
{
Column {
Text(
text = comic!!.comic.comic_name,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
modifier = Modifier.padding(4.dp)
)
Text(
text = comic!!.comic.author,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
modifier = Modifier.padding(4.dp).fillMaxWidth()
)
}
}
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).padding(top = 6.dp).clip(RoundedCornerShape(6.dp)))
{
items(comicGridViewModel.chapterList)
{ c ->
ChapterCard(comic!!, navController, c, comicGridViewModel)
}
}
Box(
Modifier
.padding(horizontal = 16.dp).padding(top = 6.dp).padding(bottom = 20.dp).heightIn(min = 42.dp)
.background(Color.White.copy(alpha = 0.65f), shape = RoundedCornerShape(12.dp))
.clickable{
comicGridViewModel.updateProcess(comicId.hexToString())
{
c ->
ChapterCard(comicGridViewModel.comic!!, navController, c, comicGridViewModel)
if(record != null) {
val k = comic!!.getPageChapterIndex(record!!.position)!!
val route = "comic_page_route/${"${comic!!.id}".toHex()}/${
comic!!.getPageIndex(k.first.page)
}"
navController.navigate(route)
}else
{
val route = "comic_page_route/${"${comic!!.id}".toHex()}/${0}"
navController.navigate(route)
}
}
}
)
{
Row(Modifier.fillMaxWidth().align(Alignment.Center).padding(horizontal = 8.dp)) {
if(record != null)
{
val k = comic!!.getPageChapterIndex(record!!.position)!!
Text(
text = "Last Read Position: ${k.first.name} ${k.second}/${comic!!.getChapterLength(k.first.page)}",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
modifier = Modifier.padding(4.dp).weight(1f)
)
}else{
Text(
text = "Read from scratch",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
modifier = Modifier.padding(4.dp).weight(1f)
)
}
}
}
}
}
}
@Composable
fun ChapterCard(comic: Comic, navController: NavHostController, chapter: BookMark, comicGridViewModel: ComicGridViewModel = viewModel())
{
@@ -70,7 +158,7 @@ fun ChapterCard(comic: Comic, navController: NavHostController, chapter: BookMar
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(16.dp),
.padding(horizontal = 16.dp).padding(vertical = 6.dp),
onClick = {
val route = "comic_page_route/${"${comic.id}".toHex()}/${comic.getPageIndex(chapter.page)}"
navController.navigate(route)
@@ -78,10 +166,10 @@ fun ChapterCard(comic: Comic, navController: NavHostController, chapter: BookMar
) {
Column(Modifier.fillMaxWidth())
{
Row(Modifier.padding(12.dp))
Row(Modifier.padding(6.dp))
{
Box(Modifier
.height(260.dp)
.height(170.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0x44FFFFFF)))
{
@@ -117,7 +205,7 @@ fun ChapterCard(comic: Comic, navController: NavHostController, chapter: BookMar
}
val r = comic.comic.list.subList(iv, iv + comic.getChapterLength(c.page))
LazyRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
LazyRow(modifier = Modifier.fillMaxWidth().padding(6.dp)) {
items(r)
{
r ->
@@ -126,7 +214,7 @@ fun ChapterCard(comic: Comic, navController: NavHostController, chapter: BookMar
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.height(200.dp)
.height(140.dp)
.padding(horizontal = 6.dp),
onClick = {
val route = "comic_page_route/${"${comic.id}".toHex()}/${comic.getPageIndex(r)}"

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -25,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -44,6 +46,7 @@ import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.ToggleFullScreen
import com.acitelight.aether.model.ComicRecord
import com.acitelight.aether.viewModel.ComicPageViewModel
import kotlinx.coroutines.launch
@@ -57,20 +60,30 @@ fun ComicPageView(comicId: String, page: String, navController: NavHostControll
val pagerState = rememberPagerState(initialPage = page.toInt(), pageCount = { comicPageViewModel.pageList.size })
var showPlane by comicPageViewModel.showPlane
comicPageViewModel.UpdateProcess(pagerState.currentPage)
val comic by comicPageViewModel.comic
comic?.let {
Box()
{
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().align(Alignment.Center).background(Color.Black).clickable(){
modifier = Modifier.fillMaxSize().align(Alignment.Center).background(Color.Black).clickable{
showPlane = !showPlane
if(showPlane)
{
comicPageViewModel.coroutineScope?.launch {
comicPageViewModel.listState?.scrollToItem(index = pagerState.currentPage)
}
}
}
) {
page ->
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(comicPageViewModel.comic!!.getPage(page))
.memoryCacheKey("${comicPageViewModel.comic!!.id}/${page}")
.diskCacheKey("${comicPageViewModel.comic!!.id}/${page}")
.data(it.getPage(page))
.memoryCacheKey("${it.id}/${page}")
.diskCacheKey("${it.id}/${page}")
.build(),
contentDescription = null,
imageLoader = comicPageViewModel.imageLoader!!,
@@ -88,24 +101,24 @@ fun ComicPageView(comicId: String, page: String, navController: NavHostControll
){
Box()
{
Box(modifier = Modifier.height(180.dp).align(Alignment.TopCenter).fillMaxWidth().background(
Box(modifier = Modifier.height(240.dp).align(Alignment.TopCenter).fillMaxWidth().background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.75f),
Color.Transparent,
))))
Column(Modifier.align(Alignment.TopCenter).fillMaxWidth())
{
Row(modifier = Modifier
.fillMaxWidth()
.padding(top = 18.dp).padding(horizontal = 12.dp)
.height(60.dp)
.align(Alignment.TopCenter)
.height(42.dp)
.background(Color(0x90FFFFFF), shape = RoundedCornerShape(12.dp)))
{
Text(
text = title,
fontSize = 20.sp,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
@@ -114,13 +127,38 @@ fun ComicPageView(comicId: String, page: String, navController: NavHostControll
Text(
text = "${pagerState.currentPage + 1}/${pagerState.pageCount}",
fontSize = 24.sp,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
modifier = Modifier.padding(8.dp).widthIn(120.dp).align(Alignment.CenterVertically)
modifier = Modifier.padding(8.dp).widthIn(min = 60.dp).align(Alignment.CenterVertically)
)
}
Row(modifier = Modifier
.padding(top = 6.dp).padding(horizontal = 12.dp)
.height(42.dp)
.background(Color(0x90FFFFFF), shape = RoundedCornerShape(12.dp)))
{
val k = it.getPageChapterIndex(pagerState.currentPage)!!
Text(
text = k.first.name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
modifier = Modifier.padding(8.dp).padding(horizontal = 10.dp).align(Alignment.CenterVertically)
)
Text(
text = "${k.second}/${it.getChapterLength(k.first.page)}",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
maxLines = 1,
modifier = Modifier.padding(8.dp).widthIn(min = 60.dp).align(Alignment.CenterVertically)
)
}
}
}
}
@@ -160,19 +198,50 @@ fun ComicPageView(comicId: String, page: String, navController: NavHostControll
pagerState.requestScrollToPage(page = r)
}
){
Box(Modifier.fillMaxSize())
{
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(comicPageViewModel.comic!!.getPage(r))
.memoryCacheKey("${comicPageViewModel.comic!!.id}/${r}")
.diskCacheKey("${comicPageViewModel.comic!!.id}/${r}")
.data(it.getPage(r))
.memoryCacheKey("${it.id}/${r}")
.diskCacheKey("${it.id}/${r}")
.build(),
contentDescription = null,
imageLoader = comicPageViewModel.imageLoader!!,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(12.dp)),
.clip(RoundedCornerShape(12.dp))
.align(Alignment.Center),
contentScale = ContentScale.Fit,
)
val k = it.getPageChapterIndex(r)!!
Box(Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.background(Color.Black.copy(alpha = 0.65f), shape = RoundedCornerShape(12.dp)))
{
Row{
Text(
text = k.first.name,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
modifier = Modifier.padding(4.dp).align(Alignment.CenterVertically)
)
Text(
text = "${k.second}/${it.getChapterLength(k.first.page)}",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
modifier = Modifier.padding(4.dp).fillMaxWidth().align(Alignment.CenterVertically)
)
}
}
}
}
}
}
}

View File

@@ -72,7 +72,7 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navCon
TopRow(videoScreenViewModel);
LazyVerticalGrid(
columns = GridCells.Adaptive(200.dp),
columns = GridCells.Adaptive(160.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)

View File

@@ -2,6 +2,8 @@ package com.acitelight.aether.viewModel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -9,15 +11,20 @@ import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.model.BookMark
import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicRecord
import com.acitelight.aether.model.ComicRecordDatabase
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.view.hexToString
import kotlinx.coroutines.launch
class ComicGridViewModel : ViewModel()
{
var imageLoader: ImageLoader? = null
var comic: Comic? = null
var comic = mutableStateOf<Comic?>(null)
val chapterList = mutableStateListOf<BookMark>()
var db: ComicRecordDatabase? = null
var record = mutableStateOf<ComicRecord?>(null)
@Composable
fun SetupClient()
@@ -28,18 +35,33 @@ class ComicGridViewModel : ViewModel()
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
db = remember {
try{
ComicRecordDatabase.getDatabase(context)
}catch (e: Exception) {
print(e.message)
} as ComicRecordDatabase?
}
}
fun Resolve(id: String)
{
if(comic != null) return
if(comic.value != null) return
viewModelScope.launch {
comic = MediaManager.queryComicInfo(id)
val c = comic!!
comic.value = MediaManager.queryComicInfo(id)
val c = comic.value!!
for(i in c.comic.bookmarks)
{
chapterList.add(i)
}
}
}
fun updateProcess(id: String, callback: () -> Unit)
{
viewModelScope.launch {
record.value = db?.userDao()?.getById(id.toInt())
callback()
}
}
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
@@ -16,20 +17,24 @@ import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicRecord
import com.acitelight.aether.model.ComicRecordDatabase
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.view.hexToString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ComicPageViewModel : ViewModel()
{
var imageLoader: ImageLoader? = null
var comic: Comic? = null
var comic = mutableStateOf<Comic?>(null)
var pageList = mutableStateListOf<String>()
var title = mutableStateOf<String>("")
var listState: LazyListState? = null
var coroutineScope: CoroutineScope? = null
var showPlane = mutableStateOf(false)
var showPlane = mutableStateOf(true)
var db: ComicRecordDatabase? = null
@Composable
fun SetupClient()
@@ -42,21 +47,38 @@ class ComicPageViewModel : ViewModel()
.build()
listState = rememberLazyListState()
coroutineScope = rememberCoroutineScope()
db = remember {
try{
ComicRecordDatabase.getDatabase(context)
}catch (e: Exception) {
print(e.message)
} as ComicRecordDatabase?
}
}
@Composable
fun Resolve(id: String, page: Int)
{
if(comic != null) return
if(comic.value != null) return
LaunchedEffect(id, page) {
coroutineScope?.launch {
comic = MediaManager.queryComicInfo(id)
comic?.let {
comic.value = MediaManager.queryComicInfo(id)
comic.value?.let {
pageList.addAll(it.comic.list)
title.value = it.comic.comic_name
listState?.scrollToItem(index = page)
UpdateProcess(page)
}
}
}
}
fun UpdateProcess(page: Int)
{
if(comic.value == null) return
coroutineScope?.launch {
db?.userDao()?.insert(ComicRecord(id = comic.value!!.id.toInt(), name = comic.value!!.comic.comic_name, position = page))
}
}
}

View File

@@ -25,6 +25,9 @@ okhttp = "5.1.0"
retrofit = "3.0.0"
retrofit2KotlinxSerializationConverter = "1.0.0"
media3DatasourceOkhttp = "1.8.0"
roomCompiler = "2.7.2"
roomKtx = "2.7.2"
roomRuntime = "2.7.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -34,6 +37,9 @@ androidx-media3-common = { module = "androidx.media3:media3-common", version.ref
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }