diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c6b739..f7b8733 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { // Compose UI implementation ("de.mr-pine.utils:zoomables:1.4.0") implementation ("androidx.compose.material3:material3:1.2.1") + implementation ("androidx.compose.material:material:1.6.5") implementation ("androidx.compose.material:material-icons-extended:$composeVersion") implementation ("androidx.navigation:navigation-compose:2.7.7") implementation ("com.google.accompanist:accompanist-systemuicontroller:0.30.1") diff --git a/app/src/main/java/live/shirabox/shirabox/db/Converters.kt b/app/src/main/java/live/shirabox/shirabox/db/Converters.kt index 440ea32..5791508 100644 --- a/app/src/main/java/live/shirabox/shirabox/db/Converters.kt +++ b/app/src/main/java/live/shirabox/shirabox/db/Converters.kt @@ -3,6 +3,7 @@ package live.shirabox.shirabox.db import androidx.room.TypeConverter import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import live.shirabox.core.model.ActingTeam import live.shirabox.core.model.ContentType import live.shirabox.core.model.Quality @@ -69,4 +70,14 @@ class Converters { return Json.decodeFromString(videoMarkers) } + @TypeConverter + fun encodeActingTeam(actingTeam: ActingTeam): String { + return Json.encodeToString(actingTeam) + } + + @TypeConverter + fun decodeActingTeam(actingTeam: String): ActingTeam { + return Json.decodeFromString(actingTeam) + } + } \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/db/dao/ContentDao.kt b/app/src/main/java/live/shirabox/shirabox/db/dao/ContentDao.kt index 13e53a5..35cfda7 100644 --- a/app/src/main/java/live/shirabox/shirabox/db/dao/ContentDao.kt +++ b/app/src/main/java/live/shirabox/shirabox/db/dao/ContentDao.kt @@ -9,20 +9,20 @@ import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow import live.shirabox.core.entity.ContentEntity -import live.shirabox.core.entity.relation.CollectedContent +import live.shirabox.core.entity.relation.CombinedContent @Dao interface ContentDao { @Transaction @Query("SELECT * FROM content") - fun allCollectedContent(): Flow> + fun allCombinedContent(): Flow> @Query("SELECT * FROM content WHERE favourite IS 1") fun getFavourites(): Flow> @Transaction @Query("SELECT * FROM content WHERE shikimori_id IS :shikimoriId") - fun collectedContent(shikimoriId: Int): CollectedContent + fun combinedContent(shikimoriId: Int): CombinedContent @Query("SELECT * FROM content WHERE shikimori_id IS :shikimoriId") fun getContent(shikimoriId: Int): ContentEntity? diff --git a/app/src/main/java/live/shirabox/shirabox/db/dao/EpisodeDao.kt b/app/src/main/java/live/shirabox/shirabox/db/dao/EpisodeDao.kt index 59b86f1..fbbc80f 100644 --- a/app/src/main/java/live/shirabox/shirabox/db/dao/EpisodeDao.kt +++ b/app/src/main/java/live/shirabox/shirabox/db/dao/EpisodeDao.kt @@ -8,6 +8,7 @@ import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow import live.shirabox.core.entity.EpisodeEntity +import live.shirabox.core.model.ActingTeam @Dao interface EpisodeDao { @@ -17,8 +18,11 @@ interface EpisodeDao { @Query("SELECT * FROM episode WHERE content_uid = :contentUid AND episode = :episode LIMIT 1") fun getEpisodeByParentAndEpisode(contentUid: Long, episode: Int): EpisodeEntity - @Query("SELECT * FROM episode WHERE content_uid = :contentUid") - fun getEpisodesByParent(contentUid: Long): Flow> + @Query("SELECT * FROM episode WHERE content_uid = :contentUid AND acting_team = :actingTeam AND episode = :episode LIMIT 1") + fun getEpisode(contentUid: Long, episode: Int, actingTeam: ActingTeam): EpisodeEntity + + @Query("SELECT * FROM episode WHERE content_uid = :contentUid AND acting_team = :actingTeam") + fun getEpisodes(contentUid: Long, actingTeam: ActingTeam): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertEpisodes(vararg episodeEntity: EpisodeEntity) diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerActivity.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerActivity.kt index 785a872..d857b70 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerActivity.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerActivity.kt @@ -51,6 +51,7 @@ class PlayerActivity : ComponentActivity() { contentUid = arguments!!.getLong("content_uid"), contentName = arguments.getString("name").toString(), contentEnName = arguments.getString("en_name").toString(), + actingTeam = Json.decodeFromString(arguments.getString("acting_team") ?: ""), episode = arguments.getInt("episode"), startIndex = arguments.getInt("start_index"), playlist = Json.decodeFromString( diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerControls.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerControls.kt index 4920d80..4a7cc50 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerControls.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerControls.kt @@ -89,7 +89,7 @@ fun ControlsScaffold(exoPlayer: ExoPlayer, model: PlayerViewModel) { var currentPosition by remember { mutableLongStateOf(exoPlayer.currentPosition) } var totalDuration by remember { mutableLongStateOf(exoPlayer.duration) } var playbackState by remember { mutableIntStateOf(exoPlayer.playbackState) } - var hasNextMediaItem by remember { mutableStateOf(exoPlayer.hasNextMediaItem()) } + var hasNextMediaItem by remember { mutableStateOf(model.playlist.lastIndex != exoPlayer.currentMediaItemIndex) } var hasPreviousMediaItem by remember { mutableStateOf(exoPlayer.hasPreviousMediaItem()) } var currentMediaItemIndex by remember { mutableIntStateOf(exoPlayer.currentMediaItemIndex) } @@ -147,11 +147,11 @@ fun ControlsScaffold(exoPlayer: ExoPlayer, model: PlayerViewModel) { totalDuration = exoPlayer.duration currentPosition = exoPlayer.contentPosition playbackState = exoPlayer.playbackState - hasNextMediaItem = exoPlayer.hasNextMediaItem() + hasNextMediaItem = model.playlist.lastIndex != exoPlayer.currentMediaItemIndex hasPreviousMediaItem = exoPlayer.hasPreviousMediaItem() - currentMediaItemIndex = exoPlayer.currentMediaItemIndex + 1 + currentMediaItemIndex = exoPlayer.currentMediaItemIndex.inc() - delay(400) + delay(200) } } diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerViewModel.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerViewModel.kt index e7c15f5..7f5e8a3 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerViewModel.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/player/PlayerViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import live.shirabox.core.datastore.AppDataStore import live.shirabox.core.datastore.DataStoreScheme +import live.shirabox.core.model.ActingTeam import live.shirabox.core.model.PlaylistVideo import live.shirabox.data.animeskip.AnimeSkipRepository import live.shirabox.shirabox.db.AppDatabase @@ -26,6 +27,7 @@ class PlayerViewModel( val contentUid: Long, val contentName: String, val contentEnName: String, + val actingTeam: ActingTeam, val episode: Int, val startIndex: Int, val playlist: List @@ -43,7 +45,7 @@ class PlayerViewModel( fun saveEpisodePosition(episode: Int, time: Long) { viewModelScope.launch(Dispatchers.IO) { val episodeEntity = - db?.episodeDao()?.getEpisodeByParentAndEpisode(contentUid, episode) + db?.episodeDao()?.getEpisode(contentUid, episode, actingTeam) episodeEntity?.let { db?.episodeDao()?.updateEpisodes(it.copy(watchingTime = time)) } } @@ -51,7 +53,7 @@ class PlayerViewModel( fun fetchEpisodePositions() { viewModelScope.launch(Dispatchers.IO) { - db?.episodeDao()?.getEpisodesByParent(contentUid)?.collect { entityList -> + db?.episodeDao()?.getEpisodes(contentUid, actingTeam)?.collect { entityList -> episodesPositions.putAll(entityList.associate { it.episode to it.watchingTime }) diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt index ea625b3..b83afa7 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt @@ -29,6 +29,7 @@ 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.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.Favorite @@ -38,6 +39,9 @@ import androidx.compose.material.icons.outlined.LiveTv import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MovieCreation import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -130,7 +134,9 @@ class ResourceActivity : ComponentActivity() { } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, + ExperimentalMaterialApi::class +) @Composable fun Resource( id: Int, @@ -149,6 +155,7 @@ fun Resource( } } val isFavourite = model.isFavourite.value + val isRefreshing = model.isRefreshing.value val isReady = remember(content) { content != null @@ -158,6 +165,13 @@ fun Resource( mutableStateOf(false) } + val pullRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { + model.content.value?.let { model.refresh(it) } + } + ) + LaunchedEffect(Unit) { model.fetchContent(id) model.fetchRelated(id) @@ -205,6 +219,7 @@ fun Resource( Column( modifier = Modifier .fillMaxSize() + .pullRefresh(pullRefreshState) .verticalScroll(rememberScrollState()), ) { Box { @@ -533,6 +548,16 @@ fun Resource( Spacer(Modifier.height(56.dp)) } + Box( + modifier = Modifier.padding(64.dp).fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState + ) + } + ResourceBottomSheet( content = content, model = model, @@ -584,6 +609,7 @@ fun CommentComponent(username: String, avatar: String, timestamp: String, text: supportingContent = { Text(text) }, coverImage = avatar, clickable = false, + headlineText = username, trailingIcon = null, ) } diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt index 9bedb75..b7c1a67 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt @@ -1,5 +1,6 @@ package live.shirabox.shirabox.ui.activity.resource +import android.content.Context import android.content.Intent import android.text.format.DateUtils import androidx.compose.animation.fadeIn @@ -8,15 +9,23 @@ 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 +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -35,17 +44,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import live.shirabox.core.entity.EpisodeEntity +import live.shirabox.core.model.ActingTeam import live.shirabox.core.model.Content import live.shirabox.core.model.ContentType import live.shirabox.core.model.PlaylistVideo @@ -73,14 +85,16 @@ fun ResourceBottomSheet( } } - val sortedEpisodesMap: Map> = remember(episodes) { + val sortedEpisodesMap: Map>> = remember(episodes) { episodes.groupBy { it.source } .mapKeys { map -> model.repositories.find { it.name == map.key } } .mapValues { entry -> entry.value.sortedByDescending { it.episode } - } + }.map { entry -> + entry.key to entry.value.groupBy { it.actingTeam } + }.toMap() } LaunchedEffect(Unit) { @@ -98,14 +112,19 @@ fun ResourceBottomSheet( visibilityState = visibilityState ) - is ResourceSheetScreen.Episodes -> EpisodesSheetScreen( - content = (currentSheetScreenState.value as ResourceSheetScreen.Episodes).content, - episodes = sortedEpisodesMap[(currentSheetScreenState.value as ResourceSheetScreen.Episodes).source] - ?: emptyList(), - model = model, - currentSheetScreenState = currentSheetScreenState, - visibilityState = visibilityState - ) + is ResourceSheetScreen.Episodes -> { + val instance = currentSheetScreenState.value as ResourceSheetScreen.Episodes + + EpisodesSheetScreen( + content = (currentSheetScreenState.value as ResourceSheetScreen.Episodes).content, + episodes = sortedEpisodesMap[instance.repository]?.get(instance.team) + ?: emptyList(), + model = model, + team = instance.team, + currentSheetScreenState = currentSheetScreenState, + visibilityState = visibilityState + ) + } } } } @@ -115,7 +134,7 @@ fun ResourceBottomSheet( fun SourcesSheetScreen( content: Content, model: ResourceViewModel, - episodes: Map>, + episodes: Map>>, currentSheetScreenState: MutableState, visibilityState: MutableState, ) { @@ -180,40 +199,45 @@ fun SourcesSheetScreen( ) { LazyColumn { episodes.forEach { data -> - val source = data.key - val entityList = data.value + val repository = data.key + val actingTeams = data.value.entries.sortedByDescending { + model.pinnedTeams.contains(it.key.name) + } - source?.let { - item { - val context = LocalContext.current + repository?.let { + if(actingTeams.isEmpty()) return@let - val isPinned by remember(model.pinnedSources) { - derivedStateOf { model.pinnedSources.contains(source.name) } - } - val updatedTimestamp = remember { - DateUtils.getRelativeTimeSpanString( - context, - entityList.first().uploadTimestamp * 1000L + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = repository.name, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onBackground.copy(0.7f) + ) + HorizontalDivider( + modifier = Modifier.height(2.dp) ) } + } - ExtendedListItem( - headlineContent = { Text(source.name) }, - supportingContent = { - Text( - "${entityList.size} " + - if (content.type == ContentType.ANIME) "Серий" else "Глав" - ) - }, - overlineContent = { Text("Обновлено $updatedTimestamp") }, - coverImage = source.icon, - trailingIcon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, - onTrailingIconClick = { - model.switchSourcePinStatus(context, content.shikimoriID, source) + actingTeams.forEach { (team, entities) -> + item { + TeamListItem( + model = model, + repository = repository, + content = content, + episodes = entities, + team = team + ) { + currentSheetScreenState.value = + ResourceSheetScreen.Episodes(content, repository, team) } - ) { - currentSheetScreenState.value = - ResourceSheetScreen.Episodes(content, source) } } } @@ -229,6 +253,7 @@ fun SourcesSheetScreen( fun EpisodesSheetScreen( content: Content, episodes: List, + team: ActingTeam, model: ResourceViewModel, currentSheetScreenState: MutableState, visibilityState: MutableState @@ -302,11 +327,66 @@ fun EpisodesSheetScreen( enter = fadeIn() ) { LazyColumn { + val lastViewedEpisode = episodes.firstOrNull { it.watchingTime != -1L } + ?: episodes.reversed().first() + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 0.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.widthIn( + 0.dp, + (LocalConfiguration.current.screenWidthDp / 3).dp + ), + text = team.name, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onBackground.copy(0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + HorizontalDivider( + modifier = Modifier + .height(2.dp) + .fillMaxWidth() + .weight(weight = 1f, fill = false) + ) + Button( + onClick = { + startPlayerActivity( + context = context, + content = content, + episodeEntity = lastViewedEpisode, + episodes = episodes, + team = team + ) + } + ) { + Row ( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ){ + Icon(imageVector = Icons.Rounded.PlayArrow, contentDescription = "Play") + Text( + text = when(lastViewedEpisode.watchingTime) { + -1L -> stringResource(id = R.string.watch) + else -> stringResource(id = R.string.continue_watching) + } + ) + } + } + } + } + items(episodes) { episodeEntity -> val updatedTimestamp = DateUtils.getRelativeTimeSpanString( LocalContext.current, - episodeEntity.uploadTimestamp * 1000L + episodeEntity.uploadTimestamp ) val isViewed = episodeEntity.watchingTime > 0 val textColor = if (isViewed) @@ -334,26 +414,13 @@ fun EpisodesSheetScreen( }, modifier = Modifier.clickable { when (episodeEntity.type) { - ContentType.ANIME -> context.startActivity( - Intent( - context, - PlayerActivity::class.java - ).apply { - val playlist = episodes.map { - PlaylistVideo( - episode = it.episode, - streamUrls = it.videos, - openingMarkers = it.videoMarkers - ) - }.reversed() - - putExtra("content_uid", episodeEntity.contentUid) - putExtra("name", content.name) - putExtra("en_name", content.enName) - putExtra("episode", episodeEntity.episode) - putExtra("start_index", playlist.indexOfFirst { it.episode == episodeEntity.episode}) - putExtra("playlist", Json.encodeToString(playlist)) - }) + ContentType.ANIME -> startPlayerActivity( + context = context, + content = content, + episodeEntity = episodeEntity, + episodes = episodes, + team = team + ) else -> {} } @@ -366,7 +433,70 @@ fun EpisodesSheetScreen( } } +@Composable +private fun TeamListItem( + model: ResourceViewModel, + repository: AbstractContentRepository, + content: Content, + episodes: List, + team: ActingTeam, + onClick: () -> Unit +) { + val context = LocalContext.current + + val isPinned = remember(model.pinnedTeams.size) { + derivedStateOf { model.pinnedTeams.contains(team.name) } + } + val updatedTimestamp = remember { + DateUtils.getRelativeTimeSpanString( + context, + episodes.first().uploadTimestamp + ) + } + + ExtendedListItem( + headlineContent = { Text(team.name) }, + supportingContent = { + Text( + "${episodes.size} Серий" + ) + }, + overlineContent = { Text("Обновлено $updatedTimestamp") }, + coverImage = team.logoUrl, + trailingIcon = if (isPinned.value) Icons.Filled.PushPin else Icons.Outlined.PushPin, + headlineText = team.name, + onTrailingIconClick = { + model.switchTeamPinStatus(context, content.shikimoriID, repository, team) + }, + onClick = onClick + ) +} + +private fun startPlayerActivity(context: Context, content: Content, episodeEntity: EpisodeEntity, episodes: List, team: ActingTeam) { + context.startActivity( + Intent( + context, + PlayerActivity::class.java + ).apply { + val playlist = episodes.map { + PlaylistVideo( + episode = it.episode, + streamUrls = it.videos, + openingMarkers = it.videoMarkers + ) + }.reversed() + + putExtra("content_uid", episodeEntity.contentUid) + putExtra("name", content.name) + putExtra("en_name", content.enName) + putExtra("acting_team", Json.encodeToString(team)) + putExtra("episode", episodeEntity.episode) + putExtra("start_index", playlist.indexOfFirst { it.episode == episodeEntity.episode}) + putExtra("playlist", Json.encodeToString(playlist)) + }) +} + sealed class ResourceSheetScreen { data class Sources(val model: ResourceViewModel) : ResourceSheetScreen() - data class Episodes(val content: Content, val source: AbstractContentRepository) : ResourceSheetScreen() + data class Episodes(val content: Content, val repository: AbstractContentRepository, val team: ActingTeam) : ResourceSheetScreen() } \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt index 20a1158..69e5a0d 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt @@ -10,8 +10,10 @@ import com.google.firebase.ktx.Firebase import com.google.firebase.messaging.ktx.messaging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.firstOrNull @@ -20,14 +22,15 @@ import kotlinx.coroutines.launch import live.shirabox.core.datastore.AppDataStore import live.shirabox.core.datastore.DataStoreScheme import live.shirabox.core.entity.EpisodeEntity +import live.shirabox.core.model.ActingTeam import live.shirabox.core.model.Content import live.shirabox.core.model.ContentType import live.shirabox.core.util.Util import live.shirabox.core.util.Util.Companion.mapContentToEntity import live.shirabox.core.util.Util.Companion.mapEntityToContent -import live.shirabox.data.DataSources import live.shirabox.data.catalog.shikimori.ShikimoriRepository import live.shirabox.data.content.AbstractContentRepository +import live.shirabox.data.content.ContentRepositoryRegistry import live.shirabox.shirabox.db.AppDatabase class ResourceViewModel(context: Context, private val contentType: ContentType) : ViewModel() { @@ -39,25 +42,27 @@ class ResourceViewModel(context: Context, private val contentType: ContentType) val internalContentUid = mutableLongStateOf(0) val isFavourite = mutableStateOf(false) - val pinnedSources = mutableStateListOf() + val pinnedTeams = mutableStateListOf() val episodeFetchComplete = mutableStateOf(false) + val isRefreshing = mutableStateOf(false) val contentObservationException = mutableStateOf(null) - val repositories = DataSources.contentSources.filter { it.contentType == contentType } + val repositories = + ContentRepositoryRegistry.REPOSITORIES.filter { it.contentType == contentType } - fun fetchContent(shikimoriId: Int) { + fun fetchContent(shikimoriId: Int, forceRefresh: Boolean = false) { viewModelScope.launch(Dispatchers.IO) { db?.let { database -> - val pickedData = database.contentDao().getContent(shikimoriId) + val cachedData = database.contentDao().getContent(shikimoriId) - pickedData?.let { + cachedData?.let { content.value = mapEntityToContent(it) isFavourite.value = it.isFavourite internalContentUid.longValue = it.uid - pinnedSources.addAll(it.pinnedSources) + pinnedTeams.addAll(it.pinnedTeams) - return@launch + if(!forceRefresh) return@launch } ShikimoriRepository.fetchContent(shikimoriId, contentType).catch { @@ -65,25 +70,40 @@ class ResourceViewModel(context: Context, private val contentType: ContentType) it.printStackTrace() emitAll(emptyFlow()) }.collect { - val newUid = database.contentDao().insertContents( - mapContentToEntity( - content = it, - isFavourite = false, - lastViewTimestamp = System.currentTimeMillis(), - pinnedSources = emptyList() - ) - ).first() + when(cachedData){ + null -> { + val newUid = database.contentDao().insertContents( + mapContentToEntity( + content = it, + isFavourite = false, + lastViewTimestamp = System.currentTimeMillis(), + pinnedTeams = emptyList() + ) + ).first() - internalContentUid.longValue = newUid - content.value = it + internalContentUid.longValue = newUid + content.value = it + } + else -> { + database.contentDao().updateContents( + mapContentToEntity( + contentUid = cachedData.uid, + content = it, + isFavourite = cachedData.isFavourite, + lastViewTimestamp = cachedData.lastViewTimestamp, + pinnedTeams = cachedData.pinnedTeams + ) + ) + } + } } } } } - fun fetchRelated(id: Int) { + fun fetchRelated(shikimoriID: Int) { viewModelScope.launch(Dispatchers.IO) { - ShikimoriRepository.fetchRelated(id, contentType).catch { + ShikimoriRepository.fetchRelated(shikimoriID, contentType).catch { it.printStackTrace() emitAll(emptyFlow()) }.collect { contents -> @@ -96,37 +116,53 @@ class ResourceViewModel(context: Context, private val contentType: ContentType) Flow> = db?.episodeDao()?.all() ?: emptyFlow() fun fetchEpisodes(content: Content) { - viewModelScope.launch(Dispatchers.IO) { - val finishedDeferred = async { - db?.contentDao()?.collectedContent(content.shikimoriID)?.let { collectedContent -> - repositories.forEach { source -> - source.searchEpisodes(content).collect { list -> - list.mapIndexed { index, episodeEntity -> - val matchingEpisode = collectedContent.episodes.getOrNull(index) - - /** - * Keep local data (e.g. watching time and id's) - */ - - episodeEntity.copy( - uid = matchingEpisode?.uid, - contentUid = collectedContent.content.uid, - watchingTime = matchingEpisode?.watchingTime ?: -1L, - readingPage = matchingEpisode?.readingPage ?: -1 - ) - }.toTypedArray().let { entities -> - db.episodeDao().insertEpisodes(*entities) - } + val finishedDeferred = viewModelScope.async(Dispatchers.IO) { + val combinedContent = db?.contentDao()?.combinedContent(content.shikimoriID) + + combinedContent?.let { + repositories.forEach { repository -> + val cachedEpisodes = combinedContent.episodes + val completeSearchRequired = cachedEpisodes.none { it.source == repository.name } + + async { + when (completeSearchRequired) { + true -> completeEpisodesSearch( + repository = repository, + content = content, + contentUid = combinedContent.content.uid, + cachedEpisodes = cachedEpisodes + ) + + false -> partialEpisodesSearch( + repository = repository, + content = content, + contentUid = combinedContent.content.uid, + cachedEpisodes = cachedEpisodes, + range = cachedEpisodes.last().episode.inc()..Int.MAX_VALUE + ) } - } + }.await() } - true } + true + } + viewModelScope.launch(Dispatchers.IO) { episodeFetchComplete.value = finishedDeferred.await() } } + fun refresh(content: Content) { + viewModelScope.launch(Dispatchers.IO) { + isRefreshing.value = true + fetchContent(content.shikimoriID, true) + fetchRelated(content.shikimoriID) + fetchEpisodes(content) + delay(2000L) + isRefreshing.value = false + } + } + fun switchFavouriteStatus(id: Int) { viewModelScope.launch(Dispatchers.IO) { isFavourite.value = !isFavourite.value @@ -138,7 +174,12 @@ class ResourceViewModel(context: Context, private val contentType: ContentType) } } - fun switchSourcePinStatus(context: Context, id: Int, repository: AbstractContentRepository) { + fun switchTeamPinStatus( + context: Context, + id: Int, + repository: AbstractContentRepository, + team: ActingTeam + ) { viewModelScope.launch(Dispatchers.IO) { val content = db?.contentDao()?.getContent(id) val subscriptionAllowed = @@ -148,22 +189,25 @@ class ResourceViewModel(context: Context, private val contentType: ContentType) content?.let { entity -> val contentTopic = Util.encodeTopic( repository = repository.name, - actingTeam = repository.name, + actingTeam = team.name, contentEnName = entity.enName ) - when (pinnedSources.contains(repository.name)) { + when (pinnedTeams.contains(team.name)) { true -> { - pinnedSources.remove(repository.name) - if(subscriptionAllowed) Firebase.messaging.unsubscribeFromTopic(contentTopic) + pinnedTeams.remove(team.name) + if (subscriptionAllowed) Firebase.messaging.unsubscribeFromTopic( + contentTopic + ) } + else -> { - pinnedSources.add(repository.name) - if(subscriptionAllowed) Firebase.messaging.subscribeToTopic(contentTopic) + pinnedTeams.add(team.name) + if (subscriptionAllowed) Firebase.messaging.subscribeToTopic(contentTopic) } } - db?.contentDao()?.updateContents(entity.copy(pinnedSources = pinnedSources)) + db?.contentDao()?.updateContents(entity.copy(pinnedTeams = pinnedTeams)) } } } @@ -180,4 +224,58 @@ class ResourceViewModel(context: Context, private val contentType: ContentType) } } } + + private suspend fun completeEpisodesSearch( + repository: AbstractContentRepository, + content: Content, + contentUid: Long, + cachedEpisodes: List + ) { + repository.searchEpisodes(content).catch { + it.printStackTrace() + emitAll(emptyFlow()) + }.collectLatest { + cacheEpisodes(it, cachedEpisodes, contentUid) + } + } + + private suspend fun partialEpisodesSearch( + repository: AbstractContentRepository, + content: Content, + contentUid: Long, + cachedEpisodes: List, + range: IntRange + ) { + repository.searchEpisodesInRange(content, range).catch { + it.printStackTrace() + emitAll(emptyFlow()) + }.collectLatest { + cacheEpisodes(it, cachedEpisodes, contentUid) + } + } + + private fun cacheEpisodes(episodes: List, cachedEpisodes: List, contentUid: Long) { + episodes.map { episodeEntity -> + + /** + * Keep local data (e.g. watching time and id's) + */ + + when ( + val matchingEpisode = cachedEpisodes.firstOrNull { it.episode == episodeEntity.episode } + ) { + null -> episodeEntity.copy(uid = null, contentUid = contentUid) + else -> { + episodeEntity.copy( + uid = matchingEpisode.uid, + contentUid = contentUid, + watchingTime = matchingEpisode.watchingTime, + readingPage = matchingEpisode.readingPage + ) + } + } + }.let { entities -> + db?.episodeDao()?.insertEpisodes(*entities.toTypedArray()) + } + } } \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/general/ListItem.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/general/ListItem.kt index 6f07166..1c164c6 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/component/general/ListItem.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/component/general/ListItem.kt @@ -21,6 +21,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent import coil.request.CachePolicy import coil.request.ImageRequest import live.shirabox.shirabox.R @@ -87,17 +90,21 @@ fun ListItem( @Composable fun ExtendedListItem( + modifier: Modifier = Modifier, headlineContent: @Composable () -> Unit, overlineContent: @Composable () -> Unit = {}, supportingContent: @Composable () -> Unit = {}, coverImage: String? = null, trailingIcon: ImageVector?, clickable: Boolean = true, + headlineText: String, onTrailingIconClick: () -> Unit = {}, onClick: () -> Unit = {} ) { ListItem( - modifier = if (clickable) Modifier.clickable(onClick = onClick) else Modifier, + modifier = if (clickable) Modifier + .clickable(onClick = onClick) + .then(modifier) else Modifier.then(modifier), overlineContent = overlineContent, headlineContent = headlineContent, supportingContent = supportingContent, @@ -114,18 +121,26 @@ fun ExtendedListItem( }, leadingContent = { coverImage?.let { - AsyncImage( + SubcomposeAsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(coverImage) .crossfade(true) .build(), - modifier = Modifier - .height(40.dp) - .width(40.dp) - .clip(RoundedCornerShape(100)), - contentDescription = "Composable Image", - contentScale = ContentScale.Crop - ) + contentScale = ContentScale.Crop, + contentDescription = headlineText + ) { + val state = painter.state + + when(state) { + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent( + modifier = Modifier + .height(40.dp) + .width(40.dp) + .clip(RoundedCornerShape(100)) + ) + else -> Monogram(str = headlineText) + } + } } } ) diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/general/Monogram.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/general/Monogram.kt new file mode 100644 index 0000000..ee76526 --- /dev/null +++ b/app/src/main/java/live/shirabox/shirabox/ui/component/general/Monogram.kt @@ -0,0 +1,48 @@ +package live.shirabox.shirabox.ui.component.general + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun Monogram(modifier: Modifier = Modifier, str: String) { + val background = MaterialTheme.colorScheme.primary + val onPrimary = MaterialTheme.colorScheme.onPrimary + + val text = str.replace(Regex("[a-z]"), "") + .replace(Regex("[а-я]"), "").uppercase().take(2) + + Box( + modifier = Modifier + .size(40.dp) + .drawBehind { + drawCircle( + brush = Brush.linearGradient( + colors = listOf( + background.copy(0.8f), + background.copy(0.45f) + ) + ), + radius = this.size.width.div(2) + ) + } + .then(modifier), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = TextStyle(color = onPrimary, fontSize = 16.sp), + fontWeight = FontWeight.Medium + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/profile/history/HistoryViewModel.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/profile/history/HistoryViewModel.kt index dcfd0aa..c823585 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/screen/profile/history/HistoryViewModel.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/profile/history/HistoryViewModel.kt @@ -11,5 +11,5 @@ class HistoryViewModel(context: Context) : ViewModel() { private val db = AppDatabase.getAppDataBase(context) fun contentsFlow(): Flow> = - db?.contentDao()?.allCollectedContent() ?: emptyFlow() + db?.contentDao()?.allCombinedContent() ?: emptyFlow() } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e45d2b..832a621 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,7 @@ Обсуждение Ой… А где интернет-то? Вернуться + Продолжить