From 4fe51a5b892da61b7eb1a51834bcbec9d8c1a7dd Mon Sep 17 00:00:00 2001 From: Secozzi Date: Fri, 24 Jan 2025 23:09:41 +0100 Subject: [PATCH] feat(player): Implement `resolveVideoUrl` --- .../tachiyomi/ui/player/PlayerActivity.kt | 4 +- .../tachiyomi/ui/player/PlayerViewModel.kt | 183 +++++++++++++++--- .../ui/player/controls/PlayerControls.kt | 2 + .../ui/player/controls/PlayerSheets.kt | 2 + .../components/sheets/QualitySheet.kt | 96 ++++++--- .../tachiyomi/animesource/model/Video.kt | 26 +++ .../animesource/online/AnimeHttpSource.kt | 12 ++ 7 files changed, 279 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt index 79bc1ecf0f..62b7d1b645 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt @@ -232,7 +232,9 @@ class PlayerActivity : BaseActivity() { setupPlayerOrientation() Thread.setDefaultUncaughtExceptionHandler { _, throwable -> - toast(throwable.message) + runOnUiThread { + toast(throwable.message) + } logcat(LogPriority.ERROR, throwable) finish() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt index 4b471ea7d8..97521e639b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt @@ -36,7 +36,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.viewModelFactory import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.entries.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.items.episode.model.toDbEpisode @@ -47,9 +46,7 @@ import eu.kanade.presentation.more.settings.screen.player.custombutton.CustomBut import eu.kanade.presentation.more.settings.screen.player.custombutton.getButtons import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.animesource.model.Hoster -import eu.kanade.tachiyomi.animesource.model.SerializableHoster.Companion.serialize import eu.kanade.tachiyomi.animesource.model.SerializableHoster.Companion.toHosterList -import eu.kanade.tachiyomi.animesource.model.SerializableVideo.Companion.toVideoList import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.data.database.models.anime.Episode @@ -265,6 +262,9 @@ class PlayerViewModel @JvmOverloads constructor( val panelShown = MutableStateFlow(Panels.None) val dialogShown = MutableStateFlow(Dialogs.None) + private val _dismissSheet = MutableStateFlow(false) + val dismissSheet = _dismissSheet.asStateFlow() + private val _seekText = MutableStateFlow(null) val seekText = _seekText.asStateFlow() private val _doubleTapSeekAmount = MutableStateFlow(0) @@ -608,9 +608,18 @@ class PlayerViewModel @JvmOverloads constructor( _areControlsLocked.update { false } } + private fun dismissSheet() { + _dismissSheet.update { _ -> true } + } + + private fun resetDismissSheet() { + _dismissSheet.update { _ -> false } + } + fun showSheet(sheet: Sheets) { sheetShown.update { sheet } if (sheet == Sheets.None) { + resetDismissSheet() showControls() } else { hideControls() @@ -1239,7 +1248,12 @@ class PlayerViewModel @JvmOverloads constructor( if (hoster.videoList == null) { HosterState.Loading(hoster.hosterName) } else { - HosterState.Ready(hoster.hosterName, hoster.videoList!!) + val videoList = hoster.videoList!! + HosterState.Ready( + hoster.hosterName, + videoList, + List(videoList.size) { Video.State.LOAD_VIDEO }, + ) } } } @@ -1261,7 +1275,10 @@ class PlayerViewModel @JvmOverloads constructor( if (hosterIdx == hosterIndex) { hosterState.videoList.getOrNull(videoIndex)?.let { hasFoundPreferredVideo.set(true) - loadVideo(it, hosterIndex, videoIndex) + val success = loadVideo(source, it, hosterIndex, videoIndex) + if (!success) { + hasFoundPreferredVideo.set(false) + } } } @@ -1269,7 +1286,11 @@ class PlayerViewModel @JvmOverloads constructor( if (prefIndex != -1 && hosterIndex == -1) { if (hasFoundPreferredVideo.compareAndSet(false, true)) { if (selectedHosterVideoIndex.value == Pair(-1, -1)) { - loadVideo(hosterState.videoList[prefIndex], hosterIdx, prefIndex) + val success = + loadVideo(source, hosterState.videoList[prefIndex], hosterIdx, prefIndex) + if (!success) { + hasFoundPreferredVideo.set(false) + } } } } @@ -1277,16 +1298,10 @@ class PlayerViewModel @JvmOverloads constructor( } if (hasFoundPreferredVideo.compareAndSet(false, true)) { - val firstHosterIndex = _hosterState.value.indexOfFirst { it is HosterState.Ready && it.videoList.isNotEmpty() } - if (firstHosterIndex == -1) { - throw Exception("No available videos.") - } else { - loadVideo( - (_hosterState.value[firstHosterIndex] as HosterState.Ready).videoList.first(), - firstHosterIndex, - 0, - ) - } + val (hosterIdx, videoIdx) = selectBestVideo() + val video = (hosterState.value[hosterIdx] as HosterState.Ready).videoList[videoIdx] + + loadVideo(source, video, hosterIdx, videoIdx) } } } catch (e: CancellationException) { @@ -1299,28 +1314,147 @@ class PlayerViewModel @JvmOverloads constructor( } } + /** + * Check for the best video from the current hosterState. + * + * The first video with the `preferred` attribute is selected, however + * if no such video is selected the first video with a non-empty url is selected. + * If there are no viable videos at all, an error is thrown. + * + * @return the indices of the hoster & video + */ + private fun selectBestVideo(): Pair { + val availableHosters = hosterState.value.withIndex() + .filter { (_, state) -> state is HosterState.Ready } + + // Check for first preferred + val isPreferred: (Pair) -> Boolean = { (v, s) -> + v.preferred && (s == Video.State.READY || s == Video.State.QUEUE) + } + val prefHosterIdx = availableHosters.indexOfFirst { + (it.value as HosterState.Ready).let { hoster -> + hoster.videoList zip hoster.videoState + }.any(isPreferred) + } + if (prefHosterIdx != -1) { + val videoList = (availableHosters[prefHosterIdx].value as HosterState.Ready).let { hoster -> + hoster.videoList zip hoster.videoState + } + val prefVideoIdx = videoList.indexOfFirst(isPreferred) + return availableHosters[prefHosterIdx].index to prefVideoIdx + } + + // Check for first video with non-empty url + val firstValid: (Pair) -> Boolean = { (v, s) -> + v.videoUrl.isNotEmpty() && (s == Video.State.READY || s == Video.State.QUEUE) + } + val firstAvailableHosterIdx = availableHosters.indexOfFirst { + (it.value as HosterState.Ready).let { hoster -> + hoster.videoList zip hoster.videoState + }.any(firstValid) + } + if (firstAvailableHosterIdx != -1) { + val videoList = (availableHosters[firstAvailableHosterIdx].value as HosterState.Ready).let { hoster -> + hoster.videoList zip hoster.videoState + } + val firstVideoIdx = videoList.indexOfFirst(firstValid) + return availableHosters[firstAvailableHosterIdx].index to firstVideoIdx + } + + // No success + throw Exception("No available videos") + } + private suspend fun loadHosterVideos(source: AnimeSource, hoster: Hoster): HosterState { return try { val videos = EpisodeLoader.getVideos(source, hoster) - HosterState.Ready(hoster.hosterName, videos) + HosterState.Ready(hoster.hosterName, videos, List(videos.size) { Video.State.QUEUE }) } catch (e: Exception) { currentCoroutineContext().ensureActive() HosterState.Error(hoster.hosterName) } } - private suspend fun loadVideo(video: Video, hosterIndex: Int, videoIndex: Int) { + private fun HosterState.Ready.getChangedAt(index: Int, newVideo: Video, newState: Video.State): HosterState.Ready { + return HosterState.Ready( + name = this.name, + videoList = this.videoList.mapIndexed { idx, video -> + if (idx == index) newVideo else video + }, + videoState = this.videoState.mapIndexed { idx, state -> + if (idx == index) newState else state + }, + ) + } + + private suspend fun loadVideo(source: AnimeSource?, video: Video, hosterIndex: Int, videoIndex: Int): Boolean { + val selectedHosterState = (_hosterState.value[hosterIndex] as? HosterState.Ready) ?: return false updateIsLoadingEpisode(true) - updatePausedState() + val oldSelectedIndex = _selectedHosterVideoIndex.value + _selectedHosterVideoIndex.update { _ -> Pair(hosterIndex, videoIndex) } + + _hosterState.updateAt( + hosterIndex, + selectedHosterState.getChangedAt(videoIndex, video, Video.State.LOAD_VIDEO), + ) + // Pause until everything has loaded + updatePausedState() pause() - _currentVideo.update { _ -> video } - _selectedHosterVideoIndex.update { _ -> Pair(hosterIndex, videoIndex) } + val newVideoUrl = if (source is AnimeHttpSource && + selectedHosterState.videoState[videoIndex] != Video.State.READY + ) { + try { + source.resolveVideoUrl(video) + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + + "" + } + } else { + video.videoUrl + } + + if (newVideoUrl.isEmpty()) { + if (currentVideo.value == null) { + _hosterState.updateAt( + hosterIndex, + selectedHosterState.getChangedAt(videoIndex, video, Video.State.ERROR), + ) + + val (newHosterIdx, newVideoIdx) = selectBestVideo() + val newVideo = (hosterState.value[newHosterIdx] as HosterState.Ready).videoList[newVideoIdx] + + return loadVideo(source, newVideo, newHosterIdx, newVideoIdx) + } else { + _selectedHosterVideoIndex.update { _ -> oldSelectedIndex } + _hosterState.updateAt( + hosterIndex, + selectedHosterState.getChangedAt(videoIndex, video, Video.State.ERROR), + ) + return false + } + } + + val newVideo = video.copy(videoUrl = newVideoUrl) + _hosterState.updateAt( + hosterIndex, + selectedHosterState.getChangedAt(videoIndex, newVideo, Video.State.READY), + ) + + _currentVideo.update { _ -> newVideo } + if (sheetShown.value == Sheets.QualityTracks) { + dismissSheet() + } + qualityIndex = Pair(hosterIndex, videoIndex) - activity.setVideo(video) + activity.setVideo(newVideo) + return true } fun onVideoClicked(hosterIndex: Int, videoIndex: Int) { @@ -1330,7 +1464,10 @@ class PlayerViewModel @JvmOverloads constructor( ?: return // Shouldn't happen, but just in caseā„¢ viewModelScope.launch(Dispatchers.IO) { - loadVideo(video, hosterIndex, videoIndex) + val success = loadVideo(currentSource.value, video, hosterIndex, videoIndex) + if (!success) { + updateIsLoadingEpisode(false) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt index d6e5f2dbb9..03ff4002a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt @@ -533,6 +533,7 @@ fun PlayerControls( } val sheetShown by viewModel.sheetShown.collectAsState() + val dismissSheet by viewModel.dismissSheet.collectAsState() val subtitles by viewModel.subtitleTracks.collectAsState() val selectedSubtitles by viewModel.selectedSubtitles.collectAsState() val audioTracks by viewModel.audioTracks.collectAsState() @@ -592,6 +593,7 @@ fun PlayerControls( }, onOpenPanel = viewModel::showPanel, onDismissRequest = { viewModel.showSheet(Sheets.None) }, + dismissSheet = dismissSheet, ) val panel by viewModel.panelShown.collectAsState() PlayerPanels( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt index ec2ee9d285..1b2c7d5689 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt @@ -93,6 +93,7 @@ fun PlayerSheets( onOpenPanel: (Panels) -> Unit, onDismissRequest: () -> Unit, + dismissSheet: Boolean, ) { when (sheetShown) { Sheets.None -> {} @@ -140,6 +141,7 @@ fun PlayerSheets( onClickHoster = onClickHoster, onClickVideo = onClickVideo, onDismissRequest = onDismissRequest, + dismissSheet = dismissSheet, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/QualitySheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/QualitySheet.kt index 6e45c6b270..872cc1e61b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/QualitySheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/QualitySheet.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.ui.player.controls.components.sheets -import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -9,10 +8,12 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically 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.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -29,8 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,7 +49,11 @@ sealed class HosterState(open val name: String) { data class Idle(override val name: String) : HosterState(name) data class Loading(override val name: String) : HosterState(name) data class Error(override val name: String) : HosterState(name) - data class Ready(override val name: String, val videoList: List