feat(anime screen): Next Episode Countdown (#1045)

This commit is contained in:
Quickdev 2023-06-26 13:28:12 +05:30 committed by GitHub
parent 878b0b65a2
commit 424d587cfa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 299 additions and 20 deletions

View file

@ -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<Int, Long>) {
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()
}
}

View file

@ -20,4 +20,5 @@ enum class EntryScreenItem {
DESCRIPTION_WITH_TAG,
ITEM_HEADER,
ITEM,
AIRING_TIME,
}

View file

@ -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),

View file

@ -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,
)
}
}
}
}
}

View file

@ -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,
)

View file

@ -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? {

View file

@ -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
},

View file

@ -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<AnimeTrackItem>, 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<Int, Long> = Pair(anime.nextEpisodeToAir, anime.nextEpisodeAiringAt),
) : AnimeScreenState() {
val processedEpisodes: Sequence<EpisodeItem>
@ -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 ?: ""

View file

@ -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<AnimeTrackItem>, manualFetch: Boolean): Pair<Int, Long> {
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<Int, Long> {
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)
}
}
}

View file

@ -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 = "",