[Manatoki] Main/Reader OK

This commit is contained in:
tom5079
2021-12-20 11:44:13 +09:00
parent 0f4e1a8e0d
commit b82ef8695c
15 changed files with 1111 additions and 178 deletions

View File

@@ -110,10 +110,10 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0")
implementation("androidx.room:room-runtime:2.3.0")
annotationProcessor("androidx.room:room-compiler:2.3.0")
kapt("androidx.room:room-compiler:2.3.0")
implementation("androidx.room:room-ktx:2.3.0")
implementation("androidx.room:room-runtime:2.4.0")
annotationProcessor("androidx.room:room-compiler:2.4.0")
kapt("androidx.room:room-compiler:2.4.0")
implementation("androidx.room:room-ktx:2.4.0")
implementation("androidx.datastore:datastore:1.0.0")
implementation("androidx.datastore:datastore-preferences:1.0.0")
@@ -138,6 +138,8 @@ dependencies {
implementation("xyz.quaver:documentfilex:0.7.1")
implementation("xyz.quaver:subsampledimage:0.0.1-alpha11-SNAPSHOT")
implementation("com.google.guava:guava:31.0.1-android")
implementation("org.kodein.log:kodein-log:0.11.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.7")

View File

@@ -23,6 +23,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import org.kodein.di.*
import xyz.quaver.pupil.sources.hitomi.Hitomi
import xyz.quaver.pupil.sources.manatoki.Manatoki
abstract class Source {
abstract val name: String
@@ -39,7 +40,7 @@ val sourceModule = DI.Module(name = "source") {
listOf<(Application) -> (Source)>(
{ Hitomi(it) },
//{ Hiyobi_io(it) },
//{ Manatoki(it) }
{ Manatoki(it) }
).forEach { source ->
inSet { singleton { source(instance()).let { it.name to it } } }
}

View File

@@ -41,13 +41,16 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.insets.LocalWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.rememberInsetsPaddingValues
import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.insets.ui.TopAppBar
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -63,6 +66,7 @@ import xyz.quaver.pupil.R
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.ui.theme.Orange500
import xyz.quaver.pupil.util.NetworkCache
import xyz.quaver.pupil.util.activity
import xyz.quaver.pupil.util.rememberFileXImageSource
import kotlin.math.abs
@@ -75,9 +79,6 @@ open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAwar
private val database: AppDatabase by instance()
private val historyDao = database.historyDao()
private val bookmarkDao = database.bookmarkDao()
var error by mutableStateOf(false)
var title by mutableStateOf<String?>(null)
@@ -171,6 +172,19 @@ fun ReaderBase(
val scaffoldState = rememberScaffoldState()
val snackbarCoroutineScope = rememberCoroutineScope()
LaunchedEffect(model.isFullscreen) {
context.activity?.window?.let { window ->
ViewCompat.getWindowInsetsController(window.decorView)?.let {
if (model.isFullscreen) {
it.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars())
} else
it.show(WindowInsetsCompat.Type.systemBars())
}
}
}
if (model.error)
stringResource(R.string.reader_failed_to_find_gallery).let {
snackbarCoroutineScope.launch {
@@ -181,16 +195,6 @@ fun ReaderBase(
}
}
val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight
SideEffect {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
}
Scaffold(
topBar = {
if (!model.isFullscreen)
@@ -225,6 +229,7 @@ fun ReaderBase(
floatingActionButton = {
if (!model.isFullscreen)
MultipleFloatingActionButton(
modifier = Modifier.navigationBarsPadding(),
items = listOf(
SubFabItem(
icon = Icons.Default.Fullscreen,
@@ -245,7 +250,8 @@ fun ReaderBase(
Box(Modifier.padding(contentPadding)) {
LazyColumn(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
) {
itemsIndexed(model.imageList) { i, uri ->
val state = rememberSubSampledImageState(ScaleTypes.FIT_WIDTH)

View File

@@ -35,7 +35,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
@@ -56,9 +55,8 @@ import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.google.accompanist.insets.LocalWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.rememberInsetsPaddingValues
import com.google.accompanist.insets.systemBarsPadding
import com.google.accompanist.insets.statusBarsPadding
import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.theme.LightBlue300
import kotlin.math.*
@@ -119,25 +117,15 @@ fun <T> SearchBase(
}
}
val systemBarsPaddingValues = rememberInsetsPaddingValues(insets = LocalWindowInsets.current.systemBars)
val statusBarsPaddingValues = rememberInsetsPaddingValues(insets = LocalWindowInsets.current.statusBars)
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
val searchBarDefaultOffset = systemBarsPaddingValues.calculateTopPadding() + 64.dp
val searchBarDefaultOffset = statusBarsPaddingValues.calculateTopPadding() + 64.dp
val searchBarDefaultOffsetPx = LocalDensity.current.run { searchBarDefaultOffset.roundToPx() }
var overscroll: Float? by remember { mutableStateOf(null) }
val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight
SideEffect {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
}
LaunchedEffect(navigationIconProgress) {
navigationIcon.progress = navigationIconProgress
}
@@ -307,7 +295,7 @@ fun <T> SearchBase(
FloatingSearchBar(
modifier = Modifier
.systemBarsPadding()
.statusBarsPadding()
.offset(0.dp, LocalDensity.current.run { model.searchBarOffset.toDp() }),
query = model.query,
onQueryChange = { model.query = it },

View File

@@ -1,6 +1,6 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -13,13 +13,12 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.dialog
package xyz.quaver.pupil.sources.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -29,13 +28,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavController
import org.kodein.di.compose.rememberInstance
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.sources.SourceEntries
@Composable
fun SourceSelectDialog(navController: NavController, currentSource: String, onDismissRequest: () -> Unit = { }) {
SourceSelectDialog(currentSource = currentSource, onDismissRequest = onDismissRequest) {
onDismissRequest()
navController.navigate(it.name) {
popUpTo(currentSource) { inclusive = true }
}
}
}
@Composable
fun SourceSelectDialogItem(source: Source, isSelected: Boolean, onSelected: (Source) -> Unit = { }) {
Row(
@@ -86,4 +95,4 @@ fun SourceSelectDialog(currentSource: String, onDismissRequest: () -> Unit = { }
}
}
}
}
}

View File

@@ -41,7 +41,6 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import io.ktor.client.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
@@ -57,7 +56,6 @@ import xyz.quaver.pupil.sources.hitomi.composable.DetailedSearchResult
import xyz.quaver.pupil.sources.hitomi.lib.getGalleryInfo
import xyz.quaver.pupil.sources.hitomi.lib.getReferer
import xyz.quaver.pupil.sources.hitomi.lib.imageUrlFromImage
import xyz.quaver.pupil.ui.dialog.SourceSelectDialog
class Hitomi(app: Application) : Source(), DIAware {
override val di by closestDI(app)
@@ -73,9 +71,9 @@ class Hitomi(app: Application) : Source(), DIAware {
override val iconResID: Int = R.drawable.hitomi
override fun NavGraphBuilder.navGraph(navController: NavController) {
navigation(startDestination = "search", route = name) {
composable("search") { Search(navController) }
composable("reader/{itemID}") { Reader(navController) }
navigation(startDestination = "hitomi.la/search", route = name) {
composable("hitomi.la/search") { Search(navController) }
composable("hitomi.la/reader/{itemID}") { Reader(navController) }
}
}
@@ -94,16 +92,7 @@ class Hitomi(app: Application) : Source(), DIAware {
var sourceSelectDialog by remember { mutableStateOf(false) }
if (sourceSelectDialog)
SourceSelectDialog(
currentSource = name,
onDismissRequest = { sourceSelectDialog = false }
) {
sourceSelectDialog = false
navController.navigate("main/${it.name}") {
launchSingleTop = true
popUpTo("main/{source}") { inclusive = true }
}
}
SourceSelectDialog(navController, name) { sourceSelectDialog = false }
LaunchedEffect(model.currentPage, model.sortByPopularity) {
model.search()
@@ -188,7 +177,10 @@ class Hitomi(app: Application) : Source(), DIAware {
}
}
) { result ->
navController.navigate("reader/${result.itemID}")
logger.info {
result.toString()
}
navController.navigate("hitomi.la/reader/${result.itemID}")
}
}
}
@@ -209,21 +201,19 @@ class Hitomi(app: Application) : Source(), DIAware {
val bookmark by bookmarkDao.contains(name, itemID).observeAsState(false)
LaunchedEffect(model) {
launch(Dispatchers.IO) {
kotlin.runCatching {
val galleryID = itemID.toInt()
LaunchedEffect(itemID) {
runCatching {
val galleryID = itemID.toInt()
val galleryInfo = getGalleryInfo(client, galleryID)
val galleryInfo = getGalleryInfo(client, galleryID)
model.title = galleryInfo.title
model.title = galleryInfo.title
model.load(galleryInfo.files.map { imageUrlFromImage(galleryID, it, false) }) {
append("Referer", getReferer(galleryID))
}
}.onFailure {
model.error = true
model.load(galleryInfo.files.map { imageUrlFromImage(galleryID, it, false) }) {
append("Referer", getReferer(galleryID))
}
}.onFailure {
model.error = true
}
}

View File

@@ -1,101 +1,339 @@
///*
// * Pupil, Hitomi.la viewer for Android
// * Copyright (C) 2021 tom5079
// *
// * This program is free software: you can redistribute it and/or modify
// * it under the terms of the GNU General Public License as published by
// * the Free Software Foundation, either version 3 of the License, or
// * (at your option) any later version.
// *
// * This program is distributed in the hope that it will be useful,
// * but WITHOUT ANY WARRANTY; without even the implied warranty of
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// * GNU General Public License for more details.
// *
// * You should have received a copy of the GNU General Public License
// * along with this program. If not, see <https://www.gnu.org/licenses/>.
// */
//
//package xyz.quaver.pupil.sources.manatoki
//
//import android.app.Application
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.channels.Channel
//import kotlinx.coroutines.coroutineScope
//import kotlinx.coroutines.withContext
//import kotlinx.parcelize.Parcelize
//import org.jsoup.Jsoup
//import org.kodein.di.DIAware
//import org.kodein.di.android.closestDI
//import org.kodein.log.LoggerFactory
//import org.kodein.log.newLogger
//import xyz.quaver.pupil.R
//import xyz.quaver.pupil.sources.ItemInfo
//import xyz.quaver.pupil.sources.Source
//
//@Parcelize
//class ManatokiItemInfo(
// override val itemID: String,
// override val title: String
//) : ItemInfo {
// override val source: String = "manatoki.net"
//}
//
//class Manatoki(app: Application) : Source(), DIAware {
// override val di by closestDI(app)
//
// private val logger = newLogger(LoggerFactory.default)
//
// override val name = "manatoki.net"
// override val availableSortMode: List<String> = emptyList()
// override val iconResID: Int = R.drawable.manatoki
//
// override suspend fun search(
// query: String,
// range: IntRange,
// sortMode: Int
// ): Pair<Channel<ItemInfo>, Int> {
// TODO("Not yet implemented")
// }
//
// override suspend fun images(itemID: String): List<String> = coroutineScope {
// val jsoup = withContext(Dispatchers.IO) {
// Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
// }
//
// val htmlData = jsoup
// .selectFirst(".view-padding > script")!!
// .data()
// .splitToSequence('\n')
// .fold(StringBuilder()) { sb, line ->
// if (!line.startsWith("html_data")) return@fold sb
//
// line.drop(12).dropLast(2).split('.').forEach {
// if (it.isNotBlank()) sb.appendCodePoint(it.toInt(16))
// }
// sb
// }.toString()
//
// Jsoup.parse(htmlData)
// .select("img[^data-]:not([style])")
// .map {
// it.attributes()
// .first { it.key.startsWith("data-") }
// .value
// }
// }
//
// override suspend fun info(itemID: String): ItemInfo = coroutineScope {
// val jsoup = withContext(Dispatchers.IO) {
// Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
// }
//
// val title = jsoup.selectFirst(".toon-title")!!.ownText()
//
// ManatokiItemInfo(
// itemID,
// title
// )
// }
//
//}
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.manatoki
import android.app.Application
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.google.accompanist.insets.LocalWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.rememberInsetsPaddingValues
import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.insets.ui.TopAppBar
import io.ktor.client.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.compose.rememberInstance
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.pupil.R
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.sources.composable.ReaderBase
import xyz.quaver.pupil.sources.composable.ReaderBaseViewModel
import xyz.quaver.pupil.sources.composable.SourceSelectDialog
import xyz.quaver.pupil.sources.manatoki.composable.BoardButton
import xyz.quaver.pupil.sources.manatoki.composable.MangaListingBottomSheet
import xyz.quaver.pupil.sources.manatoki.composable.Thumbnail
import xyz.quaver.pupil.sources.manatoki.viewmodel.MainViewModel
import java.util.concurrent.ConcurrentHashMap
class Manatoki(app: Application) : Source(), DIAware {
override val di by closestDI(app)
private val logger = newLogger(LoggerFactory.default)
override val name = "manatoki.net"
override val iconResID = R.drawable.manatoki
private val readerInfoChannel = ConcurrentHashMap<String, Channel<ReaderInfo>>()
override fun NavGraphBuilder.navGraph(navController: NavController) {
navigation(route = name, startDestination = "manatoki.net/") {
composable("manatoki.net/") { Main(navController) }
composable("manatoki.net/reader/{itemID}") { Reader(navController) }
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Main(navController: NavController) {
val model: MainViewModel = viewModel()
val client: HttpClient by rememberInstance()
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
val coroutineScope = rememberCoroutineScope()
val onListing: (MangaListing) -> Unit = {
mangaListing = it
logger.info {
it.toString()
}
coroutineScope.launch {
sheetState.show()
}
}
val onReader: (ReaderInfo) -> Unit = { readerInfo ->
val channel = Channel<ReaderInfo>()
readerInfoChannel[readerInfo.itemID] = channel
coroutineScope.launch {
channel.send(readerInfo)
}
navController.navigate("manatoki.net/reader/${readerInfo.itemID}")
}
var sourceSelectDialog by remember { mutableStateOf(false) }
if (sourceSelectDialog)
SourceSelectDialog(navController, name) { sourceSelectDialog = false }
LaunchedEffect(Unit) {
navController.backQueue.forEach {
logger.info {
it.destination.route.toString()
}
}
model.load()
}
BackHandler {
if (sheetState.currentValue == ModalBottomSheetValue.Hidden)
navController.popBackStack()
else
coroutineScope.launch {
sheetState.hide()
}
}
ModalBottomSheetLayout(
sheetState = sheetState,
sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp),
sheetContent = {
MangaListingBottomSheet(mangaListing) {
coroutineScope.launch {
client.getItem(it, onListing, onReader)
}
}
}
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text("박사장 게섯거라")
},
actions = {
IconButton(onClick = { sourceSelectDialog = true }) {
Image(
painter = painterResource(id = R.drawable.manatoki),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
IconButton(onClick = { navController.navigate("settings") }) {
Icon(Icons.Default.Settings, contentDescription = null)
}
},
contentPadding = rememberInsetsPaddingValues(
insets = LocalWindowInsets.current.statusBars,
applyBottom = false
)
)
}
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
Column(
Modifier
.padding(8.dp, 0.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"최신화",
style = MaterialTheme.typography.h5
)
LazyRow(
modifier = Modifier
.fillMaxWidth()
.height(210.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(model.recentUpload) { item ->
Thumbnail(item) {
coroutineScope.launch {
client.getItem(it, onListing, onReader)
}
}
}
}
Divider()
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
BoardButton("마나게시판", Color(0xFF007DB4))
BoardButton("유머/가십", Color(0xFFF09614))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
BoardButton("역식자게시판", Color(0xFFA0C850))
BoardButton("원본게시판", Color(0xFFFF4500))
}
}
Text("만화 목록", style = MaterialTheme.typography.h5)
LazyRow(
modifier = Modifier
.fillMaxWidth()
.height(210.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(model.mangaList) { item ->
Thumbnail(item) {
coroutineScope.launch {
client.getItem(it, onListing, onReader)
}
}
}
}
Text("주간 베스트", style = MaterialTheme.typography.h5)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
model.topWeekly.forEachIndexed { index, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
(index + 1).toString(),
modifier = Modifier
.background(Color(0xFF64C3F5))
.width(24.dp),
color = Color.White,
textAlign = TextAlign.Center
)
Text(
item.title,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
item.count,
color = Color(0xFFFF4500)
)
}
}
}
Box(Modifier.navigationBarsPadding())
}
}
}
}
}
@Composable
fun Reader(navController: NavController) {
val model: ReaderBaseViewModel = viewModel()
val database: AppDatabase by rememberInstance()
val bookmarkDao = database.bookmarkDao()
val coroutineScope = rememberCoroutineScope()
val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID")
LaunchedEffect(Unit) {
val channel = itemID?.let { readerInfoChannel.remove(it) }
if (channel == null)
model.error = true
else {
val readerInfo = channel.receive()
model.title = readerInfo.title
model.load(readerInfo.urls)
}
}
val bookmark by bookmarkDao.contains(name, itemID ?: "").observeAsState(false)
BackHandler {
if (model.isFullscreen)
model.isFullscreen = false
else
navController.popBackStack()
}
ReaderBase(
model,
icon = {
Image(
painter = painterResource(R.drawable.manatoki),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
},
bookmark = bookmark,
onToggleBookmark = {
if (itemID != null)
coroutineScope.launch {
if (bookmark) bookmarkDao.delete(name, itemID)
else bookmarkDao.insert(name, itemID)
}
}
)
}
}

View File

@@ -0,0 +1,66 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.manatoki.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun RowScope.BoardButton(
text: String,
color: Color
) {
Card(
modifier = Modifier.height(64.dp).weight(1f),
shape = RoundedCornerShape(12.dp),
elevation = 8.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text,
modifier = Modifier.padding(8.dp, 0.dp).weight(1f),
style = MaterialTheme.typography.h6
)
Icon(
Icons.Default.ArrowForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.width(48.dp)
.fillMaxHeight()
.background(color)
)
}
}
}

View File

@@ -0,0 +1,209 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.manatoki.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.insets.LocalWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.rememberInsetsPaddingValues
import xyz.quaver.pupil.sources.manatoki.MangaListing
private val FabSpacing = 8.dp
private val HeightPercentage = 75 // take 60% of the available space
private enum class MangaListingBottomSheetLayoutContent { Top, Bottom, Fab }
@Composable
fun MangaListingBottomSheetLayout(
modifier: Modifier = Modifier,
floatingActionButton: @Composable () -> Unit,
top: @Composable () -> Unit,
bottom: @Composable () -> Unit
) {
SubcomposeLayout { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight * HeightPercentage / 100
layout(layoutWidth, layoutHeight) {
val topPlaceables = subcompose(MangaListingBottomSheetLayoutContent.Top, top).map {
it.measure(constraints)
}
val topPlaceableHeight = topPlaceables.maxOfOrNull { it.height } ?: 0
val bottomConstraints = constraints.copy(
maxHeight = layoutHeight - topPlaceableHeight
)
val bottomPlaceables = subcompose(MangaListingBottomSheetLayoutContent.Bottom, bottom).map {
it.measure(bottomConstraints)
}
val fabPlaceables = subcompose(MangaListingBottomSheetLayoutContent.Fab, floatingActionButton).mapNotNull {
it.measure(constraints).takeIf { it.height != 0 && it.width != 0 }
}
topPlaceables.forEach { it.place(0, 0) }
bottomPlaceables.forEach { it.place(0, topPlaceableHeight) }
if (fabPlaceables.isNotEmpty()) {
val fabWidth = fabPlaceables.maxOf { it.width }
val fabHeight = fabPlaceables.maxOf { it.height }
fabPlaceables.forEach {
it.place(
layoutWidth - fabWidth - FabSpacing.roundToPx(),
topPlaceableHeight - fabHeight / 2
)
}
}
}
}
}
@Composable
fun MangaListingBottomSheet(
mangaListing: MangaListing? = null,
onOpenItem: (String) -> Unit = { }
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
mangaListing?.run {
MangaListingBottomSheetLayout(
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("첫화보기") },
onClick = { entries.lastOrNull()?.let { onOpenItem(it.itemID) } }
)
},
top = {
Row(
modifier = Modifier
.height(IntrinsicSize.Min)
.background(MaterialTheme.colors.primary)
.padding(0.dp, 0.dp, 0.dp, 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val painter = rememberImagePainter(thumbnail)
Image(
modifier = Modifier
.width(150.dp)
.aspectRatio(
with(painter.intrinsicSize) { if (this == androidx.compose.ui.geometry.Size.Unspecified) 1f else width / height },
true
),
painter = painter,
contentDescription = null
)
Column(
modifier = Modifier
.weight(1f)
.padding(0.dp, 8.dp)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
title,
style = MaterialTheme.typography.h5,
modifier = Modifier.weight(1f)
)
CompositionLocalProvider(LocalContentAlpha provides 0.7f) {
Text("작가: $author")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("분류: ")
CompositionLocalProvider(LocalContentAlpha provides 1f) {
FlowRow(
modifier = Modifier.weight(1f),
mainAxisSpacing = 8.dp
) {
tags.forEach {
Card(
elevation = 4.dp
) {
Text(
it,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(4.dp)
)
}
}
}
}
}
Text("발행구분: $type")
}
}
}
},
bottom = {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
) {
items(entries) { entry ->
Row(
modifier = Modifier
.clickable {
onOpenItem(entry.itemID)
}
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
entry.title,
style = MaterialTheme.typography.h6,
modifier = Modifier.weight(1f)
)
Text("${entry.starRating}")
}
Divider()
}
}
}
)
} ?: run {
CircularProgressIndicator(
Modifier.align(Alignment.Center).navigationBarsPadding().padding(16.dp)
)
}
}
}

View File

@@ -0,0 +1,79 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.manatoki.composable
import android.os.Parcelable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class Thumbnail(
val itemID: String,
val title: String,
val thumbnail: String
): Parcelable
@Composable
fun Thumbnail(
thumbnail: Thumbnail,
onClick: (String) -> Unit = { }
) {
Card(
shape = RoundedCornerShape(12.dp),
elevation = 8.dp,
modifier = Modifier.clickable { onClick(thumbnail.itemID) }
) {
Box(
modifier = Modifier.width(IntrinsicSize.Min)
) {
Image(
modifier = Modifier.size(180.dp, 210.dp),
painter = rememberImagePainter(thumbnail.thumbnail),
contentDescription = null
)
Text(
thumbnail.title,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.7f))
.padding(8.dp),
softWrap = true,
style = MaterialTheme.typography.subtitle1
)
}
}
}

View File

@@ -0,0 +1,183 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.manatoki
import android.os.Parcelable
import com.google.common.util.concurrent.RateLimiter
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import java.util.concurrent.Executors
private val rateLimitCoroutineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val rateLimiter = RateLimiter.create(10.0)
suspend fun waitForRateLimit() {
withContext(rateLimitCoroutineDispatcher) {
rateLimiter.acquire()
}
yield()
}
@Parcelize
@Serializable
data class MangaListingEntry(
val itemID: String,
val episode: Int,
val title: String,
val starRating: Float,
val date: String,
val viewCount: Int,
val thumbsUpCount: Int
): Parcelable
@Parcelize
@Serializable
data class MangaListing(
val itemID: String,
val title: String,
val thumbnail: String,
val author: String,
val tags: List<String>,
val type: String,
val thumbsUpCount: Int,
val entries: List<MangaListingEntry>
): Parcelable
@Parcelize
@Serializable
data class ReaderInfo(
val itemID: String,
val title: String,
val urls: List<String>
): Parcelable
suspend fun HttpClient.getItem(
itemID: String,
onListing: (MangaListing) -> Unit,
onReader: (ReaderInfo) -> Unit
) = coroutineScope {
waitForRateLimit()
val content: String = get("https://manatoki116.net/comic/$itemID")
val doc = Jsoup.parse(content)
yield()
if (doc.getElementsByClass("serial-list").size == 0) {
val htmlData = doc
.selectFirst(".view-padding > script")!!
.data()
.splitToSequence('\n')
.fold(StringBuilder()) { sb, line ->
if (!line.startsWith("html_data")) return@fold sb
line.drop(12).dropLast(2).split('.').forEach {
if (it.isNotBlank()) sb.appendCodePoint(it.toInt(16))
}
sb
}.toString()
val urls = Jsoup.parse(htmlData)
.select("img[^data-]:not([style])")
.map {
it.attributes()
.first { it.key.startsWith("data-") }
.value
}
val title = doc.getElementsByClass("toon-title").first()!!.ownText()
onReader(ReaderInfo(itemID, title, urls))
} else {
val titleBlock = doc.selectFirst("div.view-title")!!
val title = titleBlock.select("div.view-content:not([itemprop])").first()!!.text()
val author =
titleBlock
.select("div.view-content:not([itemprop]):contains(작가)")
.first()!!
.getElementsByTag("a")
.first()!!
.text()
val tags =
titleBlock
.select("div.view-content:not([itemprop]):contains(분류)")
.first()!!
.getElementsByTag("a")
.map { it.text() }
val type =
titleBlock
.select("div.view-content:not([itemprop]):contains(발행구분)")
.first()!!
.getElementsByTag("a")
.first()!!
.text()
val thumbnail =
titleBlock.getElementsByTag("img").first()!!.attr("src")
val thumbsUpCount =
titleBlock.select("i.fa-thumbs-up + b").text().toInt()
val entries =
doc.select("div.serial-list .list-item").map {
val episode = it.getElementsByClass("wr-num").first()!!.text().toInt()
val (itemID, title) = it.getElementsByClass("item-subject").first()!!.let { subject ->
subject.attr("href").dropLastWhile { it != '?' }.dropLast(1).takeLastWhile { it != '/' } to subject.ownText()
}
val starRating = it.getElementsByClass("wr-star").first()!!.text().drop(1).takeWhile { it != ')' }.toFloat()
val date = it.getElementsByClass("wr-date").first()!!.text()
val viewCount = it.getElementsByClass("wr-hit").first()!!.text().replace(",", "").toInt()
val thumbsUpCount = it.getElementsByClass("wr-good").first()!!.text().replace(",", "").toInt()
MangaListingEntry(
itemID,
episode,
title,
starRating,
date,
viewCount,
thumbsUpCount
)
}
onListing(
MangaListing(
itemID,
title,
thumbnail,
author,
tags,
type,
thumbsUpCount,
entries
)
)
}
}

View File

@@ -0,0 +1,116 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.manatoki.viewmodel
import android.app.Application
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.pupil.sources.manatoki.composable.Thumbnail
import xyz.quaver.pupil.sources.manatoki.waitForRateLimit
@Serializable
data class TopWeekly(
val itemID: String,
val title: String,
val count: String
)
class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI(app)
private val logger = newLogger(LoggerFactory.default)
private val client: HttpClient by instance()
val recentUpload = mutableStateListOf<Thumbnail>()
val mangaList = mutableStateListOf<Thumbnail>()
val topWeekly = mutableStateListOf<TopWeekly>()
private var loadJob: Job? = null
fun load() {
viewModelScope.launch {
loadJob?.cancelAndJoin()
recentUpload.clear()
mangaList.clear()
topWeekly.clear()
loadJob = launch {
runCatching {
waitForRateLimit()
val doc = Jsoup.parse(client.get("https://manatoki116.net/"))
yield()
val misoPostGallery = doc.select(".miso-post-gallery")
misoPostGallery[0]
.select(".post-image > a")
.forEach { entry ->
val itemID = entry.attr("href").takeLastWhile { it != '/' }
val title = entry.selectFirst("div.in-subject > b")!!.ownText()
val thumbnail = entry.selectFirst("img")!!.attr("src")
yield()
recentUpload.add(Thumbnail(itemID, title, thumbnail))
}
misoPostGallery[1]
.select(".post-image > a").also { logger.info { it.size.toString() } }
.forEach { entry ->
val itemID = entry.attr("href").takeLastWhile { it != '/' }
val title = entry.selectFirst("div.in-subject")!!.ownText()
val thumbnail = entry.selectFirst("img")!!.attr("src")
yield()
mangaList.add(Thumbnail(itemID, title, thumbnail))
}
val misoPostList = doc.select(".miso-post-list")
misoPostList[4]
.select(".post-row > a")
.forEach { entry ->
yield()
val itemID = entry.attr("href").takeLastWhile { it != '/' }
val title = entry.ownText()
val count = entry.selectFirst("span.count")!!.text()
topWeekly.add(TopWeekly(itemID, title, count))
}
}.onFailure {
logger.warning(it)
}
}
}
}
}

View File

@@ -22,10 +22,16 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.Color
import androidx.core.view.WindowCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
@@ -53,9 +59,34 @@ class MainActivity : ComponentActivity(), DIAware {
ProvideWindowInsets {
val navController = rememberNavController()
NavHost(navController, startDestination = "hitomi.la") {
val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight
SideEffect {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
}
NavHost(navController, startDestination = "main") {
composable("main") {
var launched by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (!launched) {
val source = it.arguments?.getString("source") ?: "hitomi.la"
navController.navigate(source)
launched = true
} else {
onBackPressed()
}
}
}
sources.forEach {
it.second.run { navGraph(navController) }
it.second.run {
navGraph(navController)
}
}
}
}

View File

@@ -18,6 +18,9 @@
package xyz.quaver.pupil.util
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.BitmapFactory
import android.view.View
import androidx.compose.runtime.Composable
@@ -82,3 +85,15 @@ fun rememberFileXImageSource(file: FileX) = remember {
fun sha256(data: ByteArray) : ByteArray {
return MessageDigest.getInstance("SHA-256").digest(data)
}
val Context.activity: Activity?
get() {
var currentContext = this
while (currentContext is ContextWrapper) {
if (currentContext is Activity)
return currentContext
currentContext = currentContext.baseContext
}
return null
}