From ef20d4bfc9733258bf40085b7383fab23e16fa1e Mon Sep 17 00:00:00 2001 From: urFate Date: Mon, 15 Apr 2024 22:38:53 +0300 Subject: [PATCH] feat: implement anilib repository 1. By the way change database structure for compatibility with anilib 2. Add "watch" button to the episodes bottom sheet 3. Rename AniLibria repository to just Libria 4. Episodes fetching optimization --- app/build.gradle.kts | 1 + .../live/shirabox/shirabox/db/Converters.kt | 11 + .../shirabox/shirabox/db/dao/ContentDao.kt | 6 +- .../shirabox/shirabox/db/dao/EpisodeDao.kt | 8 +- .../ui/activity/player/PlayerActivity.kt | 1 + .../ui/activity/player/PlayerControls.kt | 8 +- .../ui/activity/player/PlayerViewModel.kt | 6 +- .../ui/activity/resource/ResourceActivity.kt | 28 +- .../activity/resource/ResourceSheetScreens.kt | 252 +++++++++++++----- .../ui/activity/resource/ResourceViewModel.kt | 200 ++++++++++---- .../shirabox/ui/component/general/ListItem.kt | 33 ++- .../shirabox/ui/component/general/Monogram.kt | 48 ++++ .../profile/history/HistoryViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../shirabox/core/entity/ContentEntity.kt | 2 +- .../shirabox/core/entity/EpisodeEntity.kt | 2 + ...CollectedContent.kt => CombinedContent.kt} | 2 +- .../live/shirabox/core/model/ActingTeam.kt | 11 + .../java/live/shirabox/core/model/Quality.kt | 2 +- .../main/java/live/shirabox/core/util/Util.kt | 12 +- .../java/live/shirabox/data/DataSources.kt | 9 - .../data/content/AbstractContentRepository.kt | 2 +- .../data/content/ContentRepositoryRegistry.kt | 10 + .../data/content/anime/animelib/AniLibData.kt | 141 ++++++++++ .../anime/animelib/AniLibRepository.kt | 148 ++++++++++ .../content/anime/libria/LibriaRepository.kt | 34 ++- .../data/content/manga/remanga/RemangaData.kt | 117 -------- .../manga/remanga/RemangaRepository.kt | 74 ----- 28 files changed, 823 insertions(+), 348 deletions(-) create mode 100644 app/src/main/java/live/shirabox/shirabox/ui/component/general/Monogram.kt rename core/src/main/java/live/shirabox/core/entity/relation/{CollectedContent.kt => CombinedContent.kt} (92%) create mode 100644 core/src/main/java/live/shirabox/core/model/ActingTeam.kt delete mode 100644 data/src/main/java/live/shirabox/data/DataSources.kt create mode 100644 data/src/main/java/live/shirabox/data/content/ContentRepositoryRegistry.kt create mode 100644 data/src/main/java/live/shirabox/data/content/anime/animelib/AniLibData.kt create mode 100644 data/src/main/java/live/shirabox/data/content/anime/animelib/AniLibRepository.kt delete mode 100644 data/src/main/java/live/shirabox/data/content/manga/remanga/RemangaData.kt delete mode 100644 data/src/main/java/live/shirabox/data/content/manga/remanga/RemangaRepository.kt 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 @@ Обсуждение Ой… А где интернет-то? Вернуться + Продолжить