From 424d587cfaa558babf95eb69262cecb4e19b48bb Mon Sep 17 00:00:00 2001 From: Quickdev Date: Mon, 26 Jun 2023 13:28:12 +0530 Subject: [PATCH] feat(anime screen): Next Episode Countdown (#1045) --- .../anime/interactor/SetAnimeViewerFlags.kt | 43 +++++++- .../entries/EntryScreenConstants.kt | 1 + .../presentation/entries/anime/AnimeScreen.kt | 78 ++++++++++++-- .../anime/components/AnimeEpisodeListItem.kt | 39 +++++++ .../data/backup/models/BackupAnime.kt | 2 +- .../data/database/models/anime/Anime.kt | 4 +- .../tachiyomi/ui/entries/anime/AnimeScreen.kt | 4 +- .../ui/entries/anime/AnimeScreenModel.kt | 27 ++++- .../eu/kanade/tachiyomi/util/AniChartApi.kt | 101 ++++++++++++++++++ .../domain/entries/anime/model/Anime.kt | 20 +++- 10 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/AniChartApi.kt diff --git a/app/src/main/java/eu/kanade/domain/entries/anime/interactor/SetAnimeViewerFlags.kt b/app/src/main/java/eu/kanade/domain/entries/anime/interactor/SetAnimeViewerFlags.kt index 65e48db20..2937f867a 100644 --- a/app/src/main/java/eu/kanade/domain/entries/anime/interactor/SetAnimeViewerFlags.kt +++ b/app/src/main/java/eu/kanade/domain/entries/anime/interactor/SetAnimeViewerFlags.kt @@ -1,20 +1,55 @@ package eu.kanade.domain.entries.anime.interactor +import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.entries.anime.model.AnimeUpdate import tachiyomi.domain.entries.anime.repository.AnimeRepository +import kotlin.math.pow class SetAnimeViewerFlags( private val animeRepository: AnimeRepository, ) { - suspend fun awaitSetSkipIntroLength(id: Long, skipIntroLength: Long) { - // TODO: Convert to proper flag format - // val anime = animeRepository.getAnimeById(id) + suspend fun awaitSetSkipIntroLength(id: Long, flag: Long) { + val anime = animeRepository.getAnimeById(id) animeRepository.updateAnime( AnimeUpdate( id = id, - viewerFlags = skipIntroLength, + viewerFlags = anime.viewerFlags.setFlag(flag, Anime.ANIME_INTRO_MASK), ), ) } + + suspend fun awaitSetNextEpisodeAiring(id: Long, flags: Pair) { + awaitSetNextEpisodeToAir(id, flags.first.toLong().addHexZeros(zeros = 2)) + awaitSetNextEpisodeAiringAt(id, flags.second.addHexZeros(zeros = 6)) + } + + private suspend fun awaitSetNextEpisodeToAir(id: Long, flag: Long) { + val anime = animeRepository.getAnimeById(id) + animeRepository.updateAnime( + AnimeUpdate( + id = id, + viewerFlags = anime.viewerFlags.setFlag(flag, Anime.ANIME_AIRING_EPISODE_MASK), + ), + ) + } + + private suspend fun awaitSetNextEpisodeAiringAt(id: Long, flag: Long) { + val anime = animeRepository.getAnimeById(id) + animeRepository.updateAnime( + AnimeUpdate( + id = id, + viewerFlags = anime.viewerFlags.setFlag(flag, Anime.ANIME_AIRING_TIME_MASK), + ), + ) + } + + private fun Long.setFlag(flag: Long, mask: Long): Long { + return this and mask.inv() or (flag and mask) + } + + private fun Long.addHexZeros(zeros: Int): Long { + val hex = 16.0 + return this.times(hex.pow(zeros)).toLong() + } } diff --git a/app/src/main/java/eu/kanade/presentation/entries/EntryScreenConstants.kt b/app/src/main/java/eu/kanade/presentation/entries/EntryScreenConstants.kt index bc226e0e1..dc3fc542d 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/EntryScreenConstants.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/EntryScreenConstants.kt @@ -20,4 +20,5 @@ enum class EntryScreenItem { DESCRIPTION_WITH_TAG, ITEM_HEADER, ITEM, + AIRING_TIME, } diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt index 0cf0e27aa..ada1dadff 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,16 +60,18 @@ import eu.kanade.presentation.entries.anime.components.AnimeEpisodeListItem import eu.kanade.presentation.entries.anime.components.AnimeInfoBox import eu.kanade.presentation.entries.anime.components.EpisodeDownloadAction import eu.kanade.presentation.entries.anime.components.ExpandableAnimeDescription +import eu.kanade.presentation.entries.anime.components.NextEpisodeAiringListItem import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload import eu.kanade.tachiyomi.source.anime.AnimeSourceManager import eu.kanade.tachiyomi.source.anime.getNameForAnimeInfo import eu.kanade.tachiyomi.ui.entries.anime.AnimeScreenState import eu.kanade.tachiyomi.ui.entries.anime.EpisodeItem -import eu.kanade.tachiyomi.ui.entries.manga.chapterDecimalFormat +import eu.kanade.tachiyomi.ui.entries.anime.episodeDecimalFormat import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences import eu.kanade.tachiyomi.util.lang.toRelativeString import eu.kanade.tachiyomi.util.system.copyToClipboard +import kotlinx.coroutines.delay import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.items.episode.model.Episode import tachiyomi.presentation.core.components.LazyColumn @@ -416,6 +419,31 @@ private fun AnimeScreenSmallImpl( ) } + if (state.airingTime > 0L) { + item( + key = EntryScreenItem.AIRING_TIME, + contentType = EntryScreenItem.AIRING_TIME, + ) { + // Handles the second by second countdown + var timer by remember { mutableStateOf(state.airingTime) } + LaunchedEffect(key1 = timer) { + if (timer > 0L) { + delay(1000L) + timer -= 1000L + } + } + if (timer > 0L) { + NextEpisodeAiringListItem( + title = stringResource( + R.string.display_mode_episode, + episodeDecimalFormat.format(state.airingEpisodeNumber), + ), + date = formatTime(state.airingTime, useDayFormat = true), + ) + } + } + } + sharedEpisodeItems( anime = state.anime, episodes = episodes, @@ -633,6 +661,31 @@ fun AnimeScreenLargeImpl( ) } + if (state.airingTime > 0L) { + item( + key = EntryScreenItem.AIRING_TIME, + contentType = EntryScreenItem.AIRING_TIME, + ) { + // Handles the second by second countdown + var timer by remember { mutableStateOf(state.airingTime) } + LaunchedEffect(key1 = timer) { + if (timer > 0L) { + delay(1000L) + timer -= 1000L + } + } + if (timer > 0L) { + NextEpisodeAiringListItem( + title = stringResource( + R.string.display_mode_episode, + episodeDecimalFormat.format(state.airingEpisodeNumber), + ), + date = formatTime(state.airingTime, useDayFormat = true), + ) + } + } + } + sharedEpisodeItems( anime = state.anime, episodes = episodes, @@ -721,8 +774,8 @@ private fun LazyListScope.sharedEpisodeItems( AnimeEpisodeListItem( title = if (anime.displayMode == Anime.EPISODE_DISPLAY_NUMBER) { stringResource( - R.string.display_mode_chapter, - chapterDecimalFormat.format(episodeItem.episode.episodeNumber.toDouble()), + R.string.display_mode_episode, + episodeDecimalFormat.format(episodeItem.episode.episodeNumber.toDouble()), ) } else { episodeItem.episode.name @@ -741,8 +794,8 @@ private fun LazyListScope.sharedEpisodeItems( ?.let { stringResource( R.string.episode_progress, - formatProgress(it), - formatProgress(episodeItem.episode.totalSeconds), + formatTime(it), + formatTime(episodeItem.episode.totalSeconds), ) }, scanlator = episodeItem.episode.scanlator.takeIf { !it.isNullOrBlank() }, @@ -786,8 +839,19 @@ private fun onEpisodeItemClick( } } -private fun formatProgress(milliseconds: Long): String { - return if (milliseconds > 3600000L) { +private fun formatTime(milliseconds: Long, useDayFormat: Boolean = false): String { + return if (useDayFormat) { + String.format( + "Airing in %02dd %02dh %02dm %02ds", + TimeUnit.MILLISECONDS.toDays(milliseconds), + TimeUnit.MILLISECONDS.toHours(milliseconds) - + TimeUnit.DAYS.toHours(TimeUnit.MILLISECONDS.toDays(milliseconds)), + TimeUnit.MILLISECONDS.toMinutes(milliseconds) - + TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(milliseconds)), + TimeUnit.MILLISECONDS.toSeconds(milliseconds) - + TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(milliseconds)), + ) + } else if (milliseconds > 3600000L) { String.format( "%d:%02d:%02d", TimeUnit.MILLISECONDS.toHours(milliseconds), diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt index 341ac3e2f..2f6fd805e 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeEpisodeListItem.kt @@ -131,3 +131,42 @@ fun AnimeEpisodeListItem( } } } + +@Composable +fun NextEpisodeAiringListItem( + modifier: Modifier = Modifier, + title: String, + date: String, +) { + Row( + modifier = modifier.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableStateOf(0) } + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier.alpha(SecondaryItemAlpha), + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Row(modifier = Modifier.alpha(SecondaryItemAlpha)) { + ProvideTextStyle( + value = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp), + ) { + Text( + text = date, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt index fe914fb44..9623062ee 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt @@ -84,7 +84,7 @@ data class BackupAnime( favorite = anime.favorite, source = anime.source, dateAdded = anime.dateAdded, - viewer_flags = anime.viewerFlags.toInt(), + viewer_flags = anime.skipIntroLength, episodeFlags = anime.episodeFlags.toInt(), updateStrategy = anime.updateStrategy, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/anime/Anime.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/anime/Anime.kt index bd224b130..eacc22ba5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/anime/Anime.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/anime/Anime.kt @@ -27,8 +27,8 @@ interface Anime : SAnime { } var skipIntroLength: Int - get() = viewer_flags and 0x000000FF - set(skipIntro) = setViewerFlags(skipIntro, 0x000000FF) + get() = viewer_flags and DomainAnime.ANIME_INTRO_MASK.toInt() + set(flag) = setViewerFlags(flag, DomainAnime.ANIME_INTRO_MASK.toInt()) } fun Anime.toDomainAnime(): DomainAnime? { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt index 919276303..995f1cc4e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt @@ -401,8 +401,8 @@ fun ChangeIntroLength( onSelectionChanged = { newLength = it + 1 }, - startIndex = if (anime.viewerFlags > 0) { - anime.viewerFlags.toInt() - 1 + startIndex = if (anime.skipIntroLength > 0) { + anime.skipIntroLength - 1 } else { defaultIntroLength }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt index 4faf35039..ae22a3f42 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt @@ -37,6 +37,7 @@ import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.source.anime.AnimeSourceManager import eu.kanade.tachiyomi.ui.entries.anime.track.AnimeTrackItem import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences +import eu.kanade.tachiyomi.util.AniChartApi import eu.kanade.tachiyomi.util.episode.getNextUnseen import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.shouldDownloadNewEpisodes @@ -76,6 +77,9 @@ import tachiyomi.domain.items.episode.service.getEpisodeSort import tachiyomi.domain.track.anime.interactor.GetAnimeTracks import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Calendar class AnimeInfoScreenModel( val context: Context, @@ -179,7 +183,7 @@ class AnimeInfoScreenModel( dialog = null, ) } - // Start observe tracking since it only needs mangaId + // Start observe tracking since it only needs animeId observeTrackers() // Fetch info-episodes when needed @@ -205,6 +209,7 @@ class AnimeInfoScreenModel( ) fetchFromSourceTasks.awaitAll() updateSuccessState { it.copy(isRefreshingData = false) } + successState?.let { updateAiringTime(it.anime, it.trackItems, manualFetch) } } } @@ -896,7 +901,6 @@ class AnimeInfoScreenModel( private fun observeTrackers() { val anime = successState?.anime ?: return - coroutineScope.launchIO { getTracks.subscribe(anime.id) .catch { logcat(LogPriority.ERROR, it) } @@ -911,10 +915,16 @@ class AnimeInfoScreenModel( .distinctUntilChanged() .collectLatest { trackItems -> updateSuccessState { it.copy(trackItems = trackItems) } + updateAiringTime(anime, trackItems, manualFetch = false) } } } + private suspend fun updateAiringTime(anime: Anime, trackItems: List, manualFetch: Boolean) { + val airingEpisode = AniChartApi().loadAiringTime(anime, trackItems, manualFetch) + updateSuccessState { it.copy(nextAiringEpisode = airingEpisode) } + } + // Track sheet - end sealed class Dialog { @@ -1006,6 +1016,7 @@ sealed class AnimeScreenState { val isRefreshingData: Boolean = false, val dialog: AnimeInfoScreenModel.Dialog? = null, val hasPromptedToAddBefore: Boolean = false, + val nextAiringEpisode: Pair = Pair(anime.nextEpisodeToAir, anime.nextEpisodeAiringAt), ) : AnimeScreenState() { val processedEpisodes: Sequence @@ -1017,6 +1028,12 @@ sealed class AnimeScreenState { val trackingCount: Int get() = trackItems.count { it.track != null } + val airingEpisodeNumber: Double + get() = nextAiringEpisode.first.toDouble() + + val airingTime: Long + get() = nextAiringEpisode.second.times(1000L).minus(Calendar.getInstance().timeInMillis) + /** * Applies the view filters to the list of chapters obtained from the database. * @return an observable of the list of chapters filtered and sorted. @@ -1045,6 +1062,12 @@ data class EpisodeItem( val isDownloaded = downloadState == AnimeDownload.State.DOWNLOADED } +val episodeDecimalFormat = DecimalFormat( + "#.###", + DecimalFormatSymbols() + .apply { decimalSeparator = '.' }, +) + private val Throwable.snackbarMessage: String get() = when (val className = this::class.simpleName) { null -> message ?: "" diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/AniChartApi.kt b/app/src/main/java/eu/kanade/tachiyomi/util/AniChartApi.kt new file mode 100644 index 000000000..2605bc231 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/AniChartApi.kt @@ -0,0 +1,101 @@ +package eu.kanade.tachiyomi.util +import eu.kanade.domain.entries.anime.interactor.SetAnimeViewerFlags +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.data.track.anilist.Anilist +import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.jsonMime +import eu.kanade.tachiyomi.ui.entries.anime.track.AnimeTrackItem +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import tachiyomi.core.util.lang.withIOContext +import tachiyomi.domain.entries.anime.model.Anime +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AniChartApi { + private val client = OkHttpClient() + private val setAnimeViewerFlags: SetAnimeViewerFlags = Injekt.get() + + internal suspend fun loadAiringTime(anime: Anime, trackItems: List, manualFetch: Boolean): Pair { + if (anime.status == SAnime.COMPLETED.toLong() && !manualFetch) return Pair(anime.nextEpisodeToAir, anime.nextEpisodeAiringAt) + return withIOContext { + var alId = 0L + var airingTime = Pair(0, 0L) + trackItems.forEach { + if (it.track != null) { + alId = when (it.service) { + is Anilist -> it.track.media_id + is MyAnimeList -> getAlIdFromMal(it.track.media_id) + else -> 0L + } + } + } + if (alId != 0L) { + airingTime = getAiringAt(alId) + setAnimeViewerFlags.awaitSetNextEpisodeAiring(anime.id, airingTime) + } + return@withIOContext airingTime + } + } + + private suspend fun getAlIdFromMal(idMal: Long): Long { + return withIOContext { + val query = """ + query { + Media(idMal:$idMal,type: ANIME) { + id + } + } + """.trimMargin() + + val response = try { + client.newCall( + POST( + "https://graphql.anilist.co", + body = buildJsonObject { put("query", query) }.toString() + .toRequestBody(jsonMime), + ), + ).execute() + } catch (e: Exception) { + return@withIOContext 0L + } + return@withIOContext response.body.string().substringAfter("id\":") + .substringBefore("}") + .toLongOrNull() ?: 0L + } + } + + private suspend fun getAiringAt(id: Long): Pair { + return withIOContext { + val query = """ + query { + Media(id:$id) { + nextAiringEpisode { + episode + airingAt + } + } + } + """.trimMargin() + val response = try { + client.newCall( + POST( + "https://graphql.anilist.co", + body = buildJsonObject { put("query", query) }.toString() + .toRequestBody(jsonMime), + ), + ).execute() + } catch (e: Exception) { + return@withIOContext Pair(0, 0L) + } + val data = response.body.string() + val episodeNumber = data.substringAfter("episode\":").substringBefore(",").toIntOrNull() ?: 0 + val airingAt = data.substringAfter("airingAt\":").substringBefore("}").toLongOrNull() ?: 0L + + return@withIOContext Pair(episodeNumber, airingAt) + } + } +} diff --git a/domain/src/main/java/tachiyomi/domain/entries/anime/model/Anime.kt b/domain/src/main/java/tachiyomi/domain/entries/anime/model/Anime.kt index 10180bf34..51765e3b2 100644 --- a/domain/src/main/java/tachiyomi/domain/entries/anime/model/Anime.kt +++ b/domain/src/main/java/tachiyomi/domain/entries/anime/model/Anime.kt @@ -3,6 +3,7 @@ package tachiyomi.domain.entries.anime.model import eu.kanade.tachiyomi.source.model.UpdateStrategy import tachiyomi.domain.entries.TriStateFilter import java.io.Serializable +import kotlin.math.pow data class Anime( val id: Long, @@ -40,8 +41,14 @@ data class Anime( val bookmarkedFilterRaw: Long get() = episodeFlags and EPISODE_BOOKMARKED_MASK - val skipIntroLength: Long - get() = viewerFlags + val skipIntroLength: Int + get() = (viewerFlags and ANIME_INTRO_MASK).toInt() + + val nextEpisodeToAir: Int + get() = (viewerFlags and ANIME_AIRING_EPISODE_MASK).removeHexZeros(zeros = 2).toInt() + + val nextEpisodeAiringAt: Long + get() = (viewerFlags and ANIME_AIRING_TIME_MASK).removeHexZeros(zeros = 6) val unseenFilter: TriStateFilter get() = when (unseenFilterRaw) { @@ -61,6 +68,11 @@ data class Anime( return episodeFlags and EPISODE_SORT_DIR_MASK == EPISODE_SORT_DESC } + private fun Long.removeHexZeros(zeros: Int): Long { + val hex = 16.0 + return this.div(hex.pow(zeros)).toLong() + } + companion object { // Generic filter that does not filter anything const val SHOW_ALL = 0x00000000L @@ -90,6 +102,10 @@ data class Anime( const val EPISODE_DISPLAY_NUMBER = 0x00100000L const val EPISODE_DISPLAY_MASK = 0x00100000L + const val ANIME_INTRO_MASK = 0x000000000000FFL + const val ANIME_AIRING_EPISODE_MASK = 0x00000000FFFF00L + const val ANIME_AIRING_TIME_MASK = 0xFFFFFFFF000000L + fun create() = Anime( id = -1L, url = "",