From 37592858087b99166866c6377c57d1e344382507 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Fri, 3 Jul 2026 00:19:36 +0200 Subject: [PATCH 01/14] Refactor player --- .../components/ExposedTextDropDownMenu.kt | 2 +- .../tachiyomi/ui/player/AniyomiMPVView.kt | 2 +- .../tachiyomi/ui/player/PlayerActivity.kt | 715 +--- .../kanade/tachiyomi/ui/player/PlayerEnums.kt | 2 +- .../tachiyomi/ui/player/PlayerObserver.kt | 62 - .../tachiyomi/ui/player/PlayerScreen.kt | 514 +++ .../tachiyomi/ui/player/PlayerViewModel.kt | 3758 ++++++++++------- .../ui/player/components/BrightnessOverlay.kt | 67 + .../ui/player/components/MpvSurface.kt | 51 + .../player/components/OrientationOverlay.kt | 17 + .../player/components/SystemAwakeOverlay.kt | 24 + .../ui/player/components/SystemBarOverlay.kt | 45 + .../ui/player/controls/GestureHandler.kt | 171 +- .../ui/player/controls/PlayerControls.kt | 1227 ++---- .../ui/player/controls/PlayerDialogs.kt | 2 +- .../ui/player/controls/PlayerPanels.kt | 2 +- .../ui/player/controls/PlayerSheets.kt | 20 +- .../controls/components/BrightnessOverlay.kt | 33 - .../ui/player/controls/components/SeekBar.kt | 2 +- .../components/dialogs/EpisodeListDialog.kt | 5 +- .../panels/SubtitleSettingsColorsCard.kt | 8 +- .../panels/SubtitleSettingsPanel.kt | 2 +- .../panels/SubtitleSettingsTypographyCard.kt | 22 +- .../components/sheets/AudioTracksSheet.kt | 6 +- .../components/sheets/ChaptersSheet.kt | 2 +- .../components/sheets/GenericTracksSheet.kt | 4 +- .../controls/components/sheets/MoreSheet.kt | 2 +- .../components/sheets/QualitySheet.kt | 4 +- .../components/sheets/ScreenshotSheet.kt | 12 +- .../components/sheets/SubtitleTracksSheet.kt | 4 +- .../ui/player/domain/BrightnessManager.kt | 5 +- .../tachiyomi/ui/player/domain/TrackSelect.kt | 2 +- .../{PlayerModels.kt => mpv/MPVModels.kt} | 8 +- .../tachiyomi/ui/player/mpv/MPVPlayer.kt | 416 ++ .../ui/player/settings/PlayerPreferences.kt | 2 +- 35 files changed, 3910 insertions(+), 3310 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerObserver.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/components/BrightnessOverlay.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/components/MpvSurface.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/components/OrientationOverlay.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemAwakeOverlay.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemBarOverlay.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/BrightnessOverlay.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/player/{PlayerModels.kt => mpv/MPVModels.kt} (96%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt diff --git a/app/src/main/java/eu/kanade/presentation/player/components/ExposedTextDropDownMenu.kt b/app/src/main/java/eu/kanade/presentation/player/components/ExposedTextDropDownMenu.kt index 2f82c01db2..97725431a6 100644 --- a/app/src/main/java/eu/kanade/presentation/player/components/ExposedTextDropDownMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/player/components/ExposedTextDropDownMenu.kt @@ -43,7 +43,7 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun ExposedTextDropDownMenu( selectedValue: String, - options: ImmutableList, + options: List, label: String, onValueChangedEvent: (String) -> Unit, modifier: Modifier = Modifier, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt index aa32d78fe9..ea7d68cee5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt @@ -87,7 +87,7 @@ class AniyomiMPVView(context: Context, attributes: AttributeSet?) : BaseMPVView( mpv?.setOptionString("idle", "yes") mpv?.setOptionString("ytdl", "no") setSafeOptionString("tls-verify", "yes") - setSafeOptionString("tls-ca-file", "${context.filesDir.path}/${PlayerActivity.MPV_DIR}/cacert.pem") + // setSafeOptionString("tls-ca-file", "${context.filesDir.path}/${PlayerActivity.MPV_DIR}/cacert.pem") // We handle selecting this in the viewmodel mpv?.setOptionString("sid", "no") 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 60002b0ea3..d8b3e5c18c 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 @@ -28,7 +28,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Rect @@ -38,7 +37,6 @@ import android.media.session.PlaybackState import android.net.Uri import android.os.Build import android.os.Bundle -import android.util.DisplayMetrics import android.util.Rational import android.view.KeyEvent import android.view.View @@ -47,67 +45,39 @@ import android.view.inputmethod.InputMethodManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope -import androidx.media.AudioAttributesCompat -import androidx.media.AudioFocusRequestCompat -import androidx.media.AudioManagerCompat -import com.hippo.unifile.UniFile +import dev.icerock.moko.resources.StringResource import eu.kanade.presentation.theme.TachiyomiTheme -import eu.kanade.tachiyomi.animesource.model.ChapterType import eu.kanade.tachiyomi.animesource.model.Hoster import eu.kanade.tachiyomi.animesource.model.SerializableHoster.Companion.serialize -import eu.kanade.tachiyomi.animesource.model.Video -import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.ui.base.activity.BaseActivity -import eu.kanade.tachiyomi.ui.player.controls.PlayerControls -import eu.kanade.tachiyomi.ui.player.settings.AdvancedPlayerPreferences -import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences import eu.kanade.tachiyomi.ui.player.settings.GesturePreferences import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences -import eu.kanade.tachiyomi.ui.player.settings.SubtitlePreferences -import eu.kanade.tachiyomi.ui.player.utils.ChapterUtils.Companion.getStringRes import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast -import `is`.xyz.mpv.MPV -import `is`.xyz.mpv.MPVNode import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchNonCancellable -import tachiyomi.core.common.util.lang.launchUI import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR import tachiyomi.i18n.aniyomi.AYMR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import kotlin.math.ceil -import kotlin.math.floor class PlayerActivity : BaseActivity() { private val viewModel by viewModels() - private val mpv by lazy { viewModel.mpv } - private val player by lazy { AniyomiMPVView(this, null) } - private val playerObserver by lazy { PlayerObserver(this) } private val windowInsetsController by lazy { WindowCompat.getInsetsController(window, window.decorView) } private val audioManager by lazy { getSystemService(AUDIO_SERVICE) as AudioManager } private val inputMethodManager by lazy { getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager } @@ -115,17 +85,11 @@ class PlayerActivity : BaseActivity() { private var mediaSession: MediaSession? = null private val gesturePreferences: GesturePreferences = Injekt.get() private val playerPreferences: PlayerPreferences = Injekt.get() - private val audioPreferences: AudioPreferences = Injekt.get() - private val advancedPlayerPreferences: AdvancedPlayerPreferences = Injekt.get() - private val subtitlePreferences: SubtitlePreferences = Injekt.get() // AM (DISCORD_RPC) --> // private val connectionPreferences: ConnectionPreferences = Injekt.get() // <-- AM (DISCORD_RPC) - private var audioFocusRequest: AudioFocusRequestCompat? = null - private var restoreAudioFocus: () -> Unit = {} - private var pipRect: Rect? = null val isPipSupportedAndEnabled by lazy { packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && @@ -162,12 +126,6 @@ class PlayerActivity : BaseActivity() { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } } - - internal const val MPV_DIR = "mpv" - private const val MPV_FONTS_DIR = "fonts" - private const val MPV_SCRIPTS_DIR = "scripts" - private const val MPV_SCRIPTS_OPTS_DIR = "script-opts" - private const val MPV_SHADERS_DIR = "shaders" } override fun onNewIntent(intent: Intent) { @@ -193,6 +151,10 @@ class PlayerActivity : BaseActivity() { lifecycleScope.launchNonCancellable { viewModel.updateIsLoadingEpisode(true) viewModel.updateIsLoadingHosters(true) + viewModel.updateHasPip( + packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + playerPreferences.enablePip.get(), + ) val initResult = viewModel.init(animeId, episodeId, hostList, hostIndex, vidIndex) if (!initResult.second.getOrDefault(false)) { @@ -208,7 +170,6 @@ class PlayerActivity : BaseActivity() { lifecycleScope.launch { viewModel.loadHosters( - source = viewModel.currentSource.value!!, hosterList = initResult.first.hosterList ?: emptyList(), hosterIndex = initResult.first.videoIndex.first, videoIndex = initResult.first.videoIndex.second, @@ -225,10 +186,8 @@ class PlayerActivity : BaseActivity() { registerSecureActivity(this) super.onCreate(savedInstanceState) - setupPlayerMPV() - setupPlayerAudio() setupMediaSession() - setupPlayerOrientation() + viewModel.setupPlayerOrientation() Thread.setDefaultUncaughtExceptionHandler { _, throwable -> runOnUiThread { @@ -241,45 +200,21 @@ class PlayerActivity : BaseActivity() { viewModel.eventFlow .onEach { event -> when (event) { + PlayerViewModel.Event.EnterPip -> { + enterPictureInPictureMode(createPipParams()) + } + PlayerViewModel.Event.Finish -> { + finish() + } + is PlayerViewModel.Event.InitialEpisodeError -> { + setInitialEpisodeError(event.error) + } is PlayerViewModel.Event.SavedImage -> { onSaveImageResult(event.result) } - is PlayerViewModel.Event.ShareImage -> { - onShareImageResult(event.uri, event.seconds) - } is PlayerViewModel.Event.SetArtResult -> { onSetAsArtResult(event.result, event.artType) } - is PlayerViewModel.Event.ShowToast -> { - showToast(stringResource(event.stringResource)) - } - is PlayerViewModel.Event.ShowToastString -> { - showToast(event.string) - } - is PlayerViewModel.Event.ChangeEpisode -> { - changeEpisode(event.episodeId, event.autoPlay) - } - is PlayerViewModel.Event.SetVideo -> { - setVideo(event.video) - } - is PlayerViewModel.Event.SetStatusBar -> { - if (event.show) { - windowInsetsController.show(WindowInsetsCompat.Type.statusBars()) - } else { - windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) - } - } - is PlayerViewModel.Event.SetBrightness -> { - window.attributes = window.attributes.apply { - screenBrightness = event.brightness - } - } - is PlayerViewModel.Event.ChangeVideoAspect -> { - changeVideoAspect(event.aspect) - } - PlayerViewModel.Event.CycleRotations -> { - cycleRotations() - } is PlayerViewModel.Event.SetKeyboard -> { if (event.show) { forceShowSoftwareKeyboard() @@ -287,6 +222,15 @@ class PlayerActivity : BaseActivity() { forceHideSoftwareKeyboard() } } + is PlayerViewModel.Event.ShareImage -> { + onShareImageResult(event.uri, event.seconds) + } + is PlayerViewModel.Event.ToastResource -> { + showToast(this.stringResource(event.stringRes)) + } + is PlayerViewModel.Event.ToastString -> { + showToast(event.string) + } PlayerViewModel.Event.ToggleKeyboard -> { toggleShowSoftwareKeyboard() } @@ -296,34 +240,19 @@ class PlayerActivity : BaseActivity() { setContent { TachiyomiTheme { - Box(modifier = Modifier.fillMaxSize()) { - AndroidView( - factory = { player }, - modifier = Modifier.onGloballyPositioned { - pipRect = run { - val boundsInWindow = it.boundsInWindow() - Rect( - boundsInWindow.left.toInt(), - boundsInWindow.top.toInt(), - boundsInWindow.right.toInt(), - boundsInWindow.bottom.toInt(), - ) - } - }, - ) - PlayerControls( - viewModel = viewModel, - onBackPress = { - if (isPipSupportedAndEnabled && viewModel.paused == false && - playerPreferences.pipOnExit.get() - ) { - enterPictureInPictureMode(createPipParams()) - } else { - finish() - } - }, - ) - } + PlayerScreen( + viewModel = viewModel, + onBack = { + if (isPipSupportedAndEnabled && viewModel.playbackData.value.paused && + playerPreferences.pipOnExit.get() + ) { + enterPictureInPictureMode(createPipParams()) + } else { + finish() + } + }, + modifier = Modifier.fillMaxSize(), + ) } } @@ -331,12 +260,7 @@ class PlayerActivity : BaseActivity() { } override fun onDestroy() { - player.isExiting = true - - audioFocusRequest?.let { - AudioManagerCompat.abandonAudioFocusRequest(audioManager, it) - } - audioFocusRequest = null + viewModel.player.release() mediaSession?.let { it.isActive = false @@ -348,10 +272,6 @@ class PlayerActivity : BaseActivity() { noisyReceiver.initialized = false } - mpv.removeLogObserver(playerObserver) - mpv.removeObserver(playerObserver) - mpv.close() - super.onDestroy() } @@ -363,10 +283,10 @@ class PlayerActivity : BaseActivity() { return } - player.isExiting = true + viewModel.setPlayerExiting(true) if (isFinishing) { viewModel.deletePendingEpisodes() - mpv.command("stop") + viewModel.mpvCommand("stop") } else { viewModel.pause() } @@ -375,12 +295,6 @@ class PlayerActivity : BaseActivity() { } override fun onStop() { - window.attributes.screenBrightness.let { - if (playerPreferences.rememberPlayerBrightness.get() && it != -1f) { - playerPreferences.playerBrightnessValue.set(it) - } - } - if (isInPictureInPictureMode && powerManager.isInteractive) { viewModel.deletePendingEpisodes() } @@ -389,26 +303,12 @@ class PlayerActivity : BaseActivity() { } override fun onUserLeaveHint() { - if (isPipSupportedAndEnabled && viewModel.paused == false && playerPreferences.pipOnExit.get()) { + if (isPipSupportedAndEnabled && !viewModel.playbackData.value.paused && playerPreferences.pipOnExit.get()) { enterPictureInPictureMode() } super.onUserLeaveHint() } - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (isPipSupportedAndEnabled && viewModel.paused == false && playerPreferences.pipOnExit.get()) { - if (viewModel.sheetShown.value == Sheets.None && - viewModel.panelShown.value == Panels.None && - viewModel.dialogShown.value == Dialogs.None - ) { - enterPictureInPictureMode() - } - } else { - super.onBackPressed() - } - } - override fun onStart() { super.onStart() setPictureInPictureParams(createPipParams()) @@ -434,195 +334,46 @@ class PlayerActivity : BaseActivity() { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER } } - - if (playerPreferences.rememberPlayerBrightness.get()) { - playerPreferences.playerBrightnessValue.get().let { - if (it != -1f) viewModel.changeBrightnessTo(it) - } - } - } - - private fun UniFile.writeText(text: String) { - this.openOutputStream().use { - it.write(text.toByteArray()) - } - } - - private fun setupPlayerMPV() { - val mpvDir = UniFile.fromFile(applicationContext.filesDir)!!.createDirectory(MPV_DIR)!! - - val mpvConfFile = mpvDir.createFile("mpv.conf")!! - advancedPlayerPreferences.mpvConf.get().let { mpvConfFile.writeText(it) } - val mpvInputFile = mpvDir.createFile("input.conf")!! - advancedPlayerPreferences.mpvInput.get().let { mpvInputFile.writeText(it) } - - player.init(mpv) - - val showBlackBars = if (subtitlePreferences.subtitleBlackBars.get()) "yes" else "no" - mpv.setOptionString("sub-ass-force-margins", showBlackBars) - mpv.setOptionString("sub-use-margins", showBlackBars) - mpv.addLogObserver(playerObserver) - mpv.addObserver(playerObserver) - } - - private fun setupPlayerAudio() { - with(audioPreferences) { - audioChannels.get().let { mpv.setPropertyString(it.property, it.value) } - - val request = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN).also { - it.setAudioAttributes( - AudioAttributesCompat.Builder().setUsage(AudioAttributesCompat.USAGE_MEDIA) - .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC).build(), - ) - it.setOnAudioFocusChangeListener(audioFocusChangeListener) - }.build() - AudioManagerCompat.requestAudioFocus(audioManager, request).let { - if (it == AudioManager.AUDIOFOCUS_REQUEST_FAILED) return@let - audioFocusRequest = request - } - } - } - - private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { - when (it) { - AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, - -> { - val oldRestore = restoreAudioFocus - val wasPlayerPaused = viewModel.paused ?: false - viewModel.pause() - restoreAudioFocus = { - oldRestore() - if (!wasPlayerPaused) viewModel.unpause() - } - } - - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - mpv.command("multiply", "volume", "0.5") - restoreAudioFocus = { - mpv.command("multiply", "volume", "2") - } - } - - AudioManager.AUDIOFOCUS_GAIN -> { - restoreAudioFocus() - restoreAudioFocus = {} - } - - AudioManager.AUDIOFOCUS_REQUEST_FAILED -> { - logcat(LogPriority.DEBUG) { "didn't get audio focus" } - } - } } override fun onResume() { - if (!player.isExiting) { + if (!viewModel.isPlayerExiting()) { super.onResume() return } - player.isExiting = false + viewModel.setPlayerExiting(false) super.onResume() - viewModel.currentVolume.update { + viewModel.setVolumeTo( audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).also { - if (it < viewModel.maxVolume) viewModel.changeMPVVolumeTo(100) - } - } + if (it < viewModel.stateData.value.maxVolume) viewModel.changeMPVVolumeTo(100) + }, + ) } override fun onConfigurationChanged(newConfig: Configuration) { if (!isInPictureInPictureMode) { - viewModel.changeVideoAspect(playerPreferences.aspectState.get()) + viewModel.setAspectRatio(playerPreferences.aspectState.get()) } else { viewModel.hideControls() } super.onConfigurationChanged(newConfig) } - fun showToast(message: String) { - runOnUiThread { toast(message) } - } - - // A bunch of observers - - @Suppress("unused") - internal fun onObserverEvent(property: String, value: Long) { - if (player.isExiting) return - } - - @Suppress("unused") - internal fun onObserverEvent(property: String) { - if (player.isExiting) return - } - - internal fun onObserverEvent(property: String, value: Boolean) { - if (player.isExiting) return - when (property) { - "pause" if value -> window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - "pause" -> window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - "eof-reached" -> endFile(value) - } - } - - internal fun onObserverEvent(property: String, value: String) { - if (player.isExiting) return - when (property.substringBeforeLast("/")) { - "user-data/aniyomi" -> viewModel.handleLuaInvocation(property, value) - } - } - - @Suppress("unused") - internal fun onObserverEvent(property: String, value: Double) { - if (player.isExiting) return - when (property) { - "video-params/aspect" -> if (isPipSupportedAndEnabled) createPipParams() - } + fun showToast(stringRes: StringResource) { + runOnUiThread { toast(stringRes) } } - @Suppress("unused") - internal fun onObserverEvent(property: String, value: MPVNode) { - if (player.isExiting) return - } - - internal fun event(eventId: Int, node: MPVNode) { - if (player.isExiting) return - when (eventId) { - MPV.mpvEvent.MPV_EVENT_FILE_LOADED -> { - viewModel.viewModelScope.launchIO { fileLoaded() } - } - MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> player.isExiting = false - MPV.mpvEvent.MPV_EVENT_END_FILE -> { - val errorNode = node.asMap()?.get("file_error") ?: return - var errorMessage = errorNode.asString() ?: "Error: File ended" - - val httpError = playerObserver.httpError - if (!httpError.isNullOrEmpty()) { - errorMessage += ": $httpError" - playerObserver.httpError = null - } - - logcat(LogPriority.ERROR) { errorMessage } - showToast(errorMessage) - - viewModel.setCurrentVideoError() - - if (playerPreferences.switchOnFailure.get()) { - if (!viewModel.loadBestVideo()) { - finish() - } - } else { - viewModel.setIsStopped(true) - } - } - } + fun showToast(message: String) { + runOnUiThread { toast(message) } } fun createPipParams(): PictureInPictureParams { val builder = PictureInPictureParams.Builder() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val anime = viewModel.currentAnime.value - val episode = viewModel.currentEpisode.value + val anime = viewModel.stateData.value.currentAnime + val episode = viewModel.stateData.value.currentEpisode if (anime != null && episode != null) { builder.setTitle(anime.title).setSubtitle(episode.name) @@ -630,24 +381,25 @@ class PlayerActivity : BaseActivity() { } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val autoEnter = playerPreferences.pipOnExit.get() - builder.setAutoEnterEnabled(viewModel.paused == false && autoEnter) - builder.setSeamlessResizeEnabled(viewModel.paused == false && autoEnter) + builder.setAutoEnterEnabled(!viewModel.playbackData.value.paused && autoEnter) + builder.setSeamlessResizeEnabled(!viewModel.playbackData.value.paused && autoEnter) } builder.setActions( createPipActions( context = this, - isPaused = viewModel.paused ?: true, + isPaused = viewModel.playbackData.value.paused, replaceWithPrevious = playerPreferences.pipReplaceWithPrevious.get(), - playlistCount = viewModel.currentPlaylist.value.size, - playlistPosition = viewModel.getCurrentEpisodeIndex(), + playlistCount = viewModel.stateData.value.currentPlaylist.size, + playlistPosition = viewModel.stateData.value.currentPlaylistIndex, ), ) builder.setSourceRectHint(pipRect) - mpv.getPropertyInt("video-params/h")?.let { - val height = it - val width = it * player.getVideoOutAspect()!! - val rational = Rational(height, width.toInt()).toFloat() - if (rational in 0.42..2.38) builder.setAspectRatio(Rational(width.toInt(), height)) + viewModel.stateData.value.let { + if (it.videoWidth > 0 && it.videoHeight > 0) { + builder.setAspectRatio( + Rational(it.videoWidth, it.videoHeight), + ) + } } return builder.build() } @@ -663,17 +415,17 @@ class PlayerActivity : BaseActivity() { setPictureInPictureParams(createPipParams()) viewModel.hideControls() viewModel.hideSeekBar() - viewModel.isBrightnessSliderShown.update { false } - viewModel.isVolumeSliderShown.update { false } - viewModel.sheetShown.update { Sheets.None } + viewModel.displayBrightnessSlider(false) + viewModel.displayVolumeSlider(false) + viewModel.setSheet(Sheets.None) pipReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent == null || intent.action != PIP_INTENTS_FILTER) return when (intent.getIntExtra(PIP_INTENT_ACTION, 0)) { PIP_PAUSE -> viewModel.pause() PIP_PLAY -> viewModel.unpause() - PIP_NEXT -> viewModel.changeEpisode(false) - PIP_PREVIOUS -> viewModel.changeEpisode(true) + PIP_NEXT -> viewModel.nextEpisode(next = true) + PIP_PREVIOUS -> viewModel.nextEpisode(next = false) PIP_SKIP -> viewModel.seekBy(10) } setPictureInPictureParams(createPipParams()) @@ -689,34 +441,15 @@ class PlayerActivity : BaseActivity() { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) } - private fun setupPlayerOrientation() { - if (player.isExiting) return - requestedOrientation = when (playerPreferences.defaultPlayerOrientationType.get()) { - PlayerOrientation.Free -> ActivityInfo.SCREEN_ORIENTATION_SENSOR - PlayerOrientation.Video -> if ((player.getVideoOutAspect() ?: 0.0) > 1.0) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - } - - PlayerOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - PlayerOrientation.ReversePortrait -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - PlayerOrientation.SensorPortrait -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - PlayerOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - PlayerOrientation.ReverseLandscape -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - PlayerOrientation.SensorLandscape -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> { viewModel.changeVolumeBy(1) - viewModel.displayVolumeSlider() + viewModel.displayVolumeSlider(true) } KeyEvent.KEYCODE_VOLUME_DOWN -> { viewModel.changeVolumeBy(-1) - viewModel.displayVolumeSlider() + viewModel.displayVolumeSlider(true) } KeyEvent.KEYCODE_DPAD_LEFT -> viewModel.handleLeftDoubleTap() KeyEvent.KEYCODE_DPAD_RIGHT -> viewModel.handleRightDoubleTap() @@ -728,7 +461,7 @@ class PlayerActivity : BaseActivity() { // other keys should be bound by the user in input.conf ig else -> { - event?.let { player.onKey(it) } + event?.let { viewModel.onKey(it) } super.onKeyDown(keyCode, event) } } @@ -736,7 +469,7 @@ class PlayerActivity : BaseActivity() { } override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { - if (player.onKey(event!!)) return true + if (viewModel.onKey(event!!)) return true return super.onKeyUp(keyCode, event) } @@ -755,10 +488,9 @@ class PlayerActivity : BaseActivity() { SingleActionGesture.PlayPause -> { super.onPlay() viewModel.unpause() - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } SingleActionGesture.Custom -> { - mpv.command("keypress", CustomKeyCodes.MediaPlay.keyCode) + viewModel.mpvCommand("keypress", CustomKeyCodes.MediaPlay.keyCode) } SingleActionGesture.Switch -> {} @@ -772,10 +504,9 @@ class PlayerActivity : BaseActivity() { SingleActionGesture.PlayPause -> { super.onPause() viewModel.pause() - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } SingleActionGesture.Custom -> { - mpv.command("keypress", CustomKeyCodes.MediaPlay.keyCode) + viewModel.mpvCommand("keypress", CustomKeyCodes.MediaPlay.keyCode) } SingleActionGesture.Switch -> {} @@ -792,10 +523,10 @@ class PlayerActivity : BaseActivity() { viewModel.pauseUnpause() } SingleActionGesture.Custom -> { - mpv.command("keypress", CustomKeyCodes.MediaPrevious.keyCode) + viewModel.mpvCommand("keypress", CustomKeyCodes.MediaPrevious.keyCode) } - SingleActionGesture.Switch -> viewModel.changeEpisode(true) + SingleActionGesture.Switch -> viewModel.nextEpisode(next = false) } } @@ -809,10 +540,10 @@ class PlayerActivity : BaseActivity() { viewModel.pauseUnpause() } SingleActionGesture.Custom -> { - mpv.command("keypress", CustomKeyCodes.MediaNext.keyCode) + viewModel.mpvCommand("keypress", CustomKeyCodes.MediaNext.keyCode) } - SingleActionGesture.Switch -> viewModel.changeEpisode(false) + SingleActionGesture.Switch -> viewModel.nextEpisode(next = true) } } @@ -851,167 +582,27 @@ class PlayerActivity : BaseActivity() { super.onSaveInstanceState(outState) } - /** - * Switches to the episode based on [episodeId], - * @param episodeId id of the episode to switch the player to - * @param autoPlay whether the episode is switching due to auto play - */ - internal fun changeEpisode(episodeId: Long?, autoPlay: Boolean = false) { - viewModel.sheetShown.update { _ -> Sheets.None } - viewModel.panelShown.update { _ -> Panels.None } - viewModel.pause() - viewModel.clearTracks() - viewModel.isLoading.update { _ -> true } - viewModel.resetHosterState() - - lifecycleScope.launch { - viewModel.updateIsLoadingEpisode(true) - viewModel.updateIsLoadingHosters(true) - viewModel.cancelHosterVideoLinksJob() - - val pipEpisodeToasts = playerPreferences.pipEpisodeToasts.get() - val switchMethod = viewModel.loadEpisode(episodeId) - - viewModel.updateIsLoadingHosters(false) - - when (switchMethod) { - null -> { - if (viewModel.currentAnime.value != null && !autoPlay) { - launchUI { toast(AYMR.strings.no_next_episode) } - } - viewModel.isLoading.update { _ -> false } - } - - else -> { - if (switchMethod.hosterList != null) { - when { - switchMethod.hosterList.isEmpty() -> setInitialEpisodeError( - PlayerViewModel.ExceptionWithStringResource( - "Hoster list is empty", - AYMR.strings.no_hosters, - ), - ) - else -> { - viewModel.loadHosters( - source = switchMethod.source, - hosterList = switchMethod.hosterList, - hosterIndex = -1, - videoIndex = -1, - ) - } - } - } else { - logcat(LogPriority.ERROR) { "Error getting links" } - } - - if (isInPictureInPictureMode && pipEpisodeToasts) { - launchUI { toast(switchMethod.episodeTitle) } - } - } - } - } - - viewModel.updateHasPreviousEpisode( - viewModel.getCurrentEpisodeIndex() != 0, - ) - viewModel.updateHasNextEpisode( - viewModel.getCurrentEpisodeIndex() != viewModel.currentPlaylist.value.size - 1, - ) - } - - fun setVideo(video: Video?, position: Long? = null) { - if (player.isExiting) return - if (video == null) return - - viewModel.setIsStopped(false) - setHttpOptions(video) - - if (viewModel.isLoadingEpisode.value) { - viewModel.currentEpisode.value?.let { episode -> - val preservePos = playerPreferences.preserveWatchingPosition.get() - val resumePosition = position - ?: if (episode.seen && !preservePos) { - 0L - } else { - episode.last_second_seen - } - mpv.command("set", "start", "${resumePosition / 1000F}") - } - } else { - viewModel.pos?.let { - mpv.command("set", "start", "$it") - } - } - - // We handle selecting these in the viewmodel - val mpvOpts = listOf( - Pair("sid", "no"), - Pair("aid", "no"), - ) - val videoOptions = (video.mpvArgs + mpvOpts).joinToString(",") { (option, value) -> - "$option=\"$value\"" - } - - mpv.command( - "loadfile", - parseVideoUrl(video.videoUrl)!!, - "replace", - "0", - videoOptions, - ) - } - /** * Called from the presenter if the initial load couldn't load the videos of the episode. In * this case the activity is closed and a toast is shown to the user. */ private fun setInitialEpisodeError(error: Throwable) { if (error is PlayerViewModel.ExceptionWithStringResource) { - toast(error.stringResource) + showToast(error.stringResource) } else { - toast(error.message) + showToast(error.message ?: "") } logcat(LogPriority.ERROR, error) finish() } - fun parseVideoUrl(videoUrl: String?): String? { - return videoUrl?.toUri()?.resolveUri(this) - ?: videoUrl - } - - fun setHttpOptions(video: Video) { - if (viewModel.isEpisodeOnline() != true) return - val source = viewModel.currentSource.value as? AnimeHttpSource ?: return - - val headers = (video.headers ?: source.headers) - .toMultimap() - .mapValues { it.value.firstOrNull() ?: "" } - .toMutableMap() - - val httpHeaderString = headers.map { - it.key + ": " + it.value.replace(",", "\\,") - }.joinToString(",") - - mpv.setOptionString("http-header-fields", httpHeaderString) - - // need to fix the cache - // MPVLib.setOptionString("cache-on-disk", "yes") - // val cacheDir = File(applicationContext.filesDir, "media").path - // MPVLib.setOptionString("cache-dir", cacheDir) - } - - fun onTrackLoadedFailure(url: String) { - viewModel.onTrackLoadedFailure(url) - } - /** * Called from the presenter when a screenshot is ready to be shared. It shows Android's * default sharing tool. */ private fun onShareImageResult(uri: Uri, seconds: String) { - val anime = viewModel.currentAnime.value ?: return - val episode = viewModel.currentEpisode.value ?: return + val anime = viewModel.stateData.value.currentAnime ?: return + val episode = viewModel.stateData.value.currentEpisode ?: return val intent = uri.toShareIntent( context = applicationContext, @@ -1027,7 +618,7 @@ class PlayerActivity : BaseActivity() { private fun onSaveImageResult(result: PlayerViewModel.SaveImageResult) { when (result) { is PlayerViewModel.SaveImageResult.Success -> { - toast(MR.strings.picture_saved) + showToast(MR.strings.picture_saved) } is PlayerViewModel.SaveImageResult.Error -> { logcat(LogPriority.ERROR, result.error) @@ -1040,7 +631,7 @@ class PlayerActivity : BaseActivity() { * It shows a different message depending on the [result]. */ private fun onSetAsArtResult(result: SetAsArt, artType: ArtType) { - toast( + showToast( when (result) { SetAsArt.Success -> when (artType) { @@ -1054,46 +645,6 @@ class PlayerActivity : BaseActivity() { ) } - private fun changeVideoAspect(aspect: VideoAspect) { - var ratio = -1.0 - val pan: Double - when (aspect) { - VideoAspect.Crop -> { - pan = 1.0 - } - - VideoAspect.Fit -> { - pan = 0.0 - mpv.setPropertyDouble("panscan", 0.0) - } - - VideoAspect.Stretch -> { - val dm = DisplayMetrics() - windowManager.defaultDisplay.getRealMetrics(dm) - ratio = dm.widthPixels / dm.heightPixels.toDouble() - pan = 0.0 - } - } - viewModel.setAspect(aspect, pan, ratio) - } - - private fun cycleRotations() { - requestedOrientation = when (requestedOrientation) { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, - ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE, - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, - -> { - playerPreferences.defaultPlayerOrientationType.set(PlayerOrientation.SensorPortrait) - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - } - - else -> { - playerPreferences.defaultPlayerOrientationType.set(PlayerOrientation.SensorLandscape) - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } - } - private fun toggleShowSoftwareKeyboard() { if (inputMethodManager.isActive) { forceHideSoftwareKeyboard() @@ -1110,100 +661,6 @@ class PlayerActivity : BaseActivity() { inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0) } - private fun fileLoaded() { - if (player.isExiting) return - - setMpvOptions() - setMpvMediaTitle() - setupPlayerOrientation() - setupChapters() - viewModel.checkFileLoaded() - - // aniSkip stuff - viewModel.viewModelScope.launchIO { - if (viewModel.introSkipEnabled && playerPreferences.aniSkipEnabled.get() && - !(playerPreferences.disableAniSkipOnChapters.get() && viewModel.getChapterCount() > 0) - ) { - viewModel.aniSkipResponse(viewModel.duration)?.let { - viewModel.addTimestamps(it) - } - } - } - } - - private fun setMpvOptions() { - if (player.isExiting) return - val video = viewModel.currentVideo.value ?: return - - // Only check for `MPV_ARGS_TAG` on downloaded videos - if (listOf("file", "content", "data").none { video.videoUrl.startsWith(it) }) { - return - } - - try { - val metadata = mpv.getPropertyString("metadata")?.let { - Json.decodeFromString>(it) - } ?: return - - val opts = metadata[Video.MPV_ARGS_TAG] - ?.split(";") - ?.map { it.split("=", limit = 2) } - ?: return - - opts.forEach { (option, value) -> - mpv.setPropertyString(option, value) - } - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "Failed to read video metadata" } - } - } - - private fun setupChapters() { - if (player.isExiting) return - - val timestamps = viewModel.currentVideo.value?.timestamps?.takeIf { it.isNotEmpty() } - ?.map { timestamp -> - if (timestamp.name.isEmpty() && timestamp.type != ChapterType.Other) { - timestamp.copy( - name = timestamp.type.getStringRes()?.let(::stringResource) ?: "", - ) - } else { - timestamp - } - } - ?: return - - viewModel.addTimestamps(timestamps) - } - - private fun setMpvMediaTitle() { - if (player.isExiting) return - val anime = viewModel.currentAnime.value ?: return - val episode = viewModel.currentEpisode.value ?: return - - // Write to mpv table - mpv.setPropertyString("user-data/current-anime/episode-title", episode.name) - - val epNumber = episode.episode_number.let { number -> - if (ceil(number) == floor(number)) number.toInt() else number - }.toString().padStart(2, '0') - - val title = stringResource( - AYMR.strings.mpv_media_title, - anime.title, - epNumber, - episode.name, - ) - - mpv.setPropertyString("force-media-title", title) - } - - private fun endFile(eofReached: Boolean) { - if (eofReached && playerPreferences.autoplayEnabled.get()) { - viewModel.changeEpisode(previous = false, autoPlay = true) - } - } - // AM (DISCORD_RPC) --> /* private fun updateDiscordRPC(exitingPlayer: Boolean) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt index cc9bd11c26..2a1b324de0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt @@ -137,7 +137,7 @@ sealed class Dialogs { sealed class PlayerUpdates { data object None : PlayerUpdates() data object DoubleSpeed : PlayerUpdates() - data object AspectRatio : PlayerUpdates() + data class AspectRatio(val aspect: VideoAspect) : PlayerUpdates() data class ShowText(val value: String) : PlayerUpdates() data class ShowTextResource(val textResource: StringResource) : PlayerUpdates() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerObserver.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerObserver.kt deleted file mode 100644 index 533ac634a2..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerObserver.kt +++ /dev/null @@ -1,62 +0,0 @@ -package eu.kanade.tachiyomi.ui.player - -import `is`.xyz.mpv.MPV -import `is`.xyz.mpv.MPVNode -import logcat.LogPriority - -class PlayerObserver(val activity: PlayerActivity) : - MPV.EventObserver, - MPV.LogObserver { - - override fun eventProperty(property: String) { - activity.runOnUiThread { activity.onObserverEvent(property) } - } - - override fun eventProperty(property: String, value: Long) { - activity.runOnUiThread { activity.onObserverEvent(property, value) } - } - - override fun eventProperty(property: String, value: Boolean) { - activity.runOnUiThread { activity.onObserverEvent(property, value) } - } - - override fun eventProperty(property: String, value: String) { - activity.runOnUiThread { activity.onObserverEvent(property, value) } - } - - override fun eventProperty(property: String, value: Double) { - activity.runOnUiThread { activity.onObserverEvent(property, value) } - } - - override fun eventProperty(property: String, value: MPVNode) { - activity.runOnUiThread { activity.onObserverEvent(property, value) } - } - - override fun event(eventId: Int, data: MPVNode) { - activity.runOnUiThread { activity.event(eventId, data) } - } - - var httpError: String? = null - - override fun logMessage(prefix: String, level: Int, text: String) { - if (level == MPV.mpvLogLevel.MPV_LOG_LEVEL_ERROR) { - if (text.startsWith(TRACK_LOAD_FAILURE)) { - val url = text.removePrefix(TRACK_LOAD_FAILURE).substringBeforeLast(".") - activity.onTrackLoadedFailure(url) - } - } - - val logPriority = when (level) { - MPV.mpvLogLevel.MPV_LOG_LEVEL_FATAL, MPV.mpvLogLevel.MPV_LOG_LEVEL_ERROR -> LogPriority.ERROR - MPV.mpvLogLevel.MPV_LOG_LEVEL_WARN -> LogPriority.WARN - MPV.mpvLogLevel.MPV_LOG_LEVEL_INFO -> LogPriority.INFO - else -> LogPriority.VERBOSE - } - if (text.contains("HTTP error")) httpError = text.removePrefix("http: ") - logcat.logcat("mpv/$prefix", logPriority) { text } - } - - companion object { - const val TRACK_LOAD_FAILURE = "Can not open external file " - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt new file mode 100644 index 0000000000..36488209c4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt @@ -0,0 +1,514 @@ +package eu.kanade.tachiyomi.ui.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.core.annotation.InternalVoyagerApi +import cafe.adriel.voyager.navigator.internal.BackHandler +import eu.kanade.domain.ui.UiPreferences +import eu.kanade.presentation.theme.playerRippleConfiguration +import eu.kanade.tachiyomi.ui.player.Decoder.Companion.getDecoderFromValue +import eu.kanade.tachiyomi.ui.player.PlayerViewModel.PlayerEvent +import eu.kanade.tachiyomi.ui.player.components.BrightnessOverlay +import eu.kanade.tachiyomi.ui.player.components.MpvSurface +import eu.kanade.tachiyomi.ui.player.components.OrientationOverlay +import eu.kanade.tachiyomi.ui.player.components.SystemAwakeOverlay +import eu.kanade.tachiyomi.ui.player.controls.DoubleTapToSeekOvals +import eu.kanade.tachiyomi.ui.player.controls.GestureHandler +import eu.kanade.tachiyomi.ui.player.controls.LocalPlayerButtonsClickEvent +import eu.kanade.tachiyomi.ui.player.controls.PlayerControls +import eu.kanade.tachiyomi.ui.player.controls.PlayerDialogs +import eu.kanade.tachiyomi.ui.player.controls.PlayerPanels +import eu.kanade.tachiyomi.ui.player.controls.PlayerSheets +import eu.kanade.tachiyomi.ui.player.controls.components.panels.SubColorType +import eu.kanade.tachiyomi.ui.player.controls.components.panels.SubtitlesBorderStyle +import eu.kanade.tachiyomi.ui.player.controls.components.panels.resetColors +import eu.kanade.tachiyomi.ui.player.controls.components.panels.resetTypography +import eu.kanade.tachiyomi.ui.player.controls.components.panels.toColorHexString +import eu.kanade.tachiyomi.ui.player.controls.components.sheets.toFixed +import eu.kanade.tachiyomi.ui.player.mpv.VideoTrack +import eu.kanade.tachiyomi.ui.player.settings.AdvancedPlayerPreferences +import eu.kanade.tachiyomi.ui.player.settings.AudioChannels +import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences +import eu.kanade.tachiyomi.ui.player.settings.DecoderPreferences +import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences +import eu.kanade.tachiyomi.ui.player.settings.SubtitleAssOverride +import eu.kanade.tachiyomi.ui.player.settings.SubtitleJustification +import eu.kanade.tachiyomi.ui.player.settings.SubtitlePreferences +import kotlinx.coroutines.delay +import tachiyomi.core.common.preference.deleteAndGet +import tachiyomi.core.common.preference.minusAssign +import tachiyomi.core.common.preference.plusAssign +import tachiyomi.presentation.core.util.collectAsState +import tachiyomi.source.local.isLocal +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(InternalVoyagerApi::class) +@Composable +fun PlayerScreen( + viewModel: PlayerViewModel, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val uiPreferences = remember { Injekt.get() } + val playerPreferences = remember { Injekt.get() } + val audioPreferences = remember { Injekt.get() } + val subtitlePreferences = remember { Injekt.get() } + val decoderPreferences = remember { Injekt.get() } + val advancedPreferences = remember { Injekt.get() } + + val stateData by viewModel.stateData.collectAsStateWithLifecycle() + val uiData by viewModel.uiData.collectAsStateWithLifecycle() + val playbackData by viewModel.playbackData.collectAsStateWithLifecycle() + + Box(modifier = modifier.fillMaxSize().background(Color.Black)) { + val mpvVolume by viewModel.propFlow("volume").collectAsStateWithLifecycle() + val pausedForCache by viewModel.propFlow("paused-for-cache").collectAsStateWithLifecycle() + val coreIdle by viewModel.propFlow("core-idle").collectAsStateWithLifecycle() + val readAhead by viewModel.propFlow("demuxer-cache-time").collectAsStateWithLifecycle() + val remaining by viewModel.propFlow("playtime-remaining").collectAsStateWithLifecycle() + val playbackSpeed by viewModel.propFlow("speed").collectAsStateWithLifecycle() + val currentChapter by viewModel.propFlow("chapter").collectAsStateWithLifecycle() + + BackHandler( + enabled = stateData.isPipAvailable && !playbackData.paused && playerPreferences.pipOnExit.get() && + uiData.sheetShown == Sheets.None && + uiData.panelShown == Panels.None && + uiData.dialogShown == Dialogs.None, + ) { + viewModel.handlePlayerEvent(PlayerEvent.EnterPip) + } + + MpvSurface( + modifier = Modifier.fillMaxSize(), + mpv = viewModel.mpv, + videoOutput = viewModel.videoOutput, + ) + + GestureHandler( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + interactionSource = interactionSource, + ) + + DoubleTapToSeekOvals( + amount = playbackData.doubleTapSeekAmount, + text = playbackData.seekText, + interactionSource = interactionSource, + ) + + OrientationOverlay( + orientation = playbackData.currentOrientation, + ) + + SystemAwakeOverlay( + paused = playbackData.paused, + ) + + BrightnessOverlay( + brightness = playbackData.currentBrightness, + ) + + var resetControls by remember { mutableStateOf(true) } + + LaunchedEffect( + uiData.controlsShown, + playbackData.paused, + playbackData.isSeeking, + resetControls, + ) { + if (uiData.controlsShown && !playbackData.paused && !playbackData.isSeeking) { + delay(uiData.playerTimeToDisappearMs.milliseconds) + viewModel.hideControls() + } + } + + CompositionLocalProvider( + LocalRippleConfiguration provides playerRippleConfiguration, + LocalPlayerButtonsClickEvent provides { resetControls = !resetControls }, + LocalContentColor provides Color.White, + ) { + PlayerControls( + stateData = stateData, + uiData = uiData, + playbackData = playbackData, + onBack = onBack, + onPlayerEvent = viewModel::handlePlayerEvent, + mpvVolume = mpvVolume, + pausedForCache = pausedForCache, + coreIdle = coreIdle, + readAhead = readAhead, + remaining = remaining, + playbackSpeed = playbackSpeed, + currentChapter = currentChapter, + modifier = Modifier.fillMaxSize(), + ) + + // Sheets + val showFailedHosters by playerPreferences.showFailedHosters.collectAsState() + val emptyHosters by playerPreferences.showEmptyHosters.collectAsState() + val showSubtitles by subtitlePreferences.screenshotSubtitles.collectAsState() + val speedPresets by playerPreferences.speedPresets.collectAsState() + val statisticsPage by advancedPreferences.playerStatisticsPage.collectAsState() + + val mpvDecoder by viewModel.propFlow("hwdec-current").collectAsState() + val decoder by remember { derivedStateOf { getDecoderFromValue(mpvDecoder ?: "auto") } } + val audioChannels by audioPreferences.audioChannels.collectAsState() + val pitchCorrection by audioPreferences.enablePitchCorrection.collectAsState() + val mpvAudioPitchCorrection by viewModel.propFlow("audio-pitch-correction").collectAsState() + + val subtitles = remember(stateData.subtitleTracks, stateData.externalSubtitleTracks) { + stateData.subtitleTracks.map { VideoTrack.Internal(it) } + stateData.externalSubtitleTracks + } + val audioTracks = remember(stateData.audioTracks, stateData.externalAudioTracks) { + stateData.audioTracks.map { VideoTrack.Internal(it) } + stateData.externalAudioTracks + } + + PlayerSheets( + sheetShown = uiData.sheetShown, + subtitles = subtitles, + onAddSubtitle = viewModel::addSubtitle, + onSelectSubtitle = viewModel::selectSub, + audioTracks = audioTracks, + onAddAudio = viewModel::addAudio, + onSelectAudio = viewModel::selectAudio, + isLoadingHosters = uiData.isLoadingHosters, + hosterState = stateData.hosterState, + expandedState = uiData.hosterExpandedList, + selectedVideoIndex = uiData.selectedHosterVideoIndex, + onClickHoster = viewModel::onHosterClicked, + onClickVideo = viewModel::onVideoClicked, + displayHosters = Pair(showFailedHosters, emptyHosters), + chapter = stateData.chapters.getOrNull(currentChapter ?: 0), + chapters = stateData.chapters, + onSeekToChapter = viewModel::selectChapter, + decoder = decoder, + onUpdateDecoder = { viewModel.setPropertyString("hwdec", it.value) }, + pitchCorrection = pitchCorrection || mpvAudioPitchCorrection == true, + onPitchCorrectionChange = { + audioPreferences.enablePitchCorrection.set(it) + viewModel.setPropertyBoolean("audio-pitch-correction", it) + }, + speed = playbackSpeed ?: playerPreferences.playerSpeed.get(), + speedPresets = speedPresets.map { it.toFloat() }.sorted(), + onSpeedChange = { + viewModel.setPropertyFloat("speed", it.toFixed(2)) + }, + onAddSpeedPreset = { playerPreferences.speedPresets += it.toFixed(2).toString() }, + onRemoveSpeedPreset = { playerPreferences.speedPresets -= it.toFixed(2).toString() }, + onResetSpeedPresets = playerPreferences.speedPresets::delete, + onMakeDefaultSpeed = { playerPreferences.playerSpeed.set(it.toFixed(2)) }, + onResetDefaultSpeed = { + viewModel.setPropertyFloat("speed", playerPreferences.playerSpeed.deleteAndGet().toFixed(2)) + }, + statisticsPage = statisticsPage, + audioChannels = audioChannels, + sleepTimerTimeRemaining = playbackData.remainingTime, + onStartSleepTimer = viewModel::startTimer, + onStatisticsPageChange = { page -> + if ((page == 0) xor + (statisticsPage == 0) + ) { + viewModel.mpvCommand("script-binding", "stats/display-stats-toggle") + } + if (page != 0) viewModel.mpvCommand("script-binding", "stats/display-page-$page") + advancedPreferences.playerStatisticsPage.set(page) + }, + onAudioChannelsChange = { + audioPreferences.audioChannels.set(it) + if (it == AudioChannels.ReverseStereo) { + viewModel.setPropertyString(AudioChannels.AutoSafe.property, AudioChannels.AutoSafe.value) + } else { + viewModel.setPropertyString(AudioChannels.ReverseStereo.property, "") + } + viewModel.setPropertyString(it.property, it.value) + }, + onCustomButtonClick = viewModel::executeButton, + onCustomButtonLongClick = viewModel::executeLongPressButton, + buttons = uiData.customButtons, + isLocalSource = stateData.currentSource?.isLocal() == true, + showSubtitles = showSubtitles, + onToggleShowSubtitles = { subtitlePreferences.screenshotSubtitles.set(it) }, + onSetAsArt = viewModel::setAsArt, + onShare = viewModel::shareImage, + onSave = viewModel::saveImage, + takeScreenshot = viewModel::takeScreenshot, + onDismissScreenshot = { + viewModel.setSheet(Sheets.None) + viewModel.unpause() + }, + onOpenPanel = viewModel::setPanel, + onDismissRequest = { viewModel.setSheet(Sheets.None) }, + dismissSheet = uiData.dismissSheet, + ) + + // Panels + val subDelayPref by subtitlePreferences.subtitlesDelay.collectAsState() + val subDelaySecondaryPref by subtitlePreferences.subtitlesSecondaryDelay.collectAsState() + val deband by decoderPreferences.debanding.collectAsState() + var subtitleColorType by remember { mutableStateOf(SubColorType.Text) } + + val subDelay by viewModel.propFlow("sub-delay").collectAsState() + val subDelaySecondary by viewModel.propFlow("secondary-sub-delay").collectAsState() + val subSpeed by viewModel.propFlow("sub-speed").collectAsState() + val audioDelay by viewModel.propFlow("audio-delay").collectAsState() + val isBold by viewModel.propFlow("sub-bold").collectAsState() + val isItalic by viewModel.propFlow("sub-italic").collectAsState() + val subJustify by viewModel.propFlow("sub-justify").collectAsState() + val subFont by viewModel.propFlow("sub-font").collectAsState() + val subFontSize by viewModel.propFlow("sub-font-size").collectAsState() + val subBorderStyle by viewModel.propFlow("sub-border-style").collectAsState() + val subBorderSize by viewModel.propFlow("sub-outline-size").collectAsState() + val subShadowOffset by viewModel.propFlow("sub-shadow-offset").collectAsState() + val subColor by viewModel.propFlow("sub-color").collectAsState() + val subBorderColor by viewModel.propFlow("sub-outline-color").collectAsState() + val subBackgroundColor by viewModel.propFlow("sub-back-color").collectAsState() + val overrideAssSubs by viewModel.propFlow("sub-ass-override").collectAsState() + val subScale by viewModel.propFlow("sub-scale").collectAsState() + val subPos by viewModel.propFlow("sub-pos").collectAsState() + val mpvGpuNext by viewModel.propFlow("vo").collectAsState() + val debandSettingsMap = DebandSettings.entries.associateWith { setting -> + viewModel.propFlow(setting.mpvProperty).collectAsState().value ?: 0 + } + val filterValuesMap = VideoFilters.entries.associateWith { filter -> + viewModel.propFlow(filter.mpvProperty).collectAsState().value ?: 0 + } + + PlayerPanels( + panelShown = uiData.panelShown, + onDismissRequest = { viewModel.setPanel(Panels.None) }, + + // Subtitle settings panel state + isBold = isBold ?: subtitlePreferences.boldSubtitles.get(), + isItalic = isItalic ?: subtitlePreferences.italicSubtitles.get(), + subJustify = subJustify?.let { + SubtitleJustification.byValue(it) + } ?: subtitlePreferences.subtitleJustification.get(), + subFont = subFont ?: subtitlePreferences.subtitleFont.get(), + subFontList = uiData.fontList, + subFontSize = subFontSize ?: subtitlePreferences.subtitleFontSize.get(), + subBorderStyle = subBorderStyle?.let { SubtitlesBorderStyle.byValue(it) } + ?: subtitlePreferences.borderStyleSubtitles.get(), + subBorderSize = subBorderSize ?: subtitlePreferences.subtitleBorderSize.get(), + subShadowOffset = subShadowOffset ?: subtitlePreferences.shadowOffsetSubtitles.get(), + subColor = subtitleColorType, + currentSubtitleColor = when (subtitleColorType) { + SubColorType.Text -> subColor?.toColorInt() ?: subtitlePreferences.textColorSubtitles.get() + SubColorType.Border -> subBorderColor?.toColorInt() + ?: subtitlePreferences.borderColorSubtitles.get() + SubColorType.Background -> subBackgroundColor?.toColorInt() + ?: subtitlePreferences.backgroundColorSubtitles.get() + }, + overrideAssSubs = overrideAssSubs?.let { SubtitleAssOverride.byValue(it) } + ?: subtitlePreferences.overrideSubsASS.get(), + subScale = subScale ?: subtitlePreferences.subtitleFontScale.get(), + subPos = subPos ?: subtitlePreferences.subtitlePos.get(), + onSubBoldChange = { + viewModel.setPropertyBoolean("sub-bold", it) + subtitlePreferences.boldSubtitles.set(it) + }, + onSubItalicChange = { + viewModel.setPropertyBoolean("sub-italic", it) + subtitlePreferences.italicSubtitles.set(it) + }, + onSubJustifyChange = { + viewModel.setPropertyString("sub-justify", it.value) + subtitlePreferences.subtitleJustification.set(it) + }, + onSubFontChange = { + viewModel.setPropertyString("sub-font", it) + subtitlePreferences.subtitleFont.set(it) + }, + onSubFontSizeChange = { + viewModel.setPropertyInt("sub-font-size", it) + subtitlePreferences.subtitleFontSize.set(it) + }, + onSubBorderStyleChange = { + viewModel.setPropertyString("sub-border-style", it.value) + subtitlePreferences.borderStyleSubtitles.set(it) + }, + onSubBorderSizeChange = { + viewModel.setPropertyInt("sub-outline-size", it) + subtitlePreferences.subtitleBorderSize.set(it) + }, + onSubShadowOffsetChange = { + viewModel.setPropertyInt("sub-shadow-offset", it) + subtitlePreferences.shadowOffsetSubtitles.set(it) + }, + onSubColorChange = { + when (subtitleColorType) { + SubColorType.Text -> { + viewModel.setPropertyString("sub-color", it.toColorHexString()) + subtitlePreferences.textColorSubtitles.set(it) + } + + SubColorType.Border -> { + viewModel.setPropertyString("sub-outline-color", it.toColorHexString()) + subtitlePreferences.borderColorSubtitles.set(it) + } + + SubColorType.Background -> { + viewModel.setPropertyString("sub-back-color", it.toColorHexString()) + subtitlePreferences.backgroundColorSubtitles.set(it) + } + } + }, + onOverrideAssSubsChange = { + viewModel.setPropertyString("sub-ass-override", it.value) + subtitlePreferences.overrideSubsASS.set(it) + }, + onSubScaleChange = { + viewModel.setPropertyFloat("sub-scale", it) + subtitlePreferences.subtitleFontScale.set(it) + }, + onSubPosChange = { + viewModel.setPropertyInt("sub-pos", it) + subtitlePreferences.subtitlePos.set(it) + }, + onSubColorTypeChange = { subtitleColorType = it }, + onSubColorReset = { + resetColors( + preferences = subtitlePreferences, + setStringValue = viewModel::setPropertyString, + type = subtitleColorType, + ) + }, + onSubtitleSettingsReset = { + resetTypography( + setStringValue = viewModel::setPropertyString, + setIntValue = viewModel::setPropertyInt, + setBooleanValue = viewModel::setPropertyBoolean, + preferences = subtitlePreferences, + ) + }, + onSubtitleMiscReset = { + subtitlePreferences.subtitlePos.deleteAndGet().let { + viewModel.setPropertyInt("sub-pos", it) + } + subtitlePreferences.subtitleFontScale.deleteAndGet().let { + viewModel.setPropertyFloat("sub-scale", it) + } + subtitlePreferences.overrideSubsASS.deleteAndGet().let { + viewModel.setPropertyString("sub-ass-override", it.value) + } + }, + subDelayMsPrimary = subDelay?.times(1000)?.roundToInt() ?: subDelayPref, + subDelayMsSecondary = subDelaySecondary?.times(1000)?.roundToInt() ?: subDelaySecondaryPref, + subSpeed = subSpeed ?: subtitlePreferences.subtitlesSpeed.get().toDouble(), + onSubDelayPrimaryChange = { + viewModel.setPropertyDouble("sub-delay", it / 1000.0) + }, + onSubDelaySecondaryChange = { + viewModel.setPropertyDouble("secondary-sub-delay", it / 1000.0) + }, + onSubSpeedChange = { + viewModel.setPropertyDouble("sub-speed", it) + }, + onSubDelayApply = { + subtitlePreferences.subtitlesDelay.set((subDelay?.times(1000)?.roundToInt()) ?: 0) + subtitlePreferences.subtitlesSecondaryDelay.set((subDelaySecondary?.times(1000)?.roundToInt()) ?: 0) + }, + onSubDelayReset = { + viewModel.setPropertyDouble("sub-delay", subtitlePreferences.subtitlesDelay.get() / 1000.0) + viewModel.setPropertyDouble( + "secondary-sub-delay", + subtitlePreferences.subtitlesSecondaryDelay.get() / 1000.0, + ) + viewModel.setPropertyDouble("sub-speed", subtitlePreferences.subtitlesSpeed.get().toDouble()) + }, + audioDelayMs = (audioDelay?.times(1000))?.roundToInt() ?: audioPreferences.audioDelay.get(), + onAudioDelayChange = { viewModel.setPropertyDouble("audio-delay", it / 1000.0) }, + onAudioDelayApply = { + audioPreferences.audioDelay.set((audioDelay?.times(1000)?.roundToInt()) ?: 0) + }, + onAudioDelayReset = { + viewModel.setPropertyDouble("audio-delay", audioPreferences.audioDelay.get() / 1000.0) + }, + onDebandChange = { + decoderPreferences.debanding.set(it) + when (it) { + Debanding.None -> { + viewModel.setPropertyString("deband", "no") + viewModel.mpvCommand("vf", "remove", "@deband") + } + + Debanding.CPU -> { + viewModel.setPropertyString("deband", "no") + viewModel.mpvCommand("vf", "add", "@deband:gradfun=radius=12") + } + + Debanding.GPU -> { + viewModel.setPropertyString("deband", "yes") + viewModel.mpvCommand("vf", "remove", "@deband") + } + } + }, + onDebandReset = { + viewModel.setPropertyString("deband", "no") + viewModel.mpvCommand("vf", "remove", "@deband") + DebandSettings.entries.forEach { + viewModel.setPropertyInt(it.mpvProperty, it.preference(decoderPreferences).deleteAndGet()) + } + }, + onDebandSettingsChange = { setting, value -> + setting.preference(decoderPreferences).set(value) + viewModel.setPropertyInt(setting.mpvProperty, value) + }, + onVideoFilterChange = { filter, value -> + filter.preference(decoderPreferences).set(value) + viewModel.setPropertyInt(filter.mpvProperty, value) + }, + onFilterReset = { + VideoFilters.entries.forEach { + viewModel.setPropertyInt(it.mpvProperty, it.preference(decoderPreferences).deleteAndGet()) + } + }, + deband = deband, + isGpuNextEnabled = mpvGpuNext == "gpu-next", + filterValue = { filterValuesMap[it] ?: 0 }, + debandSettings = { debandSettingsMap[it] ?: 0 }, + modifier = Modifier, + ) + + // Dialogs + val relativeTime by uiPreferences.relativeTime.collectAsState() + val dateFormat by uiPreferences.dateFormat.collectAsState() + + PlayerDialogs( + dialogShown = uiData.dialogShown, + episodeDisplayMode = stateData.currentAnime?.displayMode, + currentEpisodeIndex = stateData.currentPlaylistIndex, + episodeList = stateData.currentPlaylist, + dateRelativeTime = relativeTime, + dateFormat = dateFormat, + onBookmarkClicked = viewModel::bookmarkEpisode, + onFillermarkClicked = viewModel::fillermarkEpisode, + onEpisodeClicked = { + viewModel.setDialog(Dialogs.None) + viewModel.changeEpisode(it) + }, + onDismissRequest = { viewModel.setDialog(Dialogs.None) }, + ) + } + } +} 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 435348dd6a..5e5ca00119 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 @@ -1,34 +1,17 @@ -/* - * Copyright 2024 Abdallah Mehiz - * https://github.com/abdallahmehiz/mpvKt - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* - * Code is a mix between PlayerViewModel from mpvKt and the former - * PlayerViewModel from Aniyomi. - */ - package eu.kanade.tachiyomi.ui.player import android.app.Application +import android.content.pm.ActivityInfo import android.net.Uri +import android.view.KeyEvent +import androidx.compose.runtime.Stable +import androidx.core.net.toUri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.yubyf.truetypeparser.TTFFile import dev.icerock.moko.resources.StringResource +import dev.vivvvek.seeker.Segment import eu.kanade.domain.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.connection.SyncPreferences @@ -36,7 +19,6 @@ import eu.kanade.domain.episode.model.toDbEpisode import eu.kanade.domain.source.interactor.GetIncognitoState import eu.kanade.domain.track.interactor.TrackEpisode import eu.kanade.domain.track.service.TrackPreferences -import eu.kanade.domain.ui.UiPreferences import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.animesource.model.ChapterType import eu.kanade.tachiyomi.animesource.model.Hoster @@ -56,7 +38,6 @@ import eu.kanade.tachiyomi.data.saver.Location import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList -import eu.kanade.tachiyomi.ui.player.PlayerActivity.Companion.MPV_DIR import eu.kanade.tachiyomi.ui.player.controls.components.IndexedSegment import eu.kanade.tachiyomi.ui.player.controls.components.sheets.HosterState import eu.kanade.tachiyomi.ui.player.controls.components.sheets.getChangedAt @@ -65,7 +46,13 @@ import eu.kanade.tachiyomi.ui.player.domain.BrightnessManager import eu.kanade.tachiyomi.ui.player.domain.TrackSelect import eu.kanade.tachiyomi.ui.player.loader.EpisodeLoader import eu.kanade.tachiyomi.ui.player.loader.HosterLoader +import eu.kanade.tachiyomi.ui.player.mpv.ChapterNode +import eu.kanade.tachiyomi.ui.player.mpv.MPVPlayer +import eu.kanade.tachiyomi.ui.player.mpv.TrackNode +import eu.kanade.tachiyomi.ui.player.mpv.TrackState +import eu.kanade.tachiyomi.ui.player.mpv.VideoTrack import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences +import eu.kanade.tachiyomi.ui.player.settings.DecoderPreferences import eu.kanade.tachiyomi.ui.player.settings.GesturePreferences import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences import eu.kanade.tachiyomi.ui.player.settings.SubtitlePreferences @@ -80,31 +67,26 @@ import eu.kanade.tachiyomi.util.lang.byteSize import eu.kanade.tachiyomi.util.lang.takeBytes import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.cacheImageDir -import `is`.xyz.mpv.MPV import `is`.xyz.mpv.MPVNode import `is`.xyz.mpv.Utils -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource @@ -137,249 +119,403 @@ import java.io.File import java.io.InputStream import java.util.Date import java.util.concurrent.atomic.AtomicBoolean -import kotlin.collections.first +import kotlin.collections.distinctBy +import kotlin.collections.orEmpty import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.time.Duration.Companion.seconds class PlayerViewModel @JvmOverloads constructor( private val context: Application, private val savedState: SavedStateHandle, private val json: Json = Injekt.get(), - private val sourceManager: SourceManager = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val storageManager: StorageManager = Injekt.get(), - private val imageSaver: ImageSaver = Injekt.get(), - private val downloadPreferences: DownloadPreferences = Injekt.get(), - private val trackPreferences: TrackPreferences = Injekt.get(), - private val trackEpisode: TrackEpisode = Injekt.get(), + private val getAnime: GetAnime = Injekt.get(), private val getNextEpisodes: GetNextEpisodes = Injekt.get(), private val getEpisodesByAnimeId: GetEpisodesByAnimeId = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), private val getTracks: GetTracks = Injekt.get(), + private val getIncognitoState: GetIncognitoState = Injekt.get(), + private val upsertHistory: UpsertHistory = Injekt.get(), private val updateEpisode: UpdateEpisode = Injekt.get(), + private val trackEpisode: TrackEpisode = Injekt.get(), private val setAnimeViewerFlags: SetAnimeViewerFlags = Injekt.get(), + + private val imageSaver: ImageSaver = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), + private val trackerManager: TrackerManager = Injekt.get(), + + private val basePreferences: BasePreferences = Injekt.get(), + private val libraryPreferences: LibraryPreferences = Injekt.get(), + private val downloadPreferences: DownloadPreferences = Injekt.get(), + private val trackPreferences: TrackPreferences = Injekt.get(), private val playerPreferences: PlayerPreferences = Injekt.get(), + private val decoderPreferences: DecoderPreferences = Injekt.get(), + private val gesturePreferences: GesturePreferences = Injekt.get(), private val audioPreferences: AudioPreferences = Injekt.get(), private val subtitlePreferences: SubtitlePreferences = Injekt.get(), - private val gesturePreferences: GesturePreferences = Injekt.get(), - private val basePreferences: BasePreferences = Injekt.get(), private val getCustomButtons: GetCustomButtons = Injekt.get(), private val trackSelect: TrackSelect = Injekt.get(), - private val getIncognitoState: GetIncognitoState = Injekt.get(), - private val libraryPreferences: LibraryPreferences = Injekt.get(), private val audioManager: AudioManager = Injekt.get(), - brightnessManager: BrightnessManager = Injekt.get(), + private val brightnessManager: BrightnessManager = Injekt.get(), // AM (SYNC) --> private val syncPreferences: SyncPreferences = Injekt.get(), // <-- AM (SYNC) - uiPreferences: UiPreferences = Injekt.get(), ) : AndroidViewModel(context) { + val videoOutput = if (decoderPreferences.gpuNext.get()) "gpu-next" else "gpu" + + val player = MPVPlayer(context, videoOutput) + val mpv = player.mpv + + // Prefs + private val reduceMotion = playerPreferences.reduceMotion.get() + private val playerTimeToDisappearMs = playerPreferences.playerTimeToDisappear.get() + private val swapVolumeAndBrightness = gesturePreferences.swapVolumeBrightness.get() + private val boostCap = audioPreferences.volumeBoostCap.get() + private val displayVolumeAsPercentage = playerPreferences.displayVolPer.get() + private val showLoadingCircle = playerPreferences.showLoadingCircle.get() + private val invertDuration = playerPreferences.invertDuration.get() + private val smoothSeeking = gesturePreferences.playerSmoothSeek.get() + private val showChapterIndicator = playerPreferences.showCurrentChapter.get() + + private val aniSkipEnabled = playerPreferences.aniSkipEnabled.get() + private val disableAniSkipOnChapters = playerPreferences.disableAniSkipOnChapters.get() + private val introSkipEnabled = playerPreferences.enableSkipIntro.get() + private val autoSkip = playerPreferences.autoSkipIntro.get() + private val netflixStyle = playerPreferences.enableNetflixStyleIntroSkip.get() + private val defaultWaitingTime = playerPreferences.waitingTimeIntroSkip.get() - val cachePath: String = context.applicationContext.cacheDir.path - val mpv = MPV(context.applicationContext) { - it.setOptionString("config", "yes") - it.setOptionString("config-dir", context.filesDir.resolve(MPV_DIR).toString()) - it.setOptionString("gpu-shader-cache-dir", cachePath) - it.setOptionString("icc-cache-dir", cachePath) - it.setOptionString("keep-open", "yes") + private val maxVolume = audioManager.getMaxVolume() + private val screenAspectRatio: Double by lazy { + val metrics = context.resources.displayMetrics + metrics.widthPixels.toDouble() / metrics.heightPixels.toDouble() } - private val _isStopped = MutableStateFlow(false) - val isStopped = _isStopped.asStateFlow() - - private val _currentPlaylist = MutableStateFlow>(persistentListOf()) - val currentPlaylist = _currentPlaylist.asStateFlow() - - private val _hasPreviousEpisode = MutableStateFlow(false) - val hasPreviousEpisode = _hasPreviousEpisode.asStateFlow() - - private val _hasNextEpisode = MutableStateFlow(false) - val hasNextEpisode = _hasNextEpisode.asStateFlow() - - private val _currentEpisode = MutableStateFlow(null) - val currentEpisode = _currentEpisode.asStateFlow() - - private val _currentAnime = MutableStateFlow(null) - val currentAnime = _currentAnime.asStateFlow() - - private val _currentSource = MutableStateFlow(null) - val currentSource = _currentSource.asStateFlow() + private val _stateData = MutableStateFlow( + PlayerStateData( + maxVolume = maxVolume, + ), + ) + val stateData = _stateData.asStateFlow() + private val _uiData = MutableStateFlow( + PlayerUiData( + reduceMotion = reduceMotion, + playerTimeToDisappearMs = playerTimeToDisappearMs, + swapVolumeAndBrightness = swapVolumeAndBrightness, + boostCap = boostCap, + displayVolumeAsPercentage = displayVolumeAsPercentage, + showLoadingCircle = showLoadingCircle, + invertDuration = invertDuration, + smoothSeeking = smoothSeeking, + showChapterIndicator = showChapterIndicator, + ), + ) + val uiData = _uiData.asStateFlow() + private val _playbackData = MutableStateFlow( + PlayerPlaybackData( + currentVolume = if (playerPreferences.rememberPlayerVolume.get()) { + playerPreferences.playerVolumeValue.get().takeUnless { it == -1 } + ?: audioManager.getVolume() + } else { + audioManager.getVolume() + }, + currentBrightness = if (playerPreferences.rememberPlayerBrightness.get()) { + playerPreferences.playerBrightnessValue.get().takeUnless { it == -1f } + ?: brightnessManager.getCurrentBrightness() + } else { + brightnessManager.getCurrentBrightness() + }, + ), + ) + val playbackData = _playbackData.asStateFlow() - private val _isEpisodeOnline = MutableStateFlow(false) - val isEpisodeOnline = _isEpisodeOnline.asStateFlow() + private val _aspectRatio = MutableStateFlow(null) + val aspectRatio = _aspectRatio.asStateFlow() - private val _isLoadingEpisode = MutableStateFlow(false) - val isLoadingEpisode = _isLoadingEpisode.asStateFlow() + private val _eventFlow = MutableSharedFlow() + val eventFlow = _eventFlow.asSharedFlow() - val mediaTitle = MutableStateFlow("") - val animeTitle = MutableStateFlow("") + private var timerJob: Job? = null - val isLoading = MutableStateFlow(true) - val hasLoadedTracks = MutableStateFlow(false) - val hasLoadedSubs = MutableStateFlow(false) - val hasLoadedAudio = MutableStateFlow(false) + init { + viewModelScope.launchIO { + getCustomButtons.subscribeAll().collectLatest { buttons -> + setupCustomButtons(buttons) + } + } - private val _externalSubtitleTracks = MutableStateFlow>(emptyList()) - val externalSubtitleTracks = _externalSubtitleTracks.asStateFlow() + viewModelScope.launchIO { + subtitlePreferences.subtitleSystemFonts.changes().collectLatest { fonts -> + updateUiData { it.copy(fontList = fetchFonts(fonts)) } + } + } - private val _externalAudioTracks = MutableStateFlow>(emptyList()) - val externalAudioTracks = _externalAudioTracks.asStateFlow() + viewModelScope.launch { + player.eventFlow + .onEach { handlePlayerFlow(it) } + .launchIn(viewModelScope) + + playerPreferences.autoplayEnabled.changes() + .onEach { v -> updateUiData { it.copy(autoPlayEnabled = v) } } + .launchIn(viewModelScope) + + playerPreferences.playerSpeed.changes() + .onEach { v -> updateUiData { it.copy(playerSpeedPref = v) } } + .launchIn(viewModelScope) + + combine( + propFlow("video-params/aspect"), + propFlow("video-params/rotate"), + ) { aspect, rotation -> aspect to rotation } + .onEach { (aspect, rotation) -> + _aspectRatio.update { _ -> + aspect?.let { + if (it < 0.001) return@update 0.0 + if ((rotation ?: 0) % 180 == 90) 1.0 / it else it + } + } + } + .launchIn(viewModelScope) + + propFlow("video-params/w") + .filterNotNull() + .onEach { v -> updateStateData { it.copy(videoWidth = v) } } + .launchIn(viewModelScope) + + propFlow("video-params/h") + .filterNotNull() + .onEach { v -> updateStateData { it.copy(videoHeight = v) } } + .launchIn(viewModelScope) + + propFlow("track-list") + .filterNotNull() + .onEach { onTrackListChanged(it) } + .launchIn(viewModelScope) + + propFlow("chapter-list") + .filterNotNull() + .onEach { onChapterListChanged(it) } + .launchIn(viewModelScope) + + propFlow("chapter") + .onEach { onChapterChanged(it) } + .launchIn(viewModelScope) + + propFlow("duration") + .filterNotNull() + .onEach { v -> + updatePlaybackData { it.copy(duration = v) } + } + .launchIn(viewModelScope) - private val _hosterList = MutableStateFlow>(emptyList()) - val hosterList = _hosterList.asStateFlow() - private val _isLoadingHosters = MutableStateFlow(true) - val isLoadingHosters = _isLoadingHosters.asStateFlow() - private val _hosterState = MutableStateFlow>(emptyList()) - val hosterState = _hosterState.asStateFlow() - private val _hosterExpandedList = MutableStateFlow>(emptyList()) - val hosterExpandedList = _hosterExpandedList.asStateFlow() - private val _selectedHosterVideoIndex = MutableStateFlow(Pair(-1, -1)) - val selectedHosterVideoIndex = _selectedHosterVideoIndex.asStateFlow() - private val _currentVideo = MutableStateFlow(null) - val currentVideo = _currentVideo.asStateFlow() + propFlow("time-pos") + .filterNotNull() + .onEach { onSecondReached(it) } + .launchIn(viewModelScope) - private val _pausedState = MutableStateFlow(false) - val pausedState = _pausedState.asStateFlow() + propFlow("pause") + .filterNotNull() + .onEach { v -> + updatePlaybackData { it.copy(paused = v) } + } + .launchIn(viewModelScope) - private val netflixTimeout = MutableStateFlow(null) + propFlow("volume-max") + .filterNotNull() + .onEach { v -> + updateStateData { it.copy(volumeBoostCap = v) } + } + .launchIn(viewModelScope) - // Start mpvKt + propFlow("sid") + .onEach { onSubtitleTrackSelectChange() } + .launchIn(viewModelScope) - private val _customButtons = MutableStateFlow>(persistentListOf()) - val customButtons = _customButtons.asStateFlow() + propFlow("secondary-sid") + .onEach { onSubtitleTrackSelectChange() } + .launchIn(viewModelScope) - private val _primaryButtonTitle = MutableStateFlow("") - val primaryButtonTitle = _primaryButtonTitle.asStateFlow() + propFlow("user-data/current-anime/intro-length") + .filterNotNull() + .onEach { setAnimeSkipIntroLength(it) } + .launchIn(viewModelScope) + } + } - private val _primaryButton = MutableStateFlow(null) - val primaryButton = _primaryButton.asStateFlow() + fun isPlayerExiting(): Boolean { + return player.isExiting + } - val paused by mpv.propFlow("pause").collectAsState(viewModelScope) - val pos by mpv.propFlow("time-pos").collectAsState(viewModelScope) - val duration by mpv.propFlow("duration").collectAsState(viewModelScope) + fun setPlayerExiting(value: Boolean) { + player.isExiting = value + } - val currentVolume = MutableStateFlow(audioManager.getVolume()) - private val volumeBoostCap by mpv.propFlow("volume-max").collectAsState(viewModelScope) + private fun updateStateData(update: (PlayerStateData) -> PlayerStateData) { + _stateData.update { update(it) } + } - val subtitleTracks = mpv.propFlow("track-list") - .map { node -> - ( - node?.toObject>(json) - ?.filter { it.isSubtitle } - ?.filterNot { it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true } - ?: persistentListOf() - ).toImmutableList() - } + private fun updateUiData(update: (PlayerUiData) -> PlayerUiData) { + _uiData.update { update(it) } + } - val audioTracks = mpv.propFlow("track-list") - .map { node -> - ( - node?.toObject>(json) - ?.filter { it.isAudio } - ?.filterNot { it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true } - ?: persistentListOf() - ).toImmutableList() - } + private fun updatePlaybackData(update: (PlayerPlaybackData) -> PlayerPlaybackData) { + _playbackData.update { update(it) } + } - val chapters = mpv.propFlow("chapter-list") - .map { (it?.toObject>(json) ?: persistentListOf()).map { it.toSegment() }.toImmutableList() } + inline fun propFlow(name: String): StateFlow { + return mpv.propFlow(name) + } - private val _skipIntroText = MutableStateFlow(null) - val skipIntroText = _skipIntroText.asStateFlow() + fun setPropertyBoolean(property: String, value: Boolean) { + mpv.setPropertyBoolean(property, value) + } - private val _controlsShown = MutableStateFlow(true) - val controlsShown = _controlsShown.asStateFlow() - private val _seekBarShown = MutableStateFlow(true) - val seekBarShown = _seekBarShown.asStateFlow() - private val _areControlsLocked = MutableStateFlow(false) - val areControlsLocked = _areControlsLocked.asStateFlow() + fun setPropertyInt(property: String, value: Int) { + mpv.setPropertyInt(property, value) + } - val playerUpdate = MutableStateFlow(PlayerUpdates.None) - val isBrightnessSliderShown = MutableStateFlow(false) - val isVolumeSliderShown = MutableStateFlow(false) - val currentBrightness = MutableStateFlow(brightnessManager.getCurrentBrightness()) + fun setPropertyFloat(property: String, value: Float) { + mpv.setPropertyFloat(property, value) + } - val sheetShown = MutableStateFlow(Sheets.None) - val panelShown = MutableStateFlow(Panels.None) - val dialogShown = MutableStateFlow(Dialogs.None) - private val _dismissSheet = MutableStateFlow(false) - val dismissSheet = _dismissSheet.asStateFlow() + fun setPropertyDouble(property: String, value: Double) { + mpv.setPropertyDouble(property, value) + } - // Pair(startingPosition, seekAmount) - val gestureSeekAmount = MutableStateFlow?>(null) - - private val _seekText = MutableStateFlow(null) - val seekText = _seekText.asStateFlow() - private val _doubleTapSeekAmount = MutableStateFlow(0) - val doubleTapSeekAmount = _doubleTapSeekAmount.asStateFlow() - private val _isSeekingForwards = MutableStateFlow(false) - val isSeekingForwards = _isSeekingForwards.asStateFlow() + fun setPropertyString(property: String, value: String) { + mpv.setPropertyString(property, value) + } - private var timerJob: Job? = null - private val _remainingTime = MutableStateFlow(0) - val remainingTime = _remainingTime.asStateFlow() + fun setPropertyNode(property: String, value: MPVNode) { + mpv.setPropertyNode(property, value) + } - private val _fontList = MutableStateFlow>(persistentListOf()) - val fontList = _fontList.asStateFlow() + fun mpvCommand(vararg command: String) { + mpv.command(*command) + } - init { - viewModelScope.launchIO { - subtitlePreferences.subtitleSystemFonts.changes().collectLatest { - _fontList.update { _ -> fetchFonts(it).toPersistentList() } + fun handlePlayerEvent(event: PlayerEvent) { + when (event) { + PlayerEvent.ChangeAspect -> { + cycleAspectRatio() + } + is PlayerEvent.ChangeSpeed -> { + setSpeed(event.value) + } + PlayerEvent.CycleRotation -> { + cycleRotations() + } + PlayerEvent.EnterPip -> { + viewModelScope.launch { + _eventFlow.emit(Event.EnterPip) + } + } + is PlayerEvent.ExecuteCustomButton -> { + uiData.value.primaryButton?.let { + if (event.long) { + executeLongPressButton(it) + } else { + executeButton(it) + } + } + } + is PlayerEvent.LockControls -> { + updateUiData { it.copy(isControlsLocked = event.lock) } + } + is PlayerEvent.NextEpisode -> { + nextEpisode(event.next) + } + PlayerEvent.PlayPause -> { + pauseUnpause() + } + is PlayerEvent.Seek -> { + updatePlaybackData { it.copy(isSeeking = true) } + seekTo(event.position) + } + PlayerEvent.SeekFinished -> { + updatePlaybackData { it.copy(isSeeking = false) } + } + is PlayerEvent.SetAutoPlay -> { + setAutoPlay(event.value) + } + is PlayerEvent.SetPanel -> { + setPanel(event.panel) + } + is PlayerEvent.SetSheet -> { + setSheet(event.sheet) + } + is PlayerEvent.ShowBrightnessSlider -> { + displayBrightnessSlider(event.show) + } + PlayerEvent.ShowEpisodeDialog -> { + updateUiData { it.copy(dialogShown = Dialogs.EpisodeList) } + } + is PlayerEvent.ShowPlayerUpdate -> { + updateUiData { it.copy(playerUpdate = event.update) } + } + is PlayerEvent.ShowVolumeSlider -> { + displayVolumeSlider(event.show) + } + PlayerEvent.SkipIntro -> { + onSkipIntro() + } + PlayerEvent.ToggleDurationTimer -> { + val newValue = !uiData.value.invertDuration + playerPreferences.invertDuration.set(newValue) + updateUiData { it.copy(invertDuration = newValue) } } } + } - mpv.propFlow("time-pos") - .filterNotNull() - .onEach(::onSecondReached) - .launchIn(viewModelScope) - - mpv.propFlow("chapter") - .onEach(::onChapterChanged) - .launchIn(viewModelScope) - - mpv.propFlow("sid") - .onEach { onSubtitleTrackSelectChange() } - .launchIn(viewModelScope) + fun handlePlayerFlow(event: MPVPlayer.Event) { + when (event) { + is MPVPlayer.Event.EOF -> eofReached(event.value) + is MPVPlayer.Event.EndFile -> endFile(event.node) + MPVPlayer.Event.FileLoaded -> fileLoaded() + is MPVPlayer.Event.LuaEvent -> handleLuaInvocation(event.property, event.value) + is MPVPlayer.Event.TrackLoadFailure -> onTrackLoadedFailure(event.url) + } + } - mpv.propFlow("secondary-sid") - .onEach { onSubtitleTrackSelectChange() } - .launchIn(viewModelScope) + // === Setup === - mpv.propFlow("user-data/current-anime/intro-length") - .filterNotNull() - .onEach(::setAnimeSkipIntroLength) - .launchIn(viewModelScope) + /** + * The position in the current video. Used to restore from process kill. + */ + private var episodePosition = savedState.get("episode_position") + set(value) { + savedState["episode_position"] = value + field = value + } - mpv.propFlow("track-list") - .filterNotNull() - .onEach { onTrackListChanged(it) } - .launchIn(viewModelScope) - } + /** + * The current video's quality index. Used to restore from process kill. + */ + private var qualityIndex = savedState.get>("quality_index") ?: Pair(-1, -1) + set(value) { + savedState["quality_index"] = value + field = value + } /** - * Starts a sleep timer/cancels the current timer if [seconds] is less than 1. + * The episode id of the currently loaded episode. Used to restore from process kill. */ - fun startTimer(seconds: Int) { - timerJob?.cancel() - _remainingTime.value = seconds - if (seconds < 1) return - timerJob = viewModelScope.launch { - for (time in seconds downTo 0) { - _remainingTime.value = time - delay(1000) - } - mpv.setPropertyBoolean("pause", true) - eventChannel.send(Event.ShowToast(AYMR.strings.toast_sleep_timer_ended)) + private var episodeId = savedState.get("episode_id") ?: -1L + set(value) { + savedState["episode_id"] = value + field = value } - } + private val fontExtensionRegex = Regex($$""".*\.[ot]tf$""") fun fetchFonts(includeSystemFonts: Boolean): List { val fontFiles = mutableListOf() storageManager.getFontsDirectory()?.listFiles()?.filter { file -> - file.name?.lowercase()?.matches(FONT_EXTENSION_REGEX) == true + file.name?.lowercase()?.matches(fontExtensionRegex) == true }?.mapNotNull { try { TTFFile.open(it.openInputStream()).families.values.first() @@ -404,7 +540,7 @@ class PlayerViewModel @JvmOverloads constructor( if (dir.exists() && dir.isDirectory) { val files = dir.listFiles() files?.filter { file -> - file.isFile && file.name.lowercase().matches(FONT_EXTENSION_REGEX) + file.isFile && file.name.lowercase().matches(fontExtensionRegex) }?.forEach { file -> try { fontFiles.add( @@ -418,1356 +554,1716 @@ class PlayerViewModel @JvmOverloads constructor( return fontFiles.distinct() } - private fun setCustomButtons(buttons: List) { - _customButtons.update { _ -> buttons.toPersistentList() } - buttons.firstOrNull { it.isFavorite }?.let { - _primaryButton.update { _ -> it } - if (primaryButtonTitle.value.isEmpty()) { - setPrimaryCustomButtonTitle(it) - } - } - } - - fun isEpisodeOnline(): Boolean? { - val anime = currentAnime.value ?: return null - val episode = currentEpisode.value ?: return null - val source = currentSource.value ?: return null - return source is AnimeHttpSource && - !EpisodeLoader.isDownload( - episode.toDomainEpisode()!!, - anime, - ) - } + // === Initialize === - fun clearTracks() { - hasLoadedTracks.update { _ -> false } - _externalAudioTracks.update { _ -> emptyList() } - _externalSubtitleTracks.update { _ -> emptyList() } + fun updateIsLoadingHosters(value: Boolean) { + updateUiData { it.copy(isLoadingHosters = value) } } fun updateIsLoadingEpisode(value: Boolean) { - _isLoadingEpisode.update { _ -> value } - } - - private fun updateEpisodeList(episodeList: List) { - _currentPlaylist.update { _ -> filterEpisodeList(episodeList).toPersistentList() } + updateUiData { it.copy(isLoadingEpisode = value) } } /** - * When all subtitle/audio tracks are loaded, select the preferred one based on preferences, - * or select the first one in the list if trackSelect fails. + * Whether this viewModel is initialized with the correct episode. */ - fun onTrackListChanged(tracks: MPVNode) { - val tracks = tracks.toObject>(json).ifEmpty { return } - if (hasLoadedTracks.value) { - onTrackAdded(tracks) - } else { - hasLoadedTracks.update { _ -> true } - onTracksLoaded(tracks) + private fun needsInit(animeId: Long, episodeId: Long): Boolean { + return stateData.value.let { + it.currentAnime?.id != animeId || it.currentEpisode?.id != episodeId } } - private fun onTrackAdded(tracks: List) { - val externalSubtitle = tracks.filter { - it.isSubtitle && it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true - } - val externalAudio = tracks.filter { - it.isAudio && it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true - } + data class InitResult( + val hosterList: List?, + val videoIndex: Pair, + val position: Long?, + ) - externalSubtitle.forEach { track -> - val idx = track.title!!.split("=")[1].toInt() - val external = externalSubtitleTracks.value[idx] + private var currentHosterList: List? = null - if (external.id != null) { - // External subtitle has already been added - return@forEach - } + class ExceptionWithStringResource( + message: String, + val stringResource: StringResource, + ) : Exception(message) - updateSubtitleTrackAt(idx) { - it.copy(id = track.id, state = TrackState.Loaded) - } - hasLoadedSubs.update { _ -> true } - checkFileLoaded() - selectSubById(track.id) - } + suspend fun init( + animeId: Long, + initialEpisodeId: Long, + hostList: String, + hostIndex: Int, + vidIndex: Int, + ): Pair> { + val defaultResult = InitResult(currentHosterList, qualityIndex, null) + if (!needsInit(animeId, initialEpisodeId)) return Pair(defaultResult, Result.success(true)) - externalAudio.forEach { track -> - val idx = track.title!!.split("=")[1].toInt() - val external = externalAudioTracks.value[idx] + return try { + getAnime.await(animeId)?.let { anime -> + sourceManager.isInitialized.first { it } + val source = sourceManager.getOrStub(anime.source) + val incognito = getIncognitoState.await(anime.source) - if (external.id != null) { - // External audio has already been added - return@forEach - } + updateStateData { it.copy(currentAnime = anime, currentSource = source, incognitoMode = incognito) } + updateUiData { it.copy(animeTitle = anime.title) } + episodeId = initialEpisodeId - updateAudioTrackAt(idx) { - it.copy(id = track.id, state = TrackState.Loaded) - } - hasLoadedAudio.update { _ -> true } - checkFileLoaded() - selectAudioById(track.id, false) - } - } + setupTrackers(anime.id) + setupEpisodeList(anime) - /** - * Called when embedded tracks are first loaded - */ - private fun onTracksLoaded(tracks: List) { - val embeddedSubs = tracks.filter { it.isSubtitle } - val embeddedAudio = tracks.filter { it.isAudio } - val externalSubs = currentVideo.value?.subtitleTracks.orEmpty().distinctBy { it.url } - .mapIndexed { idx, track -> VideoTrack.External(track, idx) } - val externalAudio = currentVideo.value?.audioTracks.orEmpty().distinctBy { it.url } - .mapIndexed { idx, track -> VideoTrack.External(track, idx) } + val episode = stateData.value.currentPlaylist.firstOrNull { it.id == episodeId } + ?: throw ExceptionWithStringResource("No episode loaded", AYMR.strings.no_episode_loaded) + setupEpisode(episode) - _externalSubtitleTracks.update { _ -> externalSubs } - _externalAudioTracks.update { _ -> externalAudio } + // Write to mpv table + val parentTitle = anime.parentId?.let { getAnime.await(it)?.title } ?: "" + setPropertyString("user-data/current-anime/anime-title", anime.title) + setPropertyString("user-data/current-anime/parent-title", parentTitle) + setPropertyInt("user-data/current-anime/intro-length", getAnimeSkipIntroLength()) + setPropertyString( + "user-data/current-anime/category", + getCategories.await(anime.id).joinToString { + it.name + }, + ) - val preferredSubtitle = trackSelect.getPreferredTrackIndex( - tracks = embeddedSubs.map { VideoTrack.Internal(it) } + externalSubs, - subtitle = true, - ) - if (preferredSubtitle == null) { - hasLoadedSubs.update { _ -> true } - } else { - selectSub(preferredSubtitle) - } + // Load hosters + if (hostList.isNotBlank()) { + currentHosterList = hostList.toHosterList().ifEmpty { + currentHosterList = null + throw ExceptionWithStringResource( + "Hoster selected from empty list", + AYMR.strings.select_hoster_from_empty_list, + ) + } + qualityIndex = Pair(hostIndex, vidIndex) + } else { + EpisodeLoader.getHosters(episode.toDomainEpisode()!!, anime, source) + .takeIf { it.isNotEmpty() } + ?.also { currentHosterList = it } + ?: run { + currentHosterList = null + throw ExceptionWithStringResource("Hoster list is empty", AYMR.strings.no_hosters) + } + } - val preferredAudio = trackSelect.getPreferredTrackIndex( - tracks = embeddedAudio.map { VideoTrack.Internal(it) } + externalAudio, - subtitle = false, - ) - if (preferredAudio == null) { - hasLoadedAudio.update { _ -> true } - } else { - selectAudio(preferredAudio, true) - } - } + val result = InitResult( + hosterList = currentHosterList, + videoIndex = qualityIndex, + position = episodePosition, + ) - fun addAudio(uri: Uri) { - val url = uri.toString() - val isContentUri = url.startsWith("content://") - val path = (if (isContentUri) uri.openContentFd(context.applicationContext) else url) - ?: return - val name = if (isContentUri) uri.getFileName(context.applicationContext) else null - if (name == null) { - mpv.command("audio-add", path, "cached") - } else { - mpv.command("audio-add", path, "cached", name) + Pair(result, Result.success(true)) + } ?: Pair(defaultResult, Result.success(false)) // Unlikely but okay + } catch (e: Throwable) { + Pair(defaultResult, Result.failure(e)) } } - fun addSubtitle(uri: Uri) { - val url = uri.toString() - val isContentUri = url.startsWith("content://") - val path = (if (isContentUri) uri.openContentFd(context.applicationContext) else url) - ?: return - val name = if (isContentUri) uri.getFileName(context.applicationContext) else null - if (name == null) { - mpv.command("sub-add", path, "cached") - } else { - mpv.command("sub-add", path, "cached", name) - } - } + private fun setupCustomButtons(buttons: List) { + val primaryButton = buttons.firstOrNull { it.isFavorite } - fun selectSub(track: VideoTrack) { - when (track) { - is VideoTrack.External -> { - if (track.id == null) { - updateSubtitleTrackAt(track.index) { - it.copy(state = TrackState.Loading) - } - viewModelScope.launchIO { - mpv.command( - "sub-add", - track.data.url, - "auto", - "${VideoTrack.TRACK_TITLE_TAG}=${track.index}", - ) - } + updateUiData { + it.copy( + customButtons = buttons, + primaryButton = primaryButton ?: it.primaryButton, + primaryButtonTitle = if (it.primaryButtonTitle.isEmpty() && primaryButton != null) { + primaryButton.name } else { - hasLoadedSubs.update { _ -> true } - checkFileLoaded() - selectSubById(track.id) - } - } - is VideoTrack.Internal -> { - hasLoadedSubs.update { _ -> true } - checkFileLoaded() - selectSubById(track.data.id) - } + it.primaryButtonTitle + }, + ) } } - fun selectAudio(track: VideoTrack, force: Boolean = false) { - when (track) { - is VideoTrack.External -> { - if (track.id == null) { - updateAudioTrackAt(track.index) { - it.copy(state = TrackState.Loading) - } - viewModelScope.launchIO { - mpv.command( - "audio-add", - track.data.url, - "auto", - "${VideoTrack.TRACK_TITLE_TAG}=${track.index}", - ) - } + private suspend fun setupTrackers(animeId: Long) { + val tracks = getTracks.await(animeId) + updateStateData { it.copy(hasTrackers = tracks.isNotEmpty()) } + } + + private suspend fun setupEpisodeList(anime: Anime) { + val episodes = getEpisodesByAnimeId.await(anime.id) + .sortedWith(getEpisodeSort(anime, sortDescending = false)) + .run { + if (basePreferences.downloadedOnly.get()) { + filterDownloaded(anime) } else { - hasLoadedAudio.update { _ -> true } - checkFileLoaded() - selectAudioById(track.id, force) + this } } - is VideoTrack.Internal -> { - hasLoadedAudio.update { _ -> true } - checkFileLoaded() - selectAudioById(track.data.id, force) - } - } - } + .map { it.toDbEpisode() } - fun onTrackLoadedFailure(url: String) { - val subtitleIdx = externalSubtitleTracks.value.indexOfFirst { - it.data.url == url - } - if (subtitleIdx != -1) { - updateSubtitleTrackAt(subtitleIdx) { - it.copy(state = TrackState.Error) - } - hasLoadedSubs.update { _ -> true } - checkFileLoaded() - } - val audioIdx = externalAudioTracks.value.indexOfFirst { - it.data.url == url - } - if (audioIdx != -1) { - updateAudioTrackAt(audioIdx) { - it.copy(state = TrackState.Error) - } - hasLoadedAudio.update { _ -> true } - checkFileLoaded() - } - } + val selectedEpisode = episodes.find { it.id == episodeId } + ?: error("Requested episode of id $episodeId not found in episode list") - fun onSubtitleTrackSelectChange() { - val id = mpv.getPropertyInt("sid") - val sid = mpv.getPropertyInt("secondary-sid") + val filtered = episodes.filterNot { + (anime.unseenFilterRaw == Anime.EPISODE_SHOW_SEEN && !it.seen) || + (anime.unseenFilterRaw == Anime.EPISODE_SHOW_UNSEEN && it.seen) || + ( + anime.downloadedFilterRaw == Anime.EPISODE_SHOW_DOWNLOADED && + !downloadManager.isEpisodeDownloaded( + it.name, + it.scanlator, + it.url, + // AM (CUSTOM_INFORMATION) --> + anime.ogTitle, + // <-- AM (CUSTOM_INFORMATION) + anime.source, + ) + ) || + ( + anime.downloadedFilterRaw == Anime.EPISODE_SHOW_NOT_DOWNLOADED && + downloadManager.isEpisodeDownloaded( + it.name, + it.scanlator, + it.url, + // AM (CUSTOM_INFORMATION) --> + anime.ogTitle, + // <-- AM (CUSTOM_INFORMATION) + anime.source, + ) + ) || + ( + anime.bookmarkedFilterRaw == Anime.EPISODE_SHOW_BOOKMARKED && + !it.bookmark + ) || + ( + anime.bookmarkedFilterRaw == Anime.EPISODE_SHOW_NOT_BOOKMARKED && + it.bookmark + ) || + ( + anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_FILLERMARKED && + !it.fillermark + ) || + ( + anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_NOT_FILLERMARKED && + it.fillermark + ) + }.toMutableList() - _externalSubtitleTracks.update { subtitleTracks -> - subtitleTracks.map { - it.copy( - mainSelection = when (it.id) { - null -> -1 - id -> 0 - sid -> 1 - else -> -1 - }, - ) - } + if (filtered.all { it.id != episodeId }) { + filtered += listOf(selectedEpisode) } - } - private fun selectSubById(id: Int) { - val selectedSubs = Pair(mpv.getPropertyInt("sid"), mpv.getPropertyInt("secondary-sid")) - when (id) { - selectedSubs.first -> Pair(selectedSubs.second, null) - selectedSubs.second -> Pair(selectedSubs.first, null) - else -> if (selectedSubs.first != null) Pair(selectedSubs.first, id) else Pair(id, null) - }.let { - it.second?.let { mpv.setPropertyInt("secondary-sid", it) } - ?: mpv.setPropertyBoolean("secondary-sid", false) - it.first?.let { mpv.setPropertyInt("sid", it) } ?: mpv.setPropertyBoolean("sid", false) - } + updateStateData { it.copy(currentPlaylist = filtered.toList()) } } - private fun selectAudioById(id: Int, force: Boolean) { - if (!force && id == mpv.getPropertyInt("aid")) { - mpv.setPropertyBoolean("aid", false) - } else { - mpv.setPropertyInt("aid", id) - } - } + private fun isEpisodeOnline(): Boolean? { + val currentState = stateData.value - private fun updateSubtitleTrackAt(index: Int, transform: (VideoTrack.External) -> VideoTrack.External) { - _externalSubtitleTracks.update { externalSubtitles -> - externalSubtitles.toMutableList().apply { - this[index] = transform(this[index]) - } - } + val anime = currentState.currentAnime ?: return null + val episode = currentState.currentEpisode ?: return null + val source = currentState.currentSource ?: return null + return source is AnimeHttpSource && + !EpisodeLoader.isDownload( + episode.toDomainEpisode()!!, + anime, + ) } - private fun updateAudioTrackAt(index: Int, transform: (VideoTrack.External) -> VideoTrack.External) { - _externalAudioTracks.update { externalAudio -> - externalAudio.toMutableList().apply { - this[index] = transform(this[index]) - } - } - } + private fun setupEpisode(episode: Episode) { + val currentState = stateData.value - fun getChapterCount(): Int { - return mpv.getPropertyInt("chapter-list/count") ?: 0 - } + val currentEpisodeIndex = currentState.currentPlaylist.indexOfFirst { + episode.id == it.id + } - fun addTimestamps(timestamps: List) { - if (timestamps.isEmpty()) return - val current = ( - mpv.getPropertyNode("chapter-list") - ?.toObject>(json) ?: emptyList() + updateStateData { + it.copy( + currentEpisode = episode, + currentPlaylistIndex = currentEpisodeIndex, + isEpisodeOnline = isEpisodeOnline() == true, + hasPreviousEpisode = currentEpisodeIndex != 0, + hasNextEpisode = currentEpisodeIndex != currentState.currentPlaylist.size - 1, ) - .map { IndexedSegment(name = it.chapterTitle, start = it.time, index = 0) } - val merged = ChapterUtils.mergeChapters(current, timestamps, duration) - - val node = MPVNode.ArrayNode( - merged.map { c -> - MPVNode.MapNode( - value = mapOf( - "time" to MPVNode.DoubleNode(c.start.toDouble()), - "title" to MPVNode.StringNode(c.name), - ), - ) - }.toTypedArray(), - ) - mpv.setPropertyNode("chapter-list", node) - } + } - private fun updatePausedState() { - if (pausedState.value == null) { - _pausedState.update { _ -> paused } + updateUiData { + it.copy(mediaTitle = episode.name) } + + setPropertyDouble("user-data/current-anime/episode-number", episode.episode_number.toDouble()) } - /** - * Check when file has loaded and see if the player can be (un)paused. - * - * If external subs/audio tracks was selected, wait until mpv has fetched them. - */ - fun checkFileLoaded() { - if (isLoadingEpisode.value && hasLoadedSubs.value && hasLoadedAudio.value) { - _isLoadingEpisode.update { _ -> false } - pausedState.value?.let { - if (it) { - pause() - } else { - unpause() - } - _pausedState.update { _ -> null } + fun setupPlayerOrientation() { + if (player.isExiting) return + val orientation = when (playerPreferences.defaultPlayerOrientationType.get()) { + PlayerOrientation.Free -> ActivityInfo.SCREEN_ORIENTATION_SENSOR + PlayerOrientation.Video -> if ((aspectRatio.value ?: 0.0) > 1.0) { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } else { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT } + PlayerOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + PlayerOrientation.ReversePortrait -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + PlayerOrientation.SensorPortrait -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + PlayerOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + PlayerOrientation.ReverseLandscape -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + PlayerOrientation.SensorLandscape -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } - } - fun pauseUnpause() = mpv.command("cycle", "pause") - fun pause() = mpv.setPropertyBoolean("pause", true) - fun unpause() = mpv.setPropertyBoolean("pause", false) + updatePlaybackData { it.copy(currentOrientation = orientation) } + } - private val showStatusBar = playerPreferences.showSystemStatusBar.get() - fun showControls() { - if (sheetShown.value != Sheets.None || - panelShown.value != Panels.None || - dialogShown.value != Dialogs.None - ) { - return - } - if (showStatusBar) { - viewModelScope.launch { - eventChannel.send(Event.SetStatusBar(true)) + private fun cycleRotations() { + val orientation = when (playbackData.value.currentOrientation) { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, + -> { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + } + else -> { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } } - _controlsShown.update { true } + updatePlaybackData { it.copy(currentOrientation = orientation) } } - fun hideControls() { - viewModelScope.launch { - eventChannel.send(Event.SetStatusBar(false)) - } - _controlsShown.update { false } - } + // === Load === - fun hideSeekBar() { - _seekBarShown.update { false } - } + private var getHosterVideoLinksJob: Job? = null - fun showSeekBar() { - if (sheetShown.value != Sheets.None) return - _seekBarShown.update { true } + fun cancelHosterVideoLinksJob() { + getHosterVideoLinksJob?.cancel() } - fun lockControls() { - _areControlsLocked.update { true } - } + fun loadHosters(hosterList: List, hosterIndex: Int, videoIndex: Int) { + val hasFoundPreferredVideo = AtomicBoolean(false) - fun unlockControls() { - _areControlsLocked.update { false } - } + updateStateData { it.copy(hosterList = hosterList) } + updateUiData { it.copy(hosterExpandedList = List(hosterList.size) { true }) } - fun dismissSheet() { - _dismissSheet.update { _ -> true } - } + val source = stateData.value.currentSource + ?: throw Exception("No source available") - private fun resetDismissSheet() { - _dismissSheet.update { _ -> false } - } + getHosterVideoLinksJob?.cancel() + getHosterVideoLinksJob = viewModelScope.launchIO { + updateStateData { + it.copy( + hosterState = hosterList.map { hoster -> + if (hoster.lazy) { + HosterState.Idle(hoster.hosterName) + } else if (hoster.videoList == null) { + HosterState.Loading(hoster.hosterName) + } else { + val videoList = hoster.videoList!! + HosterState.Ready( + hoster.hosterName, + videoList, + List(videoList.size) { Video.State.QUEUE }, + ) + } + }, + ) + } - fun showSheet(sheet: Sheets) { - sheetShown.update { sheet } - if (sheet == Sheets.None) { - resetDismissSheet() - showControls() - } else { - hideControls() - panelShown.update { Panels.None } - dialogShown.update { Dialogs.None } - } - } + try { + coroutineScope { + hosterList.mapIndexed { hosterIdx, hoster -> + async { + val hosterState = EpisodeLoader.loadHosterVideos(source, hoster) - fun showPanel(panel: Panels) { - panelShown.update { panel } - if (panel == Panels.None) { - showControls() - } else { - hideControls() - sheetShown.update { Sheets.None } - dialogShown.update { Dialogs.None } - } - } + updateHosterStateAt(hosterIdx, hosterState) - fun showDialog(dialog: Dialogs) { - dialogShown.update { dialog } - if (dialog == Dialogs.None) { - showControls() - } else { - hideControls() - sheetShown.update { Sheets.None } - panelShown.update { Panels.None } - } - } - - fun seekBy(offset: Int, precise: Boolean = false) { - mpv.command("seek", offset.toString(), if (precise) "relative+exact" else "relative") - } + if (hosterState is HosterState.Ready) { + if (hosterIdx == hosterIndex) { + hosterState.videoList.getOrNull(videoIndex)?.let { + hasFoundPreferredVideo.set(true) + val success = loadVideo(it, hosterIndex, videoIndex) + if (!success) { + hasFoundPreferredVideo.set(false) + } + } + } - fun seekTo(position: Int, precise: Boolean = true) { - if (position !in 0..(mpv.getPropertyInt("duration") ?: 0)) return - mpv.command("seek", position.toString(), if (precise) "absolute" else "absolute+keyframes") - } + val prefIndex = hosterState.videoList.indexOfFirst { it.preferred } + if (prefIndex != -1 && hosterIndex == -1) { + if (hasFoundPreferredVideo.compareAndSet(false, true)) { + if (uiData.value.selectedHosterVideoIndex == Pair(-1, -1)) { + val success = + loadVideo( + hosterState.videoList[prefIndex], + hosterIdx, + prefIndex, + ) + if (!success) { + hasFoundPreferredVideo.set(false) + } + } + } + } + } + } + }.awaitAll() - fun changeBrightnessTo( - brightness: Float, - ) { - currentBrightness.update { _ -> brightness.coerceIn(-0.75f, 1f) } - viewModelScope.launch { - eventChannel.send(Event.SetBrightness(brightness.coerceIn(0f, 1f))) - } - } + if (hasFoundPreferredVideo.compareAndSet(false, true)) { + val (hosterIdx, videoIdx) = HosterLoader.selectBestVideo(stateData.value.hosterState) + if (hosterIdx == -1) { + throw ExceptionWithStringResource("No available videos", AYMR.strings.no_available_videos) + } - fun displayBrightnessSlider() { - isBrightnessSliderShown.update { true } - } + val video = (stateData.value.hosterState[hosterIdx] as HosterState.Ready).videoList[videoIdx] + loadVideo(video, hosterIdx, videoIdx) + } + } + } catch (e: CancellationException) { + updateStateData { + it.copy( + hosterState = it.hosterList.map { h -> + HosterState.Idle(h.hosterName) + }, + ) + } - val maxVolume = audioManager.getMaxVolume() - fun changeVolumeBy(change: Int) { - val mpvVolume = mpv.getPropertyInt("volume") - if ((volumeBoostCap ?: audioPreferences.volumeBoostCap.get()) > 0 && currentVolume.value == maxVolume) { - if (mpvVolume == 100 && change < 0) changeVolumeTo(currentVolume.value + change) - val finalMPVVolume = (mpvVolume?.plus(change))?.coerceAtLeast(100) ?: 100 - if (finalMPVVolume in 100..(volumeBoostCap ?: audioPreferences.volumeBoostCap.get()) + 100) { - changeMPVVolumeTo(finalMPVVolume) - return + throw e } } - changeVolumeTo(currentVolume.value + change) - } - - fun changeVolumeTo(volume: Int) { - val newVolume = volume.coerceIn(0..maxVolume) - audioManager.setVolume(newVolume) - currentVolume.update { newVolume } - } - - fun changeMPVVolumeTo(volume: Int) { - mpv.setPropertyInt("volume", volume) - } - - fun displayVolumeSlider() { - isVolumeSliderShown.update { true } } - fun setAutoPlay(value: Boolean) { - val textRes = if (value) { - AYMR.strings.enable_auto_play - } else { - AYMR.strings.disable_auto_play + fun loadBestVideo(): Boolean { + val (hosterIdx, videoIdx) = HosterLoader.selectBestVideo(stateData.value.hosterState) + if (hosterIdx == -1) return false + val newVideo = (stateData.value.hosterState[hosterIdx] as HosterState.Ready).videoList[videoIdx] + viewModelScope.launchIO { + loadVideo(newVideo, hosterIdx, videoIdx) } - playerUpdate.update { PlayerUpdates.ShowTextResource(textRes) } - playerPreferences.autoplayEnabled.set(value) + return true } - @Suppress("DEPRECATION") - fun changeVideoAspect(aspect: VideoAspect) { - viewModelScope.launch { - eventChannel.send(Event.ChangeVideoAspect(aspect)) + /** + * Try and load a video + * + * returns true if successful, false if not + */ + private suspend fun loadVideo(video: Video, hosterIndex: Int, videoIndex: Int): Boolean { + val source = stateData.value.currentSource + ?: throw Exception("No source loaded") + val currentUi = uiData.value + val selectedHosterState = (stateData.value.hosterState[hosterIndex] as? HosterState.Ready) + ?: return false + + val oldSelectedIndex = currentUi.selectedHosterVideoIndex + updateUiData { + it.copy( + isLoadingEpisode = true, + selectedHosterVideoIndex = Pair(hosterIndex, videoIndex), + previousPauseState = it.previousPauseState ?: playbackData.value.paused, + ) + } + updateStateData { + it.copy( + hosterState = getHosterStateAt( + hosters = it.hosterState, + index = hosterIndex, + state = selectedHosterState.getChangedAt(videoIndex, video, Video.State.LOAD_VIDEO), + ), + ) } - } - fun setAspect(aspect: VideoAspect, pan: Double, ratio: Double) { - mpv.setPropertyDouble("panscan", pan) - mpv.setPropertyDouble("video-aspect-override", ratio) - playerPreferences.aspectState.set(aspect) - playerUpdate.update { PlayerUpdates.AspectRatio } - } + // Pause until everything has loaded + pause() - fun cycleScreenRotations() { - viewModelScope.launch { - eventChannel.send(Event.CycleRotations) + val resolvedVideo = if (selectedHosterState.videoState[videoIndex] != Video.State.READY) { + HosterLoader.getResolvedVideo(source, video) + } else { + video } - } - fun handleLuaInvocation(property: String, value: String) { - val data = value - .removePrefix("\"") - .removeSuffix("\"") - .ifEmpty { return } + if (resolvedVideo == null || resolvedVideo.videoUrl.isEmpty()) { + if (stateData.value.currentVideo == null) { + updateHosterStateAt(hosterIndex, selectedHosterState.getChangedAt(videoIndex, video, Video.State.ERROR)) - when (property.substringAfterLast("/")) { - "show_text" -> playerUpdate.update { PlayerUpdates.ShowText(data) } - "toggle_ui" -> { - when (data) { - "show" -> showControls() - "toggle" -> { - if (controlsShown.value) hideControls() else showControls() - } - "hide" -> { - sheetShown.update { Sheets.None } - panelShown.update { Panels.None } - dialogShown.update { Dialogs.None } - hideControls() - } - } - } - "show_panel" -> { - when (data) { - "subtitle_settings" -> showPanel(Panels.SubtitleSettings) - "subtitle_delay" -> showPanel(Panels.SubtitleDelay) - "audio_delay" -> showPanel(Panels.AudioDelay) - "video_filters" -> showPanel(Panels.VideoFilters) - } - } - "set_button_title" -> { - _primaryButtonTitle.update { _ -> data } - } - "reset_button_title" -> { - _customButtons.value.firstOrNull { it.isFavorite }?.let { - setPrimaryCustomButtonTitle(it) - } - } - "switch_episode" -> { - when (data) { - "n" -> changeEpisode(false) - "p" -> changeEpisode(true) - } - } - "launch_int_picker" -> { - val (title, nameFormat, start, stop, step, pickerProperty) = data.split("|") - val defaultValue = mpv.getPropertyInt(pickerProperty)!! - showDialog( - Dialogs.IntegerPicker( - defaultValue = defaultValue, - minValue = start.toInt(), - maxValue = stop.toInt(), - step = step.toInt(), - nameFormat = nameFormat, - title = title, - onChange = { mpv.setPropertyInt(pickerProperty, it) }, - onDismissRequest = { showDialog(Dialogs.None) }, - ), - ) - } - "show_seek_text" -> { - val (forward, text) = data.split("|", limit = 2) - showSeekText(forward == "true", text) - } - "pause" -> { - when (data) { - "pause" -> pause() - "unpause" -> unpause() - "pauseunpause" -> pauseUnpause() - } - } - "seek_to_with_text" -> { - val (seekValue, text) = data.split("|", limit = 2) - seekToWithText(seekValue.toInt(), text) - } - "seek_by_with_text" -> { - val (seekValue, text) = data.split("|", limit = 2) - seekByWithText(seekValue.toInt(), text) - } - "seek_by" -> seekByWithText(data.toInt(), null) - "seek_to" -> seekToWithText(data.toInt(), null) - "toggle_button" -> { - fun showButton() { - if (_primaryButton.value == null) { - _primaryButton.update { - customButtons.value.firstOrNull { it.isFavorite } - } + val (newHosterIdx, newVideoIdx) = HosterLoader.selectBestVideo(stateData.value.hosterState) + if (newHosterIdx == -1) { + if (stateData.value.hosterState.any { it is HosterState.Loading }) { + updateUiData { it.copy(selectedHosterVideoIndex = Pair(-1, -1)) } + return false + } else { + throw ExceptionWithStringResource("No available videos", AYMR.strings.no_available_videos) } } - when (data) { - "show" -> showButton() - "hide" -> _primaryButton.update { null } - "toggle" -> if (_primaryButton.value == null) showButton() else _primaryButton.update { null } + val newVideo = (stateData.value.hosterState[newHosterIdx] as HosterState.Ready).videoList[newVideoIdx] + return loadVideo(newVideo, newHosterIdx, newVideoIdx) + } else { + updateStateData { + it.copy( + hosterState = getHosterStateAt( + hosters = it.hosterState, + index = hosterIndex, + state = selectedHosterState.getChangedAt(videoIndex, video, Video.State.ERROR), + ), + ) } - } - "software_keyboard" -> { - viewModelScope.launch { - when (data) { - "show" -> eventChannel.send(Event.SetKeyboard(true)) - "hide" -> eventChannel.send(Event.SetKeyboard(false)) - "toggle" -> eventChannel.send(Event.ToggleKeyboard) - } + updateUiData { + it.copy( + selectedHosterVideoIndex = oldSelectedIndex, + ) } + return false } } - mpv.setPropertyString(property, "") - } - - private operator fun List.component6(): T = get(5) + updateHosterStateAt( + index = hosterIndex, + state = selectedHosterState.getChangedAt(videoIndex, resolvedVideo, Video.State.READY), + ) + updateStateData { it.copy(currentVideo = resolvedVideo) } - private val doubleTapToSeekDuration = gesturePreferences.skipLengthPreference.get() - private val preciseSeek = gesturePreferences.playerSmoothSeek.get() - private val showSeekBar = gesturePreferences.showSeekBar.get() + if (stateData.value.hasLoadedTracks) { + clearTracks() + } - private fun showSeekText(isForward: Boolean, text: String) { - _seekText.update { _ -> text } - _isSeekingForwards.update { _ -> isForward } - _doubleTapSeekAmount.update { _ -> if (isForward) 1 else -1 } - if (showSeekBar) showSeekBar() + qualityIndex = Pair(hosterIndex, videoIndex) + setVideo(resolvedVideo) + return true } - private fun seekToWithText(seekValue: Int, text: String?) { - _isSeekingForwards.value = seekValue > 0 - _doubleTapSeekAmount.value = seekValue - (pos ?: return) - _seekText.update { _ -> text } - seekTo(seekValue, preciseSeek) - if (showSeekBar) showSeekBar() - } + private fun setVideo(video: Video?) { + if (player.isExiting) return + if (video == null) return - private fun seekByWithText(value: Int, text: String?) { - _doubleTapSeekAmount.update { - if ((value < 0 && it < 0) || (pos ?: return) + value > (duration ?: return)) 0 else it + value - } - _seekText.update { text } - _isSeekingForwards.value = value > 0 - seekBy(value, preciseSeek) - if (showSeekBar) showSeekBar() - } + updateStateData { it.copy(isStopped = false) } + setHttpOptions(video) - fun updateSeekAmount(amount: Int) { - _doubleTapSeekAmount.update { _ -> amount } - } + if (uiData.value.isLoadingEpisode) { + stateData.value.currentEpisode?.let { episode -> + val preservePos = playerPreferences.preserveWatchingPosition.get() + val resumePosition = if (episode.seen && !preservePos) { + 0L + } else { + episode.last_second_seen + } + mpvCommand("set", "start", "${resumePosition / 1000F}") + } + } else { + mpvCommand("set", "start", playbackData.value.position.toString()) + } - fun updateSeekText(value: String?) { - _seekText.update { _ -> value } - } + // We handle selecting these in the viewmodel + val mpvOpts = listOf( + Pair("sid", "no"), + Pair("aid", "no"), + ) + val videoOptions = (video.mpvArgs + mpvOpts).joinToString(",") { (option, value) -> + "$option=\"$value\"" + } - fun leftSeek() { - if ((pos ?: return) > 0) _doubleTapSeekAmount.value -= doubleTapToSeekDuration - _isSeekingForwards.value = false - seekBy(-doubleTapToSeekDuration, preciseSeek) - if (showSeekBar) showSeekBar() + mpvCommand( + "loadfile", + parseVideoUrl(video.videoUrl)!!, + "replace", + "0", + videoOptions, + ) } - fun rightSeek() { - if ((pos ?: return) < (duration ?: return)) { - _doubleTapSeekAmount.value += doubleTapToSeekDuration - } - _isSeekingForwards.value = true - seekBy(doubleTapToSeekDuration, preciseSeek) - if (showSeekBar) showSeekBar() + private fun parseVideoUrl(videoUrl: String?): String? { + return videoUrl?.toUri()?.resolveUri(context) + ?: videoUrl } - fun resetHosterState() { - _pausedState.update { _ -> false } - _hosterState.update { _ -> emptyList() } - _hosterList.update { _ -> emptyList() } - _hosterExpandedList.update { _ -> emptyList() } - _selectedHosterVideoIndex.update { _ -> Pair(-1, -1) } - } + private fun setHttpOptions(video: Video) { + if (!stateData.value.isEpisodeOnline) return + val source = stateData.value.currentSource as? AnimeHttpSource + ?: return - fun changeEpisode(previous: Boolean, autoPlay: Boolean = false) { - viewModelScope.launch { - if (previous && !hasPreviousEpisode.value) { - eventChannel.send(Event.ShowToast(AYMR.strings.no_prev_episode)) - return@launch - } + val headers = (video.headers ?: source.headers) + .toMultimap() + .mapValues { it.value.firstOrNull() ?: "" } - if (!previous && !hasNextEpisode.value) { - eventChannel.send(Event.ShowToast(AYMR.strings.no_next_episode)) - return@launch - } + val httpHeaderString = headers.map { + it.key + ": " + it.value.replace(",", "\\,") + }.joinToString(",") - eventChannel.send( - Event.ChangeEpisode( - episodeId = getAdjacentEpisodeId(previous = previous), - autoPlay = autoPlay, - ), - ) - } + mpv.setOptionString("http-header-fields", httpHeaderString) } - fun handleLeftDoubleTap() { - when (gesturePreferences.leftDoubleTapGesture.get()) { - SingleActionGesture.Seek -> { - leftSeek() - } - SingleActionGesture.PlayPause -> { - pauseUnpause() - } - SingleActionGesture.Custom -> { - mpv.command("keypress", CustomKeyCodes.DoubleTapLeft.keyCode) - } - SingleActionGesture.None -> {} - SingleActionGesture.Switch -> changeEpisode(true) + private fun eofReached(eofReached: Boolean) { + if (eofReached && uiData.value.autoPlayEnabled) { + nextEpisode(next = true, autoplay = true) } } - fun handleCenterDoubleTap() { - when (gesturePreferences.centerDoubleTapGesture.get()) { - SingleActionGesture.PlayPause -> { - pauseUnpause() - } - SingleActionGesture.Custom -> { - mpv.command("keypress", CustomKeyCodes.DoubleTapCenter.keyCode) - } - SingleActionGesture.Seek -> {} - SingleActionGesture.None -> {} - SingleActionGesture.Switch -> {} - } - } + private fun endFile(node: MPVNode) { + val errorNode = node.asMap()?.get("file_error") ?: return + var errorMessage = errorNode.asString() ?: "Error: File ended" - fun handleRightDoubleTap() { - when (gesturePreferences.rightDoubleTapGesture.get()) { - SingleActionGesture.Seek -> { - rightSeek() - } - SingleActionGesture.PlayPause -> { - pauseUnpause() - } - SingleActionGesture.Custom -> { - mpv.command("keypress", CustomKeyCodes.DoubleTapRight.keyCode) - } - SingleActionGesture.None -> {} - SingleActionGesture.Switch -> changeEpisode(false) + val httpError = player.getHttpError() + if (!httpError.isNullOrEmpty()) { + errorMessage += ": $httpError" + player.resetHttpError() } - } - override fun onCleared() { - if (currentEpisode.value != null) { - saveWatchingProgress(currentEpisode.value!!) - episodeToDownload?.let { - downloadManager.addDownloadsToStartOfQueue(listOf(it)) + logcat(LogPriority.ERROR) { errorMessage } + _eventFlow.tryEmit(Event.ToastString(errorMessage)) + + setCurrentVideoError() + + if (playerPreferences.switchOnFailure.get()) { + if (!loadBestVideo()) { + viewModelScope.launch { _eventFlow.emit(Event.Finish) } } + } else { + updateStateData { it.copy(isStopped = true) } } } - // ====== OLD ====== + fun setCurrentVideoError() { + val (hosterIdx, videoIdx) = uiData.value.selectedHosterVideoIndex + val currentHosterState = (stateData.value.hosterState[hosterIdx] as? HosterState.Ready) ?: return + val currentVideo = currentHosterState.videoList[videoIdx] - private val eventChannel = Channel() - val eventFlow = eventChannel.receiveAsFlow() + updateHosterStateAt( + index = hosterIdx, + state = currentHosterState.getChangedAt(videoIdx, currentVideo, Video.State.ERROR), + ) + } - private val incognitoMode: Boolean by lazy { getIncognitoState.await(currentAnime.value?.source) } - private val downloadAheadAmount = downloadPreferences.autoDownloadWhileWatching.get() - - internal val relativeTime = uiPreferences.relativeTime.get() - internal val dateFormat = uiPreferences.dateFormat.get() + fun onVideoClicked(hosterIndex: Int, videoIndex: Int) { + val hosterState = stateData.value.hosterState[hosterIndex] as? HosterState.Ready + val video = hosterState?.videoList + ?.getOrNull(videoIndex) + ?: return // How did we get here? - /** - * The position in the current video. Used to restore from process kill. - */ - private var episodePosition = savedState.get("episode_position") - set(value) { - savedState["episode_position"] = value - field = value - } + val videoState = hosterState.videoState + .getOrNull(videoIndex) + ?: return - /** - * The current video's quality index. Used to restore from process kill. - */ - private var qualityIndex = savedState.get>("quality_index") ?: Pair(-1, -1) - set(value) { - savedState["quality_index"] = value - field = value + if (videoState == Video.State.ERROR) { + return } - /** - * The episode id of the currently loaded episode. Used to restore from process kill. - */ - private var episodeId = savedState.get("episode_id") ?: -1L - set(value) { - savedState["episode_id"] = value - field = value + viewModelScope.launchIO { + val success = loadVideo(video, hosterIndex, videoIndex) + if (success) { + if (uiData.value.sheetShown == Sheets.QualityTracks) { + dismissSheet() + } + } } + } - private var episodeToDownload: Download? = null + fun onHosterClicked(index: Int) { + when (stateData.value.hosterState[index]) { + is HosterState.Ready -> { + updateUiData { + it.copy( + hosterExpandedList = it.hosterExpandedList.toMutableList().apply { + this[index] = !it.hosterExpandedList[index] + }.toList(), + ) + } + } + is HosterState.Idle -> { + val source = stateData.value.currentSource + ?: throw Exception("Source not loaded") - private fun filterEpisodeList(episodes: List): List { - val anime = currentAnime.value ?: return episodes - val selectedEpisode = episodes.find { it.id == episodeId } - ?: error("Requested episode of id $episodeId not found in episode list") + val hosterName = stateData.value.hosterList[index].hosterName + updateHosterStateAt(index, HosterState.Loading(hosterName)) - val episodesForPlayer = episodes.filterNot { - (anime.unseenFilterRaw == Anime.EPISODE_SHOW_SEEN && !it.seen) || - (anime.unseenFilterRaw == Anime.EPISODE_SHOW_UNSEEN && it.seen) || - ( - anime.downloadedFilterRaw == Anime.EPISODE_SHOW_DOWNLOADED && - !downloadManager.isEpisodeDownloaded( - it.name, - it.scanlator, - it.url, - // AM (CUSTOM_INFORMATION) --> - anime.ogTitle, - // <-- AM (CUSTOM_INFORMATION) - anime.source, - ) - ) || - ( - anime.downloadedFilterRaw == Anime.EPISODE_SHOW_NOT_DOWNLOADED && - downloadManager.isEpisodeDownloaded( - it.name, - it.scanlator, - it.url, - // AM (CUSTOM_INFORMATION) --> - anime.ogTitle, - // <-- AM (CUSTOM_INFORMATION) - anime.source, - ) - ) || - ( - anime.bookmarkedFilterRaw == Anime.EPISODE_SHOW_BOOKMARKED && - !it.bookmark - ) || - ( - anime.bookmarkedFilterRaw == Anime.EPISODE_SHOW_NOT_BOOKMARKED && - it.bookmark - ) || - ( - anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_FILLERMARKED && - !it.fillermark - ) || - ( - anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_NOT_FILLERMARKED && - it.fillermark + viewModelScope.launchIO { + val hosterState = EpisodeLoader.loadHosterVideos( + source = source, + hoster = stateData.value.hosterList[index], + force = true, ) - }.toMutableList() - - if (episodesForPlayer.all { it.id != episodeId }) { - episodesForPlayer += listOf(selectedEpisode) + updateHosterStateAt(index, hosterState) + } + } + is HosterState.Error, is HosterState.Loading -> { } } - - return episodesForPlayer } - fun getCurrentEpisodeIndex(): Int { - return currentPlaylist.value.indexOfFirst { currentEpisode.value?.id == it.id } + private fun getHosterStateAt(hosters: List, index: Int, state: HosterState): List { + return hosters.toMutableList().apply { + this[index] = state + }.toList() } - private fun getAdjacentEpisodeId(previous: Boolean): Long { - val newIndex = if (previous) getCurrentEpisodeIndex() - 1 else getCurrentEpisodeIndex() + 1 - - return when { - previous && getCurrentEpisodeIndex() == 0 -> -1L - !previous && currentPlaylist.value.lastIndex == getCurrentEpisodeIndex() -> -1L - else -> currentPlaylist.value.getOrNull(newIndex)?.id ?: -1L + private fun updateHosterStateAt(index: Int, state: HosterState) { + updateStateData { + it.copy( + hosterState = getHosterStateAt(it.hosterState, index, state), + ) } } - fun updateHasNextEpisode(value: Boolean) { - _hasNextEpisode.update { _ -> value } - } + private fun fileLoaded() { + if (player.isExiting) return - fun updateHasPreviousEpisode(value: Boolean) { - _hasPreviousEpisode.update { _ -> value } - } + setMpvOptions() + setMpvMediaTitle() + setupChapters() + setupPlayerOrientation() + checkFileLoaded() - fun showEpisodeListDialog() { - if (currentAnime.value != null) { - showDialog(Dialogs.EpisodeList) + // AniSkip stuff + val chapterCount = stateData.value.chapters.size + viewModelScope.launchIO { + if (introSkipEnabled && aniSkipEnabled && !(disableAniSkipOnChapters && chapterCount > 0)) { + aniSkipResponse(playbackData.value.duration)?.let { + addTimeStamps(it) + } + } } } - /** - * Called when the activity is saved and not changing configurations. It updates the database - * to persist the current progress of the active episode. - */ - fun onSaveInstanceStateNonConfigurationChange() { - val currentEpisode = currentEpisode.value ?: return - viewModelScope.launchNonCancellable { - saveEpisodeProgress(currentEpisode) + private fun setMpvOptions() { + val video = stateData.value.currentVideo ?: return + + // Only check for `MPV_ARGS_TAG` on downloaded videos + if (listOf("file", "content", "data").none { video.videoUrl.startsWith(it) }) { + return } - } - // ====== Initialize anime, episode, hoster, and video list ====== + try { + val metadata = mpv.getPropertyNode("metadata")?.asMap() + ?: return - fun updateIsLoadingHosters(value: Boolean) { - _isLoadingHosters.update { _ -> value } - } + val opts = metadata[Video.MPV_ARGS_TAG] + ?.asString() + ?.split(";") + ?.map { it.split("=", limit = 2) } + ?: return - /** - * Whether this viewModel is initialized with the correct episode. - */ - private fun needsInit(animeId: Long, episodeId: Long): Boolean { - return currentAnime.value?.id != animeId || currentEpisode.value?.id != episodeId + opts.forEach { (option, value) -> + setPropertyString(option, value) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to read video metadata" } + } } - data class InitResult( - val hosterList: List?, - val videoIndex: Pair, - val position: Long?, - ) - - private var currentHosterList: List? = null - - class ExceptionWithStringResource( - message: String, - val stringResource: StringResource, - ) : Exception(message) - - suspend fun init( - animeId: Long, - initialEpisodeId: Long, - hostList: String, - hostIndex: Int, - vidIndex: Int, - ): Pair> { - val defaultResult = InitResult(currentHosterList, qualityIndex, null) - if (!needsInit(animeId, initialEpisodeId)) return Pair(defaultResult, Result.success(true)) - return try { - val anime = getAnime.await(animeId) - if (anime != null) { - _currentAnime.update { _ -> anime } - animeTitle.update { _ -> anime.title } - sourceManager.isInitialized.first { it } - episodeId = initialEpisodeId - - val buttons = getCustomButtons.getAll() - setCustomButtons(buttons) - - checkTrackers(anime) - - updateEpisodeList(initEpisodeList(anime)) - - val episode = currentPlaylist.value.first { it.id == episodeId } - val source = sourceManager.getOrStub(anime.source) + private fun setMpvMediaTitle() { + val anime = stateData.value.currentAnime ?: return + val episode = stateData.value.currentEpisode ?: return - _currentEpisode.update { _ -> episode } - _currentSource.update { _ -> source } + // Write to mpv table + setPropertyString("user-data/current-anime/episode-title", episode.name) - updateEpisode(episode) + val epNumber = episode.episode_number.let { number -> + if (ceil(number) == floor(number)) number.toInt() else number + }.toString().padStart(2, '0') - _hasPreviousEpisode.update { _ -> getCurrentEpisodeIndex() != 0 } - _hasNextEpisode.update { _ -> getCurrentEpisodeIndex() != currentPlaylist.value.size - 1 } + val title = context.stringResource( + AYMR.strings.mpv_media_title, + anime.title, + epNumber, + episode.name, + ) - // Write to mpv table - val parentTitle = anime.parentId?.let { getAnime.await(it)?.title } ?: "" - mpv.setPropertyString("user-data/current-anime/anime-title", anime.title) - mpv.setPropertyString("user-data/current-anime/parent-title", parentTitle) - mpv.setPropertyInt("user-data/current-anime/intro-length", getAnimeSkipIntroLength()) - mpv.setPropertyString( - "user-data/current-anime/category", - getCategories.await(anime.id).joinToString { - it.name - }, - ) + setPropertyString("force-media-title", title) + } - val currentEp = currentEpisode.value - ?: throw ExceptionWithStringResource("No episode loaded", AYMR.strings.no_episode_loaded) - if (hostList.isNotBlank()) { - currentHosterList = hostList.toHosterList().ifEmpty { - currentHosterList = null - throw ExceptionWithStringResource( - "Hoster selected from empty list", - AYMR.strings.select_hoster_from_empty_list, - ) - } - qualityIndex = Pair(hostIndex, vidIndex) + private fun setupChapters() { + val timeStamps = stateData.value.currentVideo?.timestamps?.takeIf { it.isNotEmpty() } + ?.map { timeStamp -> + if (timeStamp.name.isEmpty() && timeStamp.type != ChapterType.Other) { + timeStamp.copy( + name = timeStamp.type.getStringRes()?.let { context.stringResource(it) } ?: "", + ) } else { - EpisodeLoader.getHosters(currentEp.toDomainEpisode()!!, anime, source) - .takeIf { it.isNotEmpty() } - ?.also { currentHosterList = it } - ?: run { - currentHosterList = null - throw ExceptionWithStringResource("Hoster list is empty", AYMR.strings.no_hosters) - } + timeStamp } - - val result = InitResult( - hosterList = currentHosterList, - videoIndex = qualityIndex, - position = episodePosition, - ) - Pair(result, Result.success(true)) - } else { - // Unlikely but okay - Pair(defaultResult, Result.success(false)) } - } catch (e: Throwable) { - Pair(defaultResult, Result.failure(e)) - } + ?: return + + addTimeStamps(timeStamps) } - private fun updateEpisode(episode: Episode) { - mediaTitle.update { _ -> episode.name } - _isEpisodeOnline.update { _ -> isEpisodeOnline() == true } - mpv.setPropertyDouble("user-data/current-anime/episode-number", episode.episode_number.toDouble()) + private fun addTimeStamps(timeStamps: List) { + if (timeStamps.isEmpty()) return + + val current = ( + mpv.getPropertyNode("chapter-list") + ?.toObject>(json) ?: emptyList() + ) + .map { IndexedSegment(name = it.chapterTitle, start = it.time, index = 0) } + val merged = ChapterUtils.mergeChapters(current, timeStamps, playbackData.value.duration) + val node = MPVNode.ArrayNode( + merged.map { c -> + MPVNode.MapNode( + value = mapOf( + "time" to MPVNode.DoubleNode(c.start.toDouble()), + "title" to MPVNode.StringNode(c.name), + ), + ) + }.toTypedArray(), + ) + setPropertyNode("chapter-list", node) } - private fun initEpisodeList(anime: Anime): List { - val episodes = runBlocking { getEpisodesByAnimeId.await(anime.id) } + /** + * Check when file has loaded and see if the player can be (un)paused. + * + * If external subs/audio tracks was selected, wait until mpv has fetched them. + */ + private fun checkFileLoaded() { + if (uiData.value.isLoadingEpisode && stateData.value.hasLoadedSubs && stateData.value.hasLoadedAudio) { + uiData.value.previousPauseState?.let { shouldPause -> + if (shouldPause) pause() else unpause() + } - return episodes - .sortedWith(getEpisodeSort(anime, sortDescending = false)) - .run { - if (basePreferences.downloadedOnly.get()) { - filterDownloaded(anime) - } else { - this - } + updateUiData { + it.copy( + isLoadingEpisode = false, + previousPauseState = null, + ) } - .map { it.toDbEpisode() } + } } - private var hasTrackers: Boolean = false - private val checkTrackers: (Anime) -> Unit = { anime -> - val tracks = runBlocking { getTracks.await(anime.id) } - hasTrackers = tracks.isNotEmpty() + fun clearTracks() { + updateStateData { + it.copy( + externalSubtitleTracks = emptyList(), + externalAudioTracks = emptyList(), + hasLoadedTracks = false, + hasLoadedSubs = false, + hasLoadedAudio = false, + ) + } } - private var getHosterVideoLinksJob: Job? = null + /** + * When all subtitle/audio tracks are loaded, select the preferred one based on preferences, + * or select the first one in the list if trackSelect fails. + */ + fun onTrackListChanged(tracks: MPVNode) { + val tracks = tracks.toObject>(json).ifEmpty { return } + updateStateData { + it.copy( + subtitleTracks = tracks.filter { it.isSubtitle } + .filterNot { it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true }, + audioTracks = tracks.filter { it.isAudio } + .filterNot { it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true }, + ) + } - fun cancelHosterVideoLinksJob() { - getHosterVideoLinksJob?.cancel() + if (stateData.value.hasLoadedTracks) { + onTrackAdded(tracks) + } else { + updateStateData { it.copy(hasLoadedTracks = true) } + onTracksLoaded(tracks) + } } /** - * Set the video list for hosters. + * Called when a new track has been added to mpv + * + * Every new external tracks needs to be tracked internally */ - fun loadHosters(source: AnimeSource, hosterList: List, hosterIndex: Int, videoIndex: Int) { - val hasFoundPreferredVideo = AtomicBoolean(false) - - _hosterList.update { _ -> hosterList } - _hosterExpandedList.update { _ -> - List(hosterList.size) { true } + private fun onTrackAdded(tracks: List) { + val externalSubtitle = tracks.filter { + it.isSubtitle && it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true + } + val externalAudio = tracks.filter { + it.isAudio && it.title?.startsWith(VideoTrack.TRACK_TITLE_TAG) == true } - getHosterVideoLinksJob?.cancel() - getHosterVideoLinksJob = viewModelScope.launchIO { - _hosterState.update { _ -> - hosterList.map { hoster -> - if (hoster.lazy) { - HosterState.Idle(hoster.hosterName) - } else if (hoster.videoList == null) { - HosterState.Loading(hoster.hosterName) - } else { - val videoList = hoster.videoList!! - HosterState.Ready( - hoster.hosterName, - videoList, - List(videoList.size) { Video.State.QUEUE }, - ) + externalSubtitle.forEach { track -> + val idx = track.title!!.split("=")[1].toInt() + val external = stateData.value.externalSubtitleTracks[idx] + + if (external.id != null) { + // External subtitle has already been added + return@forEach + } + + updateSubtitleTrackAt(idx) { + it.copy(id = track.id, state = TrackState.Loaded) + } + updateStateData { it.copy(hasLoadedSubs = true) } + checkFileLoaded() + selectSubById(track.id) + } + + externalAudio.forEach { track -> + val idx = track.title!!.split("=")[1].toInt() + val external = stateData.value.externalAudioTracks[idx] + + if (external.id != null) { + // External audio has already been added + return@forEach + } + + updateAudioTrackAt(idx) { + it.copy(id = track.id, state = TrackState.Loaded) + } + updateStateData { it.copy(hasLoadedAudio = true) } + checkFileLoaded() + selectAudioById(track.id, false) + } + } + + /** + * Called when embedded tracks are first loaded + */ + private fun onTracksLoaded(tracks: List) { + val embeddedSubs = tracks.filter { it.isSubtitle } + val embeddedAudio = tracks.filter { it.isAudio } + val currentVideo = stateData.value.currentVideo + val externalSubs = currentVideo?.subtitleTracks.orEmpty().distinctBy { it.url } + .mapIndexed { idx, track -> VideoTrack.External(track, idx) } + val externalAudio = currentVideo?.audioTracks.orEmpty().distinctBy { it.url } + .mapIndexed { idx, track -> VideoTrack.External(track, idx) } + + updateStateData { + it.copy( + externalSubtitleTracks = externalSubs, + externalAudioTracks = externalAudio, + ) + } + + val preferredSubtitle = trackSelect.getPreferredTrackIndex( + tracks = embeddedSubs.map { VideoTrack.Internal(it) } + externalSubs, + subtitle = true, + ) + if (preferredSubtitle == null) { + updateStateData { it.copy(hasLoadedSubs = true) } + } else { + selectSub(preferredSubtitle) + } + + val preferredAudio = trackSelect.getPreferredTrackIndex( + tracks = embeddedAudio.map { VideoTrack.Internal(it) } + externalAudio, + subtitle = false, + ) + if (preferredAudio == null) { + updateStateData { it.copy(hasLoadedAudio = true) } + } else { + selectAudio(preferredAudio, true) + } + } + + private fun updateSubtitleTrackAt(index: Int, transform: (VideoTrack.External) -> VideoTrack.External) { + updateStateData { + it.copy( + externalSubtitleTracks = it.externalSubtitleTracks.toMutableList().apply { + this[index] = transform(this[index]) + }.toList(), + ) + } + } + + private fun updateAudioTrackAt(index: Int, transform: (VideoTrack.External) -> VideoTrack.External) { + updateStateData { + it.copy( + externalAudioTracks = it.externalAudioTracks.toMutableList().apply { + this[index] = transform(this[index]) + }.toList(), + ) + } + } + + fun addSubtitle(uri: Uri) { + val url = uri.toString() + val isContentUri = url.startsWith("content://") + val path = (if (isContentUri) uri.openContentFd(context) else url) + ?: return + val name = if (isContentUri) uri.getFileName(context) else null + if (name == null) { + mpvCommand("sub-add", path, "cached") + } else { + mpvCommand("sub-add", path, "cached", name) + } + } + + fun selectSub(track: VideoTrack) { + when (track) { + is VideoTrack.External -> { + if (track.id == null) { + updateSubtitleTrackAt(track.index) { + it.copy(state = TrackState.Loading) + } + viewModelScope.launchIO { + mpvCommand( + "sub-add", + track.data.url, + "auto", + "${VideoTrack.TRACK_TITLE_TAG}=${track.index}", + ) } + } else { + updateStateData { it.copy(hasLoadedSubs = true) } + checkFileLoaded() + selectSubById(track.id) } } + is VideoTrack.Internal -> { + updateStateData { it.copy(hasLoadedSubs = true) } + checkFileLoaded() + selectSubById(track.data.id) + } + } + } + private fun selectSubById(id: Int) { + val selectedSubs = Pair(mpv.getPropertyInt("sid"), mpv.getPropertyInt("secondary-sid")) + when (id) { + selectedSubs.first -> Pair(selectedSubs.second, null) + selectedSubs.second -> Pair(selectedSubs.first, null) + else -> if (selectedSubs.first != null) Pair(selectedSubs.first, id) else Pair(id, null) + }.let { + it.second?.let { setPropertyInt("secondary-sid", it) } + ?: setPropertyBoolean("secondary-sid", false) + it.first?.let { setPropertyInt("sid", it) } ?: setPropertyBoolean("sid", false) + } + } + + private fun onSubtitleTrackSelectChange() { + val id = mpv.getPropertyInt("sid") + val sid = mpv.getPropertyInt("secondary-sid") + + updateStateData { + it.copy( + externalSubtitleTracks = it.externalSubtitleTracks.map { tracks -> + tracks.copy( + mainSelection = when (tracks.id) { + null -> -1 + id -> 0 + sid -> 1 + else -> -1 + }, + ) + }, + ) + } + } + + fun addAudio(uri: Uri) { + val url = uri.toString() + val isContentUri = url.startsWith("content://") + val path = (if (isContentUri) uri.openContentFd(context) else url) + ?: return + val name = if (isContentUri) uri.getFileName(context) else null + if (name == null) { + mpvCommand("audio-add", path, "cached") + } else { + mpvCommand("audio-add", path, "cached", name) + } + } + + fun selectAudio(track: VideoTrack, force: Boolean = false) { + when (track) { + is VideoTrack.External -> { + if (track.id == null) { + updateAudioTrackAt(track.index) { + it.copy(state = TrackState.Loading) + } + viewModelScope.launchIO { + mpvCommand( + "audio-add", + track.data.url, + "auto", + "${VideoTrack.TRACK_TITLE_TAG}=${track.index}", + ) + } + } else { + updateStateData { it.copy(hasLoadedAudio = true) } + checkFileLoaded() + selectAudioById(track.id, force) + } + } + is VideoTrack.Internal -> { + updateStateData { it.copy(hasLoadedAudio = true) } + checkFileLoaded() + selectAudioById(track.data.id, force) + } + } + } + + private fun selectAudioById(id: Int, force: Boolean) { + if (!force && id == mpv.getPropertyInt("aid")) { + setPropertyBoolean("aid", false) + } else { + setPropertyInt("aid", id) + } + } + + fun onTrackLoadedFailure(url: String) { + val subtitleIdx = stateData.value.externalSubtitleTracks.indexOfFirst { + it.data.url == url + } + if (subtitleIdx != -1) { + updateSubtitleTrackAt(subtitleIdx) { + it.copy(state = TrackState.Error) + } + updateStateData { it.copy(hasLoadedSubs = true) } + checkFileLoaded() + } + val audioIdx = stateData.value.externalAudioTracks.indexOfFirst { + it.data.url == url + } + if (audioIdx != -1) { + updateAudioTrackAt(audioIdx) { + it.copy(state = TrackState.Error) + } + updateStateData { it.copy(hasLoadedAudio = true) } + checkFileLoaded() + } + } + + fun onChapterListChanged(node: MPVNode) { + val chapters = node.toObject>(json).map { + it.toSegment() + } + updateStateData { it.copy(chapters = chapters) } + } + + private data class EpisodeLoadResult( + val hosterList: List?, + val episodeTitle: String, + ) + + /** + * Load an episode, returning the hosterlist, episode title, and source + * associated with the episode. + */ + private suspend fun loadEpisode(episodeId: Long?): EpisodeLoadResult? { + val anime = stateData.value.currentAnime ?: return null + val source = sourceManager.getOrStub(anime.source) + + val chosenEpisode = stateData.value.currentPlaylist.firstOrNull { ep -> + ep.id == episodeId + } ?: return null + + setupEpisode(chosenEpisode) + + return withIOContext { try { - coroutineScope { - hosterList.mapIndexed { hosterIdx, hoster -> - async { - val hosterState = EpisodeLoader.loadHosterVideos(source, hoster) + currentHosterList = EpisodeLoader.getHosters( + episode = chosenEpisode.toDomainEpisode()!!, + anime, + source, + ) + this@PlayerViewModel.episodeId = chosenEpisode.id!! + } catch (e: Exception) { + if (e is CancellationException) throw e + logcat(LogPriority.ERROR, e) { e.message ?: "Error getting links" } + } - _hosterState.updateAt(hosterIdx, hosterState) + EpisodeLoadResult( + hosterList = currentHosterList, + episodeTitle = "${anime.title} - ${chosenEpisode.name}", + ) + } + } - if (hosterState is HosterState.Ready) { - if (hosterIdx == hosterIndex) { - hosterState.videoList.getOrNull(videoIndex)?.let { - hasFoundPreferredVideo.set(true) - val success = loadVideo(source, it, hosterIndex, videoIndex) - if (!success) { - hasFoundPreferredVideo.set(false) - } - } - } + private val pipEpisodeToasts = playerPreferences.pipEpisodeToasts.get() - val prefIndex = hosterState.videoList.indexOfFirst { it.preferred } - if (prefIndex != -1 && hosterIndex == -1) { - if (hasFoundPreferredVideo.compareAndSet(false, true)) { - if (selectedHosterVideoIndex.value == Pair(-1, -1)) { - val success = - loadVideo( - source, - hosterState.videoList[prefIndex], - hosterIdx, - prefIndex, - ) - if (!success) { - hasFoundPreferredVideo.set(false) - } - } - } - } - } - } - }.awaitAll() + /** + * Load next or previous episode + */ + fun nextEpisode(next: Boolean, autoplay: Boolean = false) { + val currentIndex = stateData.value.currentPlaylistIndex + val newIndex = if (next) currentIndex + 1 else currentIndex - 1 - if (hasFoundPreferredVideo.compareAndSet(false, true)) { - val (hosterIdx, videoIdx) = HosterLoader.selectBestVideo(hosterState.value) - if (hosterIdx == -1) { - throw ExceptionWithStringResource("No available videos", AYMR.strings.no_available_videos) - } + if (newIndex !in 0.. _eventFlow.emit( + Event.InitialEpisodeError( + ExceptionWithStringResource( + "Hoster list is empty", + AYMR.strings.no_hosters, + ), + ), + ) + else -> { + loadHosters( + hosterList = switchMethod.hosterList, + hosterIndex = -1, + videoIndex = -1, + ) + } + } + } else { + logcat(LogPriority.ERROR) { "Error getting links" } + } + + if (pipEpisodeToasts) { + _eventFlow.emit(Event.ToastString(switchMethod.episodeTitle)) + } + } + } + + // === Controls === + + fun onKey(keyEvent: KeyEvent): Boolean { + return player.onKey(keyEvent) + } + + fun updateHasPip(value: Boolean) { + updateStateData { it.copy(isPipAvailable = value) } + } + + fun pauseUnpause() = mpvCommand("cycle", "pause") + fun pause() = setPropertyBoolean("pause", true) + fun unpause() = setPropertyBoolean("pause", false) + + private val showStatusBar = playerPreferences.showSystemStatusBar.get() + fun showControls() { + val currentUi = uiData.value + if (currentUi.sheetShown != Sheets.None || + currentUi.panelShown != Panels.None || + currentUi.dialogShown != Dialogs.None + ) { + return + } + updateUiData { + it.copy( + controlsShown = true, + statusBarShown = showStatusBar, + ) + } + } + + fun hideControls() { + updateUiData { + it.copy( + controlsShown = false, + statusBarShown = false, + ) + } + } + + fun hideSeekBar() { + updateUiData { it.copy(seekBarShown = false) } + } + + fun showSeekBar() { + if (uiData.value.sheetShown != Sheets.None) return + updateUiData { it.copy(seekBarShown = true) } + } + + fun dismissSheet() { + updateUiData { it.copy(dismissSheet = true) } + } + + private fun resetDismissSheet() { + updateUiData { it.copy(dismissSheet = false) } + } + + fun setSheet(sheet: Sheets) { + updateUiData { it.copy(sheetShown = sheet) } + if (sheet == Sheets.None) { + resetDismissSheet() + showControls() + } else { + hideControls() + updateUiData { + it.copy( + panelShown = Panels.None, + dialogShown = Dialogs.None, + ) + } + } + } + + fun setPanel(panel: Panels) { + updateUiData { it.copy(panelShown = panel) } + if (panel == Panels.None) { + showControls() + } else { + hideControls() + updateUiData { + it.copy( + sheetShown = Sheets.None, + dialogShown = Dialogs.None, + ) + } + } + } + + fun setDialog(dialog: Dialogs) { + updateUiData { it.copy(dialogShown = dialog) } + if (dialog == Dialogs.None) { + showControls() + } else { + hideControls() + updateUiData { + it.copy( + sheetShown = Sheets.None, + panelShown = Panels.None, + ) + } + } + } + + fun changeBrightnessTo(brightness: Float) { + updatePlaybackData { it.copy(currentBrightness = brightness.coerceIn(-0.75f, 1f)) } + } + + fun displayBrightnessSlider(show: Boolean) { + updateUiData { it.copy(isBrightnessSliderShown = show) } + } + + fun changeVolumeBy(change: Int) { + val mpvVolume = mpv.getPropertyInt("volume") + if ((stateData.value.volumeBoostCap ?: audioPreferences.volumeBoostCap.get()) > 0 && + playbackData.value.currentVolume == maxVolume + ) { + if (mpvVolume == 100 && change < 0) changeVolumeTo(playbackData.value.currentVolume + change) + + val finalMPVVolume = (mpvVolume?.plus(change))?.coerceAtLeast(100) ?: 100 + if (finalMPVVolume in + 100..(stateData.value.volumeBoostCap ?: audioPreferences.volumeBoostCap.get()) + 100 + ) { + changeMPVVolumeTo(finalMPVVolume) + return + } + } + changeVolumeTo(playbackData.value.currentVolume + change) + } + + fun setVolumeTo(volume: Int) { + updatePlaybackData { it.copy(currentVolume = volume) } + } + + fun changeVolumeTo(volume: Int) { + val newVolume = volume.coerceIn(0..maxVolume) + audioManager.setVolume(newVolume) + playerPreferences.playerVolumeValue.set(newVolume) + updatePlaybackData { it.copy(currentVolume = newVolume) } + } + + fun changeMPVVolumeTo(volume: Int) { + setPropertyInt("volume", volume) + } + + fun displayVolumeSlider(show: Boolean) { + updateUiData { it.copy(isVolumeSliderShown = show) } + } + + private fun cycleAspectRatio() { + val newAspectRatio = when (playerPreferences.aspectState.get()) { + VideoAspect.Fit -> VideoAspect.Stretch + VideoAspect.Stretch -> VideoAspect.Crop + VideoAspect.Crop -> VideoAspect.Fit + } + + setAspectRatio(newAspectRatio) + } + + fun setAspectRatio(aspect: VideoAspect) { + val (pan, ratio) = when (aspect) { + VideoAspect.Crop -> { + 1.0 to -1.0 + } + VideoAspect.Fit -> { + 0.0 to -1.0 + } + VideoAspect.Stretch -> { + 0.0 to screenAspectRatio + } + } + + setPropertyDouble("panscan", pan) + setPropertyDouble("video-aspect-override", ratio) + playerPreferences.aspectState.set(aspect) + updateUiData { it.copy(playerUpdate = PlayerUpdates.AspectRatio(aspect)) } + } + + private fun setSpeed(value: Float) { + setPropertyFloat("speed", value) + playerPreferences.playerSpeed.set(value) + } + + private fun setAutoPlay(value: Boolean) { + val textRes = if (value) { + AYMR.strings.enable_auto_play + } else { + AYMR.strings.disable_auto_play + } + updateUiData { it.copy(playerUpdate = PlayerUpdates.ShowTextResource(textRes)) } + playerPreferences.autoplayEnabled.set(value) + } - val video = (hosterState.value[hosterIdx] as HosterState.Ready).videoList[videoIdx] + // === Custom buttons === + + fun executeButton(button: CustomButton) { + mpvCommand("script-message", "call_button_${button.id}") + } + + fun executeLongPressButton(button: CustomButton) { + mpvCommand("script-message", "call_button_${button.id}_long") + } + + fun setPrimaryCustomButtonTitle(button: CustomButton) { + updateUiData { it.copy(primaryButtonTitle = button.name) } + } + + fun handleLuaInvocation(property: String, value: String) { + val data = value + .removePrefix("\"") + .removeSuffix("\"") + .ifEmpty { return } + + when (property.substringAfterLast("/")) { + "show_text" -> updateUiData { it.copy(playerUpdate = PlayerUpdates.ShowText(data)) } + "toggle_ui" -> { + when (data) { + "show" -> showControls() + "toggle" -> if (uiData.value.controlsShown) hideControls() else showControls() + "hide" -> { + updateUiData { + it.copy( + sheetShown = Sheets.None, + panelShown = Panels.None, + dialogShown = Dialogs.None, + ) + } + hideControls() + } + } + } + "show_panel" -> { + when (data) { + "subtitle_settings" -> setPanel(Panels.SubtitleSettings) + "subtitle_delay" -> setPanel(Panels.SubtitleDelay) + "audio_delay" -> setPanel(Panels.AudioDelay) + "video_filters" -> setPanel(Panels.VideoFilters) + } + } + "set_button_title" -> { + updateUiData { it.copy(primaryButtonTitle = data) } + } + "reset_button_title" -> { + uiData.value.customButtons.firstOrNull { it.isFavorite }?.let { + setPrimaryCustomButtonTitle(it) + } + } + "switch_episode" -> { + when (data) { + "n" -> nextEpisode(next = true) + "p" -> nextEpisode(next = false) + } + } + "launch_int_picker" -> { + val (title, nameFormat, start, stop, step, pickerProperty) = data.split("|") + val defaultValue = mpv.getPropertyInt(pickerProperty)!! + setDialog( + Dialogs.IntegerPicker( + defaultValue = defaultValue, + minValue = start.toInt(), + maxValue = stop.toInt(), + step = step.toInt(), + nameFormat = nameFormat, + title = title, + onChange = { setPropertyInt(pickerProperty, it) }, + onDismissRequest = { setDialog(Dialogs.None) }, + ), + ) + } + "show_seek_text" -> { + val (forward, text) = data.split("|", limit = 2) + showSeekText(forward == "true", text) + } + "pause" -> { + when (data) { + "pause" -> pause() + "unpause" -> unpause() + "pauseunpause" -> pauseUnpause() + } + } + "seek_to_with_text" -> { + val (seekValue, text) = data.split("|", limit = 2) + seekToWithText(seekValue.toInt(), text) + } + "seek_by_with_text" -> { + val (seekValue, text) = data.split("|", limit = 2) + seekByWithText(seekValue.toInt(), text) + } + "seek_by" -> seekByWithText(data.toInt(), null) + "seek_to" -> seekToWithText(data.toInt(), null) + "toggle_button" -> { + fun showButton() { + if (uiData.value.primaryButton == null) { + updateUiData { + it.copy( + primaryButton = it.customButtons.firstOrNull { it.isFavorite }, + ) + } + } + } - loadVideo(source, video, hosterIdx, videoIdx) + when (data) { + "show" -> showButton() + "hide" -> updateUiData { it.copy(primaryButton = null) } + "toggle" -> if (uiData.value.primaryButton == null) { + showButton() + } else { + updateUiData { it.copy(primaryButton = null) } + } + } + } + "software_keyboard" -> { + viewModelScope.launch { + when (data) { + "show" -> _eventFlow.emit(Event.SetKeyboard(true)) + "hide" -> _eventFlow.emit(Event.SetKeyboard(false)) + "toggle" -> _eventFlow.emit(Event.ToggleKeyboard) } } - } catch (e: CancellationException) { - _hosterState.update { _ -> - hosterList.map { HosterState.Idle(it.hosterName) } - } - - throw e } } - } - fun setIsStopped(value: Boolean) { - _isStopped.update { _ -> value } + setPropertyString(property, "") } - fun setCurrentVideoError() { - val (hosterIdx, videoIdx) = selectedHosterVideoIndex.value - val currentHosterState = (hosterState.value[hosterIdx] as? HosterState.Ready) ?: return - val currentVideo = currentHosterState.videoList[videoIdx] + private operator fun List.component6(): T = get(5) - _hosterState.updateAt( - hosterIdx, - currentHosterState.getChangedAt(videoIdx, currentVideo, Video.State.ERROR), - ) - } + // === Seeking === - fun loadBestVideo(): Boolean { - val source = currentSource.value ?: return false - val (hosterIdx, videoIdx) = HosterLoader.selectBestVideo(hosterState.value) - if (hosterIdx == -1) return false - val newVideo = (hosterState.value[hosterIdx] as HosterState.Ready).videoList[videoIdx] - viewModelScope.launchIO { - loadVideo(source, newVideo, hosterIdx, videoIdx) - } - return true - } + private val leftDoubleTapGesture = gesturePreferences.leftDoubleTapGesture.get() + private val centerDoubleTapGesture = gesturePreferences.centerDoubleTapGesture.get() + private val rightDoubleTapGesture = gesturePreferences.rightDoubleTapGesture.get() + private val doubleTapToSeekDuration = gesturePreferences.skipLengthPreference.get() + private val showSeekBar = gesturePreferences.showSeekBar.get() - 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) + fun updateGestureSeekAmount(value: Pair?) { + updatePlaybackData { it.copy(gestureSeekAmount = value) } + } - val oldSelectedIndex = _selectedHosterVideoIndex.value - _selectedHosterVideoIndex.update { _ -> Pair(hosterIndex, videoIndex) } + fun updateIsSeeking(value: Boolean) { + updatePlaybackData { it.copy(isSeeking = value) } + } - _hosterState.updateAt( - hosterIndex, - selectedHosterState.getChangedAt(videoIndex, video, Video.State.LOAD_VIDEO), - ) + fun updateSeekAmount(amount: Int) { + updatePlaybackData { it.copy(doubleTapSeekAmount = amount) } + } - // Pause until everything has loaded - updatePausedState() - pause() + fun updateSeekText(value: String?) { + updatePlaybackData { it.copy(seekText = value) } + } - val resolvedVideo = if (selectedHosterState.videoState[videoIndex] != Video.State.READY) { - HosterLoader.getResolvedVideo(source, video) - } else { - video + fun handleLeftDoubleTap() { + when (leftDoubleTapGesture) { + SingleActionGesture.None -> { } + SingleActionGesture.Seek -> { + leftSeek() + } + SingleActionGesture.PlayPause -> { + pauseUnpause() + } + SingleActionGesture.Switch -> { + nextEpisode(next = false) + } + SingleActionGesture.Custom -> { + mpvCommand("keypress", CustomKeyCodes.DoubleTapLeft.keyCode) + } } + } - if (resolvedVideo == null || resolvedVideo.videoUrl.isEmpty()) { - if (currentVideo.value == null) { - _hosterState.updateAt( - hosterIndex, - selectedHosterState.getChangedAt(videoIndex, video, Video.State.ERROR), - ) - - val (newHosterIdx, newVideoIdx) = HosterLoader.selectBestVideo(hosterState.value) - if (newHosterIdx == -1) { - if (_hosterState.value.any { it is HosterState.Loading }) { - _selectedHosterVideoIndex.update { _ -> Pair(-1, -1) } - return false - } else { - throw ExceptionWithStringResource("No available videos", AYMR.strings.no_available_videos) - } - } - - 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 + fun handleCenterDoubleTap() { + when (centerDoubleTapGesture) { + SingleActionGesture.None -> { } + SingleActionGesture.Seek -> { } + SingleActionGesture.PlayPause -> { + pauseUnpause() + } + SingleActionGesture.Switch -> { } + SingleActionGesture.Custom -> { + mpvCommand("keypress", CustomKeyCodes.DoubleTapCenter.keyCode) } } + } - _hosterState.updateAt( - hosterIndex, - selectedHosterState.getChangedAt(videoIndex, resolvedVideo, Video.State.READY), - ) - - _currentVideo.update { _ -> resolvedVideo } - if (hasLoadedTracks.value) { - clearTracks() + fun handleRightDoubleTap() { + when (rightDoubleTapGesture) { + SingleActionGesture.None -> { } + SingleActionGesture.Seek -> { + rightSeek() + } + SingleActionGesture.PlayPause -> { + pauseUnpause() + } + SingleActionGesture.Switch -> { + nextEpisode(next = true) + } + SingleActionGesture.Custom -> { + mpvCommand("keypress", CustomKeyCodes.DoubleTapRight.keyCode) + } } - - qualityIndex = Pair(hosterIndex, videoIndex) - - eventChannel.send(Event.SetVideo(resolvedVideo)) - return true } - fun onVideoClicked(hosterIndex: Int, videoIndex: Int) { - val hosterState = _hosterState.value[hosterIndex] as? HosterState.Ready - val video = hosterState?.videoList - ?.getOrNull(videoIndex) - ?: return // Shouldn't happen, but just in caseâ„¢ - - val videoState = hosterState.videoState - .getOrNull(videoIndex) - ?: return - - if (videoState == Video.State.ERROR) { - return + fun leftSeek() { + if (playbackData.value.position > 0) { + updatePlaybackData { it.copy(doubleTapSeekAmount = it.doubleTapSeekAmount - doubleTapToSeekDuration) } } + updatePlaybackData { it.copy(isSeekingForwards = false) } + seekBy(-doubleTapToSeekDuration) + if (showSeekBar) showSeekBar() + } - viewModelScope.launchIO { - val success = loadVideo(currentSource.value, video, hosterIndex, videoIndex) - if (success) { - if (sheetShown.value == Sheets.QualityTracks) { - dismissSheet() - } - } + fun rightSeek() { + if (playbackData.value.position < playbackData.value.duration) { + updatePlaybackData { it.copy(doubleTapSeekAmount = it.doubleTapSeekAmount + doubleTapToSeekDuration) } } + updatePlaybackData { it.copy(isSeekingForwards = true) } + seekBy(doubleTapToSeekDuration) + if (showSeekBar) showSeekBar() } - fun onHosterClicked(index: Int) { - when (hosterState.value[index]) { - is HosterState.Ready -> { - _hosterExpandedList.updateAt(index, !_hosterExpandedList.value[index]) - } - is HosterState.Idle -> { - val hosterName = hosterList.value[index].hosterName - _hosterState.updateAt(index, HosterState.Loading(hosterName)) + private fun showSeekText(isForward: Boolean, text: String) { + updatePlaybackData { + it.copy( + seekText = text, + isSeekingForwards = isForward, + doubleTapSeekAmount = if (isForward) 1 else -1, + ) + } + if (showSeekBar) showSeekBar() + } - viewModelScope.launchIO { - val hosterState = EpisodeLoader.loadHosterVideos( - source = currentSource.value!!, - hoster = hosterList.value[index], - force = true, - ) - _hosterState.updateAt(index, hosterState) - } - } - is HosterState.Loading, is HosterState.Error -> {} + private fun seekToWithText(seekValue: Int, text: String?) { + updatePlaybackData { + it.copy( + seekText = text, + isSeekingForwards = seekValue > 0, + doubleTapSeekAmount = seekValue - it.position, + ) } + seekTo(seekValue) + if (showSeekBar) showSeekBar() } - private fun MutableStateFlow>.updateAt(index: Int, newValue: T) { - this.update { values -> - values.toMutableList().apply { - this[index] = newValue - } + private fun seekByWithText(value: Int, text: String?) { + updatePlaybackData { + it.copy( + seekText = text, + isSeekingForwards = value > 0, + doubleTapSeekAmount = if ((value < 0 && it.doubleTapSeekAmount < 0) || + it.position + value > it.duration + ) { + 0 + } else { + it.doubleTapSeekAmount + value + }, + ) } + seekBy(value) + if (showSeekBar) showSeekBar() } - data class EpisodeLoadResult( - val hosterList: List?, - val episodeTitle: String, - val source: AnimeSource, - ) + fun seekBy(offset: Int) { + mpvCommand("seek", offset.toString(), if (smoothSeeking) "relative+exact" else "relative") + } - suspend fun loadEpisode(episodeId: Long?): EpisodeLoadResult? { - val anime = currentAnime.value ?: return null - val source = sourceManager.getOrStub(anime.source) + fun seekTo(position: Int) { + if (position !in 0..playbackData.value.duration) return + mpvCommand("seek", position.toString(), if (smoothSeeking) "absolute" else "absolute+keyframes") + } - val chosenEpisode = currentPlaylist.value.firstOrNull { ep -> ep.id == episodeId } ?: return null + fun selectChapter(index: Int) { + setPropertyInt("chapter", index) + dismissSheet() + unpause() + } - _currentEpisode.update { _ -> chosenEpisode } - updateEpisode(chosenEpisode) + // === Aniyomi === - return withIOContext { - try { - val currentEpisode = - currentEpisode.value - ?: throw ExceptionWithStringResource("No episode loaded", AYMR.strings.no_episode_loaded) - currentHosterList = EpisodeLoader.getHosters( - currentEpisode.toDomainEpisode()!!, - anime, - source, - ) + /** + * Called when the activity is saved and not changing configurations. It updates the database + * to persist the current progress of the active episode. + */ + fun onSaveInstanceStateNonConfigurationChange() { + val currentEpisode = stateData.value.currentEpisode ?: return + viewModelScope.launchNonCancellable { + saveEpisodeProgress(currentEpisode) + } + } - this@PlayerViewModel.episodeId = currentEpisode.id!! - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { e.message ?: "Error getting links" } + override fun onCleared() { + stateData.value.currentEpisode?.let { + saveWatchingProgress(it) + episodeToDownload?.let { toDownload -> + downloadManager.addDownloadsToStartOfQueue(listOf(toDownload)) } - - EpisodeLoadResult( - hosterList = currentHosterList, - episodeTitle = anime.title + " - " + chosenEpisode.name, - source = source, - ) } + + super.onCleared() } + private val progress = playerPreferences.progressPreference.get() + /** * Called every time a second is reached in the player. Used to mark the flag of episode being * seen, update tracking services, enqueue downloaded episode deletion and download next episode. */ - fun onSecondReached(position: Long) { - if (isLoadingEpisode.value) return - val currentEp = currentEpisode.value ?: return + fun onSecondReached(position: Int) { + updatePlaybackData { it.copy(position = position) } + if (uiData.value.isLoadingEpisode) return + val currentEpisode = stateData.value.currentEpisode ?: return if (episodeId == -1L) return - val dur = duration ?: return - if (dur == 0) return + val duration = playbackData.value.duration + if (duration == 0) return // Set netflix-style timeout - netflixTimeout.value?.let { - if (it > 0) { - netflixTimeout.value = it - 1 + playbackData.value.netflixTimeout?.let { timeout -> + if (timeout > 0) { + updatePlaybackData { it.copy(netflixTimeout = timeout - 1) } } else { onSkipIntro() } } - val seconds = position * 1000L - val totalSeconds = dur * 1000L - // Save last second seen and mark as seen if needed - currentEp.last_second_seen = seconds - currentEp.total_seconds = totalSeconds - - episodePosition = seconds + // It's called seconds, but it's supposed to be in milliseconds. WTF? + currentEpisode.last_second_seen = position.toLong() * 1000L + currentEpisode.total_seconds = duration.toLong() * 1000L - val progress = playerPreferences.progressPreference.get() - val shouldTrack = !incognitoMode || hasTrackers - if (seconds >= totalSeconds * progress && shouldTrack) { + episodePosition = position.toLong() + val shouldTrack = !stateData.value.incognitoMode || stateData.value.hasTrackers + if (position >= duration * progress && shouldTrack) { viewModelScope.launchNonCancellable { - updateEpisodeProgressOnComplete(currentEp) + updateEpisodeProgressOnComplete(currentEpisode) } } - saveWatchingProgress(currentEp) + saveWatchingProgress(currentEpisode) - val inDownloadRange = seconds.toDouble() / totalSeconds > 0.35 + val inDownloadRange = position.toDouble() / duration > 0.35 if (inDownloadRange) { downloadNextEpisodes() } @@ -1782,7 +2278,7 @@ class PlayerViewModel @JvmOverloads constructor( .contains(LibraryPreferences.MARK_DUPLICATE_EPISODE_SEEN_EXISTING) if (!markDuplicateAsSeen) return - val duplicateUnseenEpisodes = currentPlaylist.value + val duplicateUnseenEpisodes = stateData.value.currentPlaylist .mapNotNull { episode -> if ( !episode.seen && @@ -1800,57 +2296,26 @@ class PlayerViewModel @JvmOverloads constructor( val isSyncEnabled = syncPreferences.isSyncEnabled() val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() if (isSyncEnabled && syncTriggerOpt.syncOnEpisodeSeen) { - SyncDataJob.startNow(Injekt.get()) + SyncDataJob.startNow(context) } // <-- AM (SYNC) } - private fun downloadNextEpisodes() { - if (downloadAheadAmount == 0) return - val anime = currentAnime.value ?: return - - // Only download ahead if current + next episode is already downloaded too to avoid jank - if (getCurrentEpisodeIndex() == currentPlaylist.value.lastIndex) return - val currentEpisode = currentEpisode.value ?: return - - val nextEpisode = currentPlaylist.value[getCurrentEpisodeIndex() + 1] - val episodesAreDownloaded = - EpisodeLoader.isDownload(currentEpisode.toDomainEpisode()!!, anime) && - EpisodeLoader.isDownload(nextEpisode.toDomainEpisode()!!, anime) - - viewModelScope.launchIO { - if (!episodesAreDownloaded) { - return@launchIO - } - val episodesToDownload = getNextEpisodes.await(anime.id, nextEpisode.id!!) - .take(downloadAheadAmount) - downloadManager.downloadEpisodes(anime, episodesToDownload) - } - } + private fun updateTrackEpisodeSeen(episode: Episode) { + if (basePreferences.incognitoMode.get() || !stateData.value.hasTrackers) return + if (!trackPreferences.autoUpdateTrack.get()) return - /** - * Determines if deleting option is enabled and nth to last episode actually exists. - * If both conditions are satisfied enqueues episode for delete - * @param chosenEpisode current episode, which is going to be marked as seen. - */ - private fun deleteEpisodeIfNeeded(chosenEpisode: Episode) { - // Determine which episode should be deleted and enqueue - val currentEpisodePosition = currentPlaylist.value.indexOf(chosenEpisode) - val removeAfterSeenSlots = downloadPreferences.removeAfterSeenSlots.get() - val episodeToDelete = currentPlaylist.value.getOrNull( - currentEpisodePosition - removeAfterSeenSlots, - ) - // If episode is completely seen no need to download it - episodeToDownload = null + val anime = stateData.value.currentAnime ?: return - // Check if deleting option is enabled and episode exists - if (removeAfterSeenSlots != -1 && episodeToDelete != null) { - enqueueDeleteSeenEpisodes(episodeToDelete) + viewModelScope.launchNonCancellable { + trackEpisode.await(context, anime.id, episode.episode_number.toDouble()) } } fun saveCurrentEpisodeWatchingProgress() { - currentEpisode.value?.let { saveWatchingProgress(it) } + stateData.value.currentEpisode?.let { + saveWatchingProgress(it) + } } /** @@ -1868,7 +2333,8 @@ class PlayerViewModel @JvmOverloads constructor( * If incognito mode isn't on or has at least 1 tracker */ private suspend fun saveEpisodeProgress(episode: Episode) { - if (!incognitoMode || hasTrackers) { + val stateData = stateData.value + if (!stateData.incognitoMode || stateData.hasTrackers) { updateEpisode.await( EpisodeUpdate( id = episode.id!!, @@ -1883,7 +2349,7 @@ class PlayerViewModel @JvmOverloads constructor( val isSyncEnabled = syncPreferences.isSyncEnabled() val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() if (isSyncEnabled && syncTriggerOpt.syncOnEpisodeOpen && episode.last_second_seen >= 1L) { - SyncDataJob.startNow(Injekt.get()) + SyncDataJob.startNow(context) } // <-- AM (SYNC) } @@ -1893,7 +2359,7 @@ class PlayerViewModel @JvmOverloads constructor( * Saves this [episode] last seen history if incognito mode isn't on. */ private suspend fun saveEpisodeHistory(episode: Episode) { - if (!incognitoMode) { + if (!stateData.value.incognitoMode) { val episodeId = episode.id!! val seenAt = Date() upsertHistory.await( @@ -1916,8 +2382,6 @@ class PlayerViewModel @JvmOverloads constructor( } } - // AY --> - /** * Fillermarks the currently active episode. */ @@ -1931,54 +2395,112 @@ class PlayerViewModel @JvmOverloads constructor( ) } } - // <-- AY - - fun takeScreenshot(cachePath: String, showSubtitles: Boolean): InputStream? { - val filename = cachePath + "/${System.currentTimeMillis()}_mpv_screenshot_tmp.png" - val subtitleFlag = if (showSubtitles) "subtitles" else "video" - - mpv.command("screenshot-to-file", filename, subtitleFlag) - val tempFile = File(filename).takeIf { it.exists() } ?: return null - val newFile = File("$cachePath/mpv_screenshot.png") - newFile.delete() - tempFile.renameTo(newFile) - return newFile.takeIf { it.exists() }?.inputStream() + private var episodeToDownload: Download? = null + private val downloadAheadAmount = downloadPreferences.autoDownloadWhileWatching.get() + + private fun downloadNextEpisodes() { + if (downloadAheadAmount == 0) return + val anime = stateData.value.currentAnime ?: return + + val currentPlaylist = stateData.value.currentPlaylist + val currentPlaylistIndex = stateData.value.currentPlaylistIndex + + // Only download ahead if current + next episode is already downloaded too to avoid jank + if (currentPlaylistIndex == currentPlaylist.lastIndex) return + val currentEpisode = stateData.value.currentEpisode ?: return + + val nextEpisode = currentPlaylist[currentPlaylistIndex + 1] + val episodesAreDownloaded = + EpisodeLoader.isDownload(currentEpisode.toDomainEpisode()!!, anime) && + EpisodeLoader.isDownload(nextEpisode.toDomainEpisode()!!, anime) + + viewModelScope.launchIO { + if (!episodesAreDownloaded) { + return@launchIO + } + val episodesToDownload = getNextEpisodes.await(anime.id, nextEpisode.id!!) + .take(downloadAheadAmount) + downloadManager.downloadEpisodes(anime, episodesToDownload) + } + } + + /** + * Determines if deleting option is enabled and nth to last episode actually exists. + * If both conditions are satisfied enqueues episode for delete + * @param chosenEpisode current episode, which is going to be marked as seen. + */ + private fun deleteEpisodeIfNeeded(chosenEpisode: Episode) { + // Determine which episode should be deleted and enqueue + val currentEpisodePosition = stateData.value.currentPlaylist.indexOf(chosenEpisode) + val removeAfterSeenSlots = downloadPreferences.removeAfterSeenSlots.get() + val episodeToDelete = stateData.value.currentPlaylist.getOrNull( + currentEpisodePosition - removeAfterSeenSlots, + ) + // If episode is completely seen no need to download it + episodeToDownload = null + + // Check if deleting option is enabled and episode exists + if (removeAfterSeenSlots != -1 && episodeToDelete != null) { + enqueueDeleteSeenEpisodes(episodeToDelete) + } + } + + /** + * Enqueues this [episode] to be deleted when [deletePendingEpisodes] is called. The download + * manager handles persisting it across process deaths. + */ + private fun enqueueDeleteSeenEpisodes(episode: Episode) { + if (!episode.seen) return + val anime = stateData.value.currentAnime ?: return + viewModelScope.launchNonCancellable { + downloadManager.enqueueEpisodesToDelete(listOf(episode.toDomainEpisode()!!), anime) + } + } + + /** + * Deletes all the pending episodes. This operation will run in a background thread and errors + * are ignored. + */ + fun deletePendingEpisodes() { + viewModelScope.launchNonCancellable { + downloadManager.deletePendingEpisodes() + } + } + + /** + * Results of the save image feature. + */ + sealed class SaveImageResult { + class Success(val uri: Uri) : SaveImageResult() + class Error(val error: Throwable) : SaveImageResult() } /** - * Saves the screenshot on the pictures directory and notifies the UI of the result. - * There's also a notification to allow sharing the image somewhere else or deleting it. + * Sets the screenshot as art and notifies the UI of the result. */ - fun saveImage(imageStream: () -> InputStream, timePos: Int?) { - val anime = currentAnime.value ?: return - - val context = Injekt.get() - val notifier = SaveImageNotifier(context) - notifier.onClear() - - val seconds = timePos?.let { Utils.prettyTime(it) } ?: return - val filename = generateFilename(anime, seconds) ?: return - - // Pictures directory. - val relativePath = DiskUtil.buildValidFilename(anime.title) + fun setAsArt(artType: ArtType, imageStream: () -> InputStream) { + val anime = stateData.value.currentAnime ?: return + val episode = stateData.value.currentEpisode ?: return - // Copy file in background. viewModelScope.launchNonCancellable { - try { - val uri = imageSaver.save( - image = Image.Screenshot( - inputStream = imageStream, - name = filename, - location = Location.Pictures(relativePath), - ), - ) - notifier.onComplete(uri) - eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri))) - } catch (e: Throwable) { - notifier.onError(e.message) - eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) + val result = try { + when (artType) { + ArtType.Cover -> anime.editCover(Injekt.get(), imageStream()) + ArtType.Background -> anime.editBackground(Injekt.get(), imageStream()) + ArtType.Thumbnail -> episode.editThumbnail(anime, Injekt.get(), imageStream()) + } + + if (anime.isLocal() || anime.favorite) { + SetAsArt.Success + } else { + SetAsArt.AddToLibraryFirst + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to set art" } + SetAsArt.Error } + _eventFlow.emit(Event.SetArtResult(result, artType)) } } @@ -1989,13 +2511,14 @@ class PlayerViewModel @JvmOverloads constructor( * get a path to the file, and it has to be decompressed somewhere first. Only the last shared * image will be kept so it won't be taking lots of internal disk space. */ - fun shareImage(imageStream: () -> InputStream, timePos: Int?) { - val anime = currentAnime.value ?: return + fun shareImage(imageStream: () -> InputStream) { + val anime = stateData.value.currentAnime ?: return + val pos = playbackData.value.position val context = Injekt.get() val destDir = context.cacheImageDir - val seconds = timePos?.let { Utils.prettyTime(it) } ?: return + val seconds = Utils.prettyTime(pos) val filename = generateFilename(anime, seconds) ?: return try { @@ -2008,7 +2531,7 @@ class PlayerViewModel @JvmOverloads constructor( location = Location.Cache, ), ) - eventChannel.send(Event.ShareImage(uri, seconds)) + _eventFlow.emit(Event.ShareImage(uri, seconds)) } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) @@ -2016,101 +2539,54 @@ class PlayerViewModel @JvmOverloads constructor( } /** - * Sets the screenshot as art and notifies the UI of the result. - */ - fun setAsArt(artType: ArtType, imageStream: () -> InputStream) { - val anime = currentAnime.value ?: return - val episode = currentEpisode.value ?: return - - viewModelScope.launchNonCancellable { - val result = try { - when (artType) { - ArtType.Cover -> anime.editCover(Injekt.get(), imageStream()) - ArtType.Background -> anime.editBackground(Injekt.get(), imageStream()) - ArtType.Thumbnail -> episode.editThumbnail(anime, Injekt.get(), imageStream()) - } - - if (anime.isLocal() || anime.favorite) { - SetAsArt.Success - } else { - SetAsArt.AddToLibraryFirst - } - } catch (_: Exception) { - SetAsArt.Error - } - eventChannel.send(Event.SetArtResult(result, artType)) - } - } - - /** - * Results of the save image feature. + * Saves the screenshot on the pictures directory and notifies the UI of the result. + * There's also a notification to allow sharing the image somewhere else or deleting it. */ - sealed class SaveImageResult { - class Success(val uri: Uri) : SaveImageResult() - class Error(val error: Throwable) : SaveImageResult() - } - - private fun updateTrackEpisodeSeen(episode: Episode) { - if (basePreferences.incognitoMode.get() || !hasTrackers) return - if (!trackPreferences.autoUpdateTrack.get()) return + fun saveImage(imageStream: () -> InputStream) { + val anime = stateData.value.currentAnime ?: return + val pos = playbackData.value.position - val anime = currentAnime.value ?: return val context = Injekt.get() + val notifier = SaveImageNotifier(context) + notifier.onClear() - viewModelScope.launchNonCancellable { - trackEpisode.await(context, anime.id, episode.episode_number.toDouble()) - } - } + val seconds = Utils.prettyTime(pos) + val filename = generateFilename(anime, seconds) ?: return - /** - * Enqueues this [episode] to be deleted when [deletePendingEpisodes] is called. The download - * manager handles persisting it across process deaths. - */ - private fun enqueueDeleteSeenEpisodes(episode: Episode) { - if (!episode.seen) return - val anime = currentAnime.value ?: return - viewModelScope.launchNonCancellable { - downloadManager.enqueueEpisodesToDelete(listOf(episode.toDomainEpisode()!!), anime) - } - } + // Pictures directory. + val relativePath = DiskUtil.buildValidFilename(anime.title) - /** - * Deletes all the pending episodes. This operation will run in a background thread and errors - * are ignored. - */ - fun deletePendingEpisodes() { + // Copy file in background. viewModelScope.launchNonCancellable { - downloadManager.deletePendingEpisodes() + try { + val uri = imageSaver.save( + image = Image.Screenshot( + inputStream = imageStream, + name = filename, + location = Location.Pictures(relativePath), + ), + ) + notifier.onComplete(uri) + _eventFlow.emit(Event.SavedImage(SaveImageResult.Success(uri))) + } catch (e: Throwable) { + notifier.onError(e.message) + _eventFlow.emit(Event.SavedImage(SaveImageResult.Error(e))) + } } } - /** - * Returns the skipIntroLength used by this anime or the default one. - */ - fun getAnimeSkipIntroLength(): Int { - val default = gesturePreferences.defaultIntroLength.get() - val anime = currentAnime.value ?: return default - val skipIntroLength = anime.skipIntroLength - val skipIntroDisable = anime.skipIntroDisable - return when { - skipIntroDisable -> 0 - skipIntroLength <= 0 -> default - else -> anime.skipIntroLength - } - } + // TODO: Make use of nodes instead of saving to cache + fun takeScreenshot(showSubtitles: Boolean): InputStream? { + val filename = context.cacheDir.path + "/${System.currentTimeMillis()}_mpv_screenshot_tmp.png" + val subtitleFlag = if (showSubtitles) "subtitles" else "video" - /** - * Updates the skipIntroLength for the open anime. - */ - fun setAnimeSkipIntroLength(skipIntroLength: Long) { - val anime = currentAnime.value ?: return - if (!anime.favorite) return - // Skip unnecessary database operation - if (skipIntroLength == getAnimeSkipIntroLength().toLong()) return - viewModelScope.launchIO { - setAnimeViewerFlags.awaitSetSkipIntroLength(anime.id, skipIntroLength) - _currentAnime.update { _ -> getAnime.await(anime.id) } - } + mpvCommand("screenshot-to-file", filename, subtitleFlag) + val tempFile = File(filename).takeIf { it.exists() } ?: return null + val newFile = File("${context.cacheDir.path}/mpv_screenshot.png") + + newFile.delete() + tempFile.renameTo(newFile) + return newFile.takeIf { it.exists() }?.inputStream() } /** @@ -2120,7 +2596,7 @@ class PlayerViewModel @JvmOverloads constructor( anime: Anime, timePos: String, ): String? { - val episode = currentEpisode.value ?: return null + val episode = stateData.value.currentEpisode ?: return null val filenameSuffix = " - $timePos" return DiskUtil.buildValidFilename( "${anime.title} - ${episode.name}".takeBytes( @@ -2129,68 +2605,34 @@ class PlayerViewModel @JvmOverloads constructor( ) + filenameSuffix } - /** - * Returns the response of the AniSkipApi for this episode. - * just works if tracking is enabled. - */ - suspend fun aniSkipResponse(playerDuration: Int?): List? { - val animeId = currentAnime.value?.id ?: return null - val trackerManager = Injekt.get() - var malId: Long? - val episodeNumber = currentEpisode.value?.episode_number?.toInt() ?: return null - if (getTracks.await(animeId).isEmpty()) { - logcat { "AniSkip: No tracks found for anime $animeId" } - return null - } - - getTracks.await(animeId).forEach { track -> - val tracker = trackerManager.get(track.trackerId) - malId = when (tracker) { - is MyAnimeList -> track.remoteId - is Anilist -> AniSkipApi().getMalIdFromAL(track.remoteId) - else -> null - } - val duration = playerDuration ?: return null - return malId?.let { - AniSkipApi().getResult(it.toInt(), episodeNumber, duration.toLong()) - } - } - return null - } - - val introSkipEnabled = playerPreferences.enableSkipIntro.get() - private val autoSkip = playerPreferences.autoSkipIntro.get() - private val netflixStyle = playerPreferences.enableNetflixStyleIntroSkip.get() - - private val defaultWaitingTime = playerPreferences.waitingTimeIntroSkip.get() + // === Skip intro === fun onChapterChanged(chapterIndex: Int?) { if (chapterIndex == null) return if (!introSkipEnabled) return - val chapterList = (mpv.getPropertyNode("chapter-list")?.toObject>(json) ?: emptyList()) + val chapterList = mpv.getPropertyNode("chapter-list")?.toObject>(json) + ?: emptyList() val chapter = chapterList.getOrNull(chapterIndex) ?: return val chapterType = chapter.chapterType if (chapterType == ChapterType.Other) { - _skipIntroText.update { _ -> null } - netflixTimeout.update { _ -> null } + updateUiData { it.copy(skipIntroText = null) } + updatePlaybackData { it.copy(netflixTimeout = null) } } else { if (netflixStyle) { // show a toast with the seconds before the skip - eventChannel.trySend( - Event.ShowToastString( - "Skip Intro: ${context.applicationContext.stringResource( + _eventFlow.tryEmit( + Event.ToastString( + "Skip Intro: ${context.stringResource( AYMR.strings.player_aniskip_dontskip_toast, chapter.chapterTitle, defaultWaitingTime, )}", ), ) - _skipIntroText.update { _ -> - context.applicationContext.stringResource(AYMR.strings.player_aniskip_dontskip) - } - netflixTimeout.update { _ -> defaultWaitingTime } + updateUiData { it.copy(skipIntroText = context.stringResource(AYMR.strings.player_aniskip_dontskip)) } + updatePlaybackData { it.copy(netflixTimeout = defaultWaitingTime) } } else if (autoSkip) { skipIntro(chapter.chapterTitle) } else { @@ -2200,69 +2642,239 @@ class PlayerViewModel @JvmOverloads constructor( } private fun skipIntro(chapterName: String) { - mpv.command("add", "chapter", "1") - showSeekText(true, context.applicationContext.stringResource(AYMR.strings.player_intro_skipped, chapterName)) + mpvCommand("add", "chapter", "1") + showSeekText(true, context.stringResource(AYMR.strings.player_intro_skipped, chapterName)) } private fun updateSkipIntroButton(chapterType: ChapterType) { val skipButtonString = chapterType.getStringRes() - _skipIntroText.update { _ -> - skipButtonString?.let { - context.applicationContext.stringResource( - AYMR.strings.player_skip_action, - context.applicationContext.stringResource(skipButtonString), - ) - } + updateUiData { + it.copy( + skipIntroText = skipButtonString?.let { s -> + context.stringResource( + AYMR.strings.player_skip_action, + context.stringResource(s), + ) + }, + ) } } fun onSkipIntro() { val chapterIndex = mpv.getPropertyInt("chapter") ?: return - val chapterList = (mpv.getPropertyNode("chapter-list")?.toObject>(json) ?: emptyList()) + val chapterList = mpv.getPropertyNode("chapter-list")?.toObject>(json) + ?: emptyList() val chapter = chapterList.getOrNull(chapterIndex) ?: return - if ((netflixTimeout.value ?: 0) > 0 && netflixStyle) { - netflixTimeout.update { _ -> null } + updatePlaybackData { it.copy(netflixTimeout = null) } + if ((playbackData.value.netflixTimeout ?: 0) > 0 && netflixStyle) { updateSkipIntroButton(chapter.chapterType) return } - netflixTimeout.update { _ -> null } skipIntro(chapter.chapterTitle) } - fun setPrimaryCustomButtonTitle(button: CustomButton) { - _primaryButtonTitle.update { _ -> button.name } - } - - sealed class Event { - data class SetArtResult(val result: SetAsArt, val artType: ArtType) : Event() - data class SavedImage(val result: SaveImageResult) : Event() - data class ShareImage(val uri: Uri, val seconds: String) : Event() - data class ShowToast(val stringResource: StringResource) : Event() - data class ShowToastString(val string: String) : Event() - data class ChangeEpisode(val episodeId: Long, val autoPlay: Boolean) : Event() - data class SetVideo(val video: Video?) : Event() - data class SetStatusBar(val show: Boolean) : Event() - data class SetBrightness(val brightness: Float) : Event() - data class ChangeVideoAspect(val aspect: VideoAspect) : Event() - data object CycleRotations : Event() - data object ToggleKeyboard : Event() - data class SetKeyboard(val show: Boolean) : Event() + /** + * Returns the skipIntroLength used by this anime or the default one. + */ + fun getAnimeSkipIntroLength(): Int { + val default = gesturePreferences.defaultIntroLength.get() + val anime = stateData.value.currentAnime ?: return default + val skipIntroLength = anime.skipIntroLength + val skipIntroDisable = anime.skipIntroDisable + return when { + skipIntroDisable -> 0 + skipIntroLength <= 0 -> default + else -> anime.skipIntroLength + } } -} -private val FONT_EXTENSION_REGEX = Regex($$""".*\.[ot]tf$""") + /** + * Updates the skipIntroLength for the open anime. + */ + fun setAnimeSkipIntroLength(skipIntroLength: Long) { + val anime = stateData.value.currentAnime ?: return + if (!anime.favorite) return + // Skip unnecessary database operation + if (skipIntroLength == getAnimeSkipIntroLength().toLong()) return + viewModelScope.launchIO { + setAnimeViewerFlags.awaitSetSkipIntroLength(anime.id, skipIntroLength) + val newAnime = getAnime.await(anime.id) + updateStateData { it.copy(currentAnime = newAnime) } + } + } -fun CustomButton.execute(mpv: MPV) { - mpv.command("script-message", "call_button_$id") -} + /** + * Returns the response of the AniSkipApi for this episode. + * + * Only works if tracking is enabled. + */ + suspend fun aniSkipResponse(playerDuration: Int?): List? { + val animeId = stateData.value.currentAnime?.id ?: return null + var malId: Long? + val episodeNumber = stateData.value.currentEpisode?.episode_number?.toInt() ?: return null + if (getTracks.await(animeId).isEmpty()) { + logcat(LogPriority.DEBUG) { "AniSkip: No tracks found for anime $animeId" } + return null + } -fun CustomButton.executeLongPress(mpv: MPV) { - mpv.command("script-message", "call_button_${id}_long") -} + getTracks.await(animeId).forEach { track -> + val tracker = trackerManager.get(track.trackerId) + malId = when (tracker) { + is MyAnimeList -> track.remoteId + is Anilist -> AniSkipApi().getMalIdFromAL(track.remoteId) + else -> null + } + val duration = playerDuration ?: return null + return malId?.let { + AniSkipApi().getResult(it.toInt(), episodeNumber, duration.toLong()) + } + } + return null + } -fun Float.normalize(inMin: Float, inMax: Float, outMin: Float, outMax: Float): Float { - return (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin + // === Misc === + + /** + * Starts a sleep timer/cancels the current timer if [seconds] is less than 1. + */ + fun startTimer(seconds: Int) { + timerJob?.cancel() + updatePlaybackData { it.copy(remainingTime = seconds) } + if (seconds < 1) return + timerJob = viewModelScope.launch { + for (time in seconds downTo 0) { + updatePlaybackData { it.copy(remainingTime = time) } + delay(1.seconds) + } + setPropertyBoolean("pause", true) + _eventFlow.emit(Event.ToastResource(AYMR.strings.toast_sleep_timer_ended)) + } + } + + // === Data === + @Stable + data class PlayerStateData( + val isStopped: Boolean = false, + val hasTrackers: Boolean = false, + val incognitoMode: Boolean = false, + val currentPlaylist: List = emptyList(), + val currentPlaylistIndex: Int = -1, + val hasPreviousEpisode: Boolean = false, + val hasNextEpisode: Boolean = false, + val isEpisodeOnline: Boolean = false, + val currentEpisode: Episode? = null, + val currentAnime: Anime? = null, + val currentSource: AnimeSource? = null, + val currentVideo: Video? = null, + val videoHeight: Int = 0, + val videoWidth: Int = 0, + val maxVolume: Int, + val volumeBoostCap: Int? = null, + val hasLoadedTracks: Boolean = false, + val hasLoadedSubs: Boolean = false, + val hasLoadedAudio: Boolean = false, + val chapters: List = emptyList(), + val subtitleTracks: List = emptyList(), + val audioTracks: List = emptyList(), + val externalSubtitleTracks: List = emptyList(), + val externalAudioTracks: List = emptyList(), + val hosterList: List = emptyList(), + val hosterState: List = emptyList(), + val isPipAvailable: Boolean = false, + ) + + @Stable + data class PlayerUiData( + val isLoadingHosters: Boolean = false, + val isLoadingEpisode: Boolean = false, + val previousPauseState: Boolean? = false, + val hosterExpandedList: List = emptyList(), + val selectedHosterVideoIndex: Pair = Pair(-1, -1), + val mediaTitle: String = "", + val animeTitle: String = "", + val controlsShown: Boolean = true, + val statusBarShown: Boolean = false, + val seekBarShown: Boolean = true, + val isControlsLocked: Boolean = false, + val playerUpdate: PlayerUpdates = PlayerUpdates.None, + val isBrightnessSliderShown: Boolean = false, + val isVolumeSliderShown: Boolean = false, + val sheetShown: Sheets = Sheets.None, + val panelShown: Panels = Panels.None, + val dialogShown: Dialogs = Dialogs.None, + val dismissSheet: Boolean = false, + val fontList: List = emptyList(), + val customButtons: List = emptyList(), + val primaryButtonTitle: String = "", + val primaryButton: CustomButton? = null, + val skipIntroText: String? = null, + + // Prefs + val reduceMotion: Boolean = false, + val playerTimeToDisappearMs: Int = 4000, + val swapVolumeAndBrightness: Boolean = false, + val boostCap: Int = 30, + val displayVolumeAsPercentage: Boolean = true, + val showLoadingCircle: Boolean = true, + val invertDuration: Boolean = false, + val smoothSeeking: Boolean = false, + val autoPlayEnabled: Boolean = false, + val showChapterIndicator: Boolean = true, + val playerSpeedPref: Float = 1f, + ) + + @Stable + data class PlayerPlaybackData( + val paused: Boolean = false, + val position: Int = 0, + val duration: Int = 0, + val currentVolume: Int, + val currentBrightness: Float, + val currentOrientation: Int? = null, + val isSeeking: Boolean = false, + val seekText: String? = null, + val doubleTapSeekAmount: Int = 0, + val isSeekingForwards: Boolean = false, + val gestureSeekAmount: Pair? = null, + val remainingTime: Int = 0, + val netflixTimeout: Int? = null, + ) + + sealed interface PlayerEvent { + data object ChangeAspect : PlayerEvent + data class ChangeSpeed(val value: Float) : PlayerEvent + data object CycleRotation : PlayerEvent + data object EnterPip : PlayerEvent + data class ExecuteCustomButton(val long: Boolean) : PlayerEvent + data class LockControls(val lock: Boolean) : PlayerEvent + data class NextEpisode(val next: Boolean) : PlayerEvent + data object PlayPause : PlayerEvent + data class Seek(val position: Int) : PlayerEvent + data object SeekFinished : PlayerEvent + data class SetAutoPlay(val value: Boolean) : PlayerEvent + data class SetPanel(val panel: Panels) : PlayerEvent + data class SetSheet(val sheet: Sheets) : PlayerEvent + data class ShowBrightnessSlider(val show: Boolean) : PlayerEvent + data object ShowEpisodeDialog : PlayerEvent + data class ShowPlayerUpdate(val update: PlayerUpdates) : PlayerEvent + data class ShowVolumeSlider(val show: Boolean) : PlayerEvent + data object SkipIntro : PlayerEvent + data object ToggleDurationTimer : PlayerEvent + } + + sealed interface Event { + data object EnterPip : Event + data object Finish : Event + data class InitialEpisodeError(val error: Throwable) : Event + data class SavedImage(val result: SaveImageResult) : Event + data class SetArtResult(val result: SetAsArt, val artType: ArtType) : Event + data class SetKeyboard(val show: Boolean) : Event + data class ShareImage(val uri: Uri, val seconds: String) : Event + data class ToastResource(val stringRes: StringResource) : Event + data class ToastString(val string: String) : Event + data object ToggleKeyboard : Event + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/BrightnessOverlay.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/BrightnessOverlay.kt new file mode 100644 index 0000000000..1417f1435c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/BrightnessOverlay.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.ui.player.components + +import androidx.activity.compose.LocalActivity +import androidx.annotation.FloatRange +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlin.math.abs + +@Composable +fun BrightnessOverlay( + @FloatRange(from = -0.75, to = 1.0) brightness: Float, + modifier: Modifier = Modifier, +) { + val activity = LocalActivity.currentOrThrow + val playerPreferences = remember { Injekt.get() } + + LaunchedEffect(Unit) { + if (brightness < 0f) { + activity.window.attributes = activity.window.attributes.apply { + screenBrightness = 0f + } + } + } + + LaunchedEffect(brightness) { + if (brightness < 0f) return@LaunchedEffect + + activity.window.attributes = activity.window.attributes.apply { + screenBrightness = brightness.coerceIn(0f, 1f) + } + } + + DisposableEffect(brightness) { + onDispose { + if (playerPreferences.rememberPlayerBrightness.get() && brightness != -1f) { + playerPreferences.playerBrightnessValue.set(brightness) + } + } + } + + if (brightness < 0) { + val brightnessAlpha = remember(brightness) { + abs(brightness) + } + + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer { + alpha = brightnessAlpha + }, + ) { + drawRect(Color.Black) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/MpvSurface.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/MpvSurface.kt new file mode 100644 index 0000000000..98633e1792 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/MpvSurface.kt @@ -0,0 +1,51 @@ +package eu.kanade.tachiyomi.ui.player.components + +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import `is`.xyz.mpv.MPV + +// Reference: https://github.com/MakD/AFinity/blob/master/app/src/main/java/com/makd/afinity/ui/player/components/MpvSurface.kt +@Composable +fun MpvSurface( + modifier: Modifier = Modifier, + mpv: MPV, + videoOutput: String, +) { + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { context -> + SurfaceView(context).apply { + holder.addCallback( + object : SurfaceHolder.Callback { + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int, + ) { + mpv.setPropertyString("android-surface-size", "${width}x$height") + } + + override fun surfaceCreated(holder: SurfaceHolder) { + mpv.attachSurface(holder.surface) + mpv.setOptionString("force-window", "yes") + mpv.setPropertyString("vo", videoOutput) + mpv.setOptionString("vid", "auto") + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + mpv.setPropertyString("vid", "no") + mpv.setPropertyString("vo", "null") + mpv.setPropertyString("force-window", "no") + mpv.detachSurface() + } + }, + ) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/OrientationOverlay.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/OrientationOverlay.kt new file mode 100644 index 0000000000..ebbc4ebad5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/OrientationOverlay.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.ui.player.components + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import cafe.adriel.voyager.navigator.currentOrThrow + +@Composable +fun OrientationOverlay(orientation: Int?) { + val activity = LocalActivity.currentOrThrow + + LaunchedEffect(orientation) { + if (orientation != null) { + activity.requestedOrientation = orientation + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemAwakeOverlay.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemAwakeOverlay.kt new file mode 100644 index 0000000000..eb1bb22f5c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemAwakeOverlay.kt @@ -0,0 +1,24 @@ +package eu.kanade.tachiyomi.ui.player.components + +import android.view.WindowManager +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import cafe.adriel.voyager.navigator.currentOrThrow + +@Composable +fun SystemAwakeOverlay(paused: Boolean) { + val activity = LocalActivity.currentOrThrow + + DisposableEffect(paused) { + if (paused) { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + onDispose { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemBarOverlay.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemBarOverlay.kt new file mode 100644 index 0000000000..6c3a028e8e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/components/SystemBarOverlay.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.player.components + +import android.annotation.SuppressLint +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import cafe.adriel.voyager.navigator.currentOrThrow + +// From https://github.com/MakD/AFinity/blob/master/app/src/main/java/com/makd/afinity/ui/player/utils/PlayerSystemBarsController.kt +@SuppressLint("WrongConstant") +@Composable +fun SystemBarOverlay(showStatusBar: Boolean) { + val activity = LocalActivity.currentOrThrow + + LaunchedEffect(showStatusBar) { + val window = activity.window + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + + if (showStatusBar) { + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } else { + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + DisposableEffect(Unit) { + onDispose { + val window = activity.window + val windowInsetsController = + WindowCompat.getInsetsController(window, window.decorView) + + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/GestureHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/GestureHandler.kt index 796dc805b9..b8b0e5333b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/GestureHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/GestureHandler.kt @@ -1,20 +1,3 @@ -/* - * Copyright 2024 Abdallah Mehiz - * https://github.com/abdallahmehiz/mpvKt - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package eu.kanade.tachiyomi.ui.player.controls import androidx.compose.animation.core.animateFloatAsState @@ -39,7 +22,6 @@ import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,11 +35,11 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import eu.kanade.presentation.player.components.LeftSideOvalShape import eu.kanade.presentation.player.components.RightSideOvalShape import eu.kanade.presentation.theme.playerRippleConfiguration import eu.kanade.tachiyomi.ui.player.Panels -import eu.kanade.tachiyomi.ui.player.PlayerUpdates import eu.kanade.tachiyomi.ui.player.PlayerViewModel import eu.kanade.tachiyomi.ui.player.Sheets import eu.kanade.tachiyomi.ui.player.controls.components.DoubleTapSeekTriangles @@ -65,74 +47,66 @@ import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences import eu.kanade.tachiyomi.ui.player.settings.GesturePreferences import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.update import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.time.Duration.Companion.milliseconds @Composable fun GestureHandler( + modifier: Modifier = Modifier, viewModel: PlayerViewModel, interactionSource: MutableInteractionSource, - modifier: Modifier = Modifier, ) { + val haptics = LocalHapticFeedback.current val playerPreferences = remember { Injekt.get() } val gesturePreferences = remember { Injekt.get() } val audioPreferences = remember { Injekt.get() } - val panelShown by viewModel.panelShown.collectAsState() val allowGesturesInPanels by playerPreferences.allowGestures.collectAsState() - val paused by viewModel.mpv.propFlow("pause").collectAsState() - val duration by viewModel.mpv.propFlow("duration").collectAsState() - val position by viewModel.mpv.propFlow("time-pos").collectAsState() - val playbackSpeed by viewModel.mpv.propFlow("speed").collectAsState() - val controlsShown by viewModel.controlsShown.collectAsState() - val areControlsLocked by viewModel.areControlsLocked.collectAsState() - val seekAmount by viewModel.doubleTapSeekAmount.collectAsState() - val isSeekingForwards by viewModel.isSeekingForwards.collectAsState() + val horizontalGesture by gesturePreferences.gestureHorizontalSeek.collectAsState() + val gestureVolumeBrightness by gesturePreferences.gestureVolumeBrightness.collectAsState() + val swapVolumeBrightness by gesturePreferences.swapVolumeBrightness.collectAsState() + val showSeekbar by gesturePreferences.showSeekBar.collectAsState() + val volumeBoostingCap by audioPreferences.volumeBoostCap.collectAsState() + + val uiData by viewModel.uiData.collectAsStateWithLifecycle() + val stateData by viewModel.stateData.collectAsStateWithLifecycle() + val playbackData by viewModel.playbackData.collectAsStateWithLifecycle() + + val position by viewModel.propFlow("time-pos").collectAsStateWithLifecycle() + val duration by viewModel.propFlow("duration").collectAsStateWithLifecycle() + val currentMPVVolume by viewModel.propFlow("volume").collectAsStateWithLifecycle() + var isDoubleTapSeeking by remember { mutableStateOf(false) } + var isLongPressing by remember { mutableStateOf(false) } - LaunchedEffect(seekAmount) { - delay(800) - isDoubleTapSeeking = false + LaunchedEffect(playbackData.doubleTapSeekAmount) { + delay(800.milliseconds) + isDoubleTapSeeking = true viewModel.updateSeekAmount(0) viewModel.updateSeekText(null) - delay(100) + delay(100.milliseconds) viewModel.hideSeekBar() } - val gestureVolumeBrightness = gesturePreferences.gestureVolumeBrightness.get() - val swapVolumeBrightness by gesturePreferences.swapVolumeBrightness.collectAsState() - val seekGesture by gesturePreferences.gestureHorizontalSeek.collectAsState() - val preciseSeeking by gesturePreferences.playerSmoothSeek.collectAsState() - val showSeekbar by gesturePreferences.showSeekBar.collectAsState() - var isLongPressing by remember { mutableStateOf(false) } - val currentVolume by viewModel.currentVolume.collectAsState() - val currentMPVVolume by viewModel.mpv.propFlow("volume").collectAsState() - val currentBrightness by viewModel.currentBrightness.collectAsState() - val volumeBoostingCap = audioPreferences.volumeBoostCap.get() - val haptics = LocalHapticFeedback.current - Box( modifier = modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeGestures) .pointerInput(Unit) { - val originalSpeed = viewModel.mpv.getPropertyFloat("speed") ?: 1f detectTapGestures( - onTap = { - if (controlsShown) viewModel.hideControls() else viewModel.showControls() - }, + onTap = { if (uiData.controlsShown) viewModel.hideControls() else viewModel.showControls() }, onDoubleTap = { - if (areControlsLocked || isDoubleTapSeeking) return@detectTapGestures + if (uiData.isControlsLocked || isDoubleTapSeeking) return@detectTapGestures if (it.x > size.width * 3 / 5) { - if (!isSeekingForwards) viewModel.updateSeekAmount(0) + if (!playbackData.isSeekingForwards) viewModel.updateSeekAmount(0) viewModel.handleRightDoubleTap() isDoubleTapSeeking = true } else if (it.x < size.width * 2 / 5) { - if (isSeekingForwards) viewModel.updateSeekAmount(0) + if (playbackData.isSeekingForwards) viewModel.updateSeekAmount(0) viewModel.handleLeftDoubleTap() isDoubleTapSeeking = true } else { @@ -140,18 +114,18 @@ fun GestureHandler( } }, onPress = { - if (panelShown != Panels.None && !allowGesturesInPanels) { - viewModel.panelShown.update { Panels.None } + if (uiData.panelShown != Panels.None && !allowGesturesInPanels) { + viewModel.setPanel(Panels.None) } val press = PressInteraction.Press( it.copy(x = if (it.x > size.width * 3 / 5) it.x - size.width * 0.6f else it.x), ) - if (!areControlsLocked && isDoubleTapSeeking && seekAmount != 0) { + if (!uiData.isControlsLocked && isDoubleTapSeeking && playbackData.doubleTapSeekAmount != 0) { if (it.x > size.width * 3 / 5) { - if (!isSeekingForwards) viewModel.updateSeekAmount(0) + if (!playbackData.isSeekingForwards) viewModel.updateSeekAmount(0) viewModel.handleRightDoubleTap() } else if (it.x < size.width * 2 / 5) { - if (isSeekingForwards) viewModel.updateSeekAmount(0) + if (playbackData.isSeekingForwards) viewModel.updateSeekAmount(0) viewModel.handleLeftDoubleTap() } else { viewModel.handleCenterDoubleTap() @@ -163,93 +137,96 @@ fun GestureHandler( tryAwaitRelease() if (isLongPressing) { isLongPressing = false - viewModel.mpv.setPropertyFloat("speed", originalSpeed) - viewModel.playerUpdate.update { PlayerUpdates.None } } interactionSource.emit(PressInteraction.Release(press)) }, onLongPress = { - if (areControlsLocked) return@detectTapGestures + if (uiData.isControlsLocked) return@detectTapGestures if (!isLongPressing) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) isLongPressing = true viewModel.pause() - viewModel.sheetShown.update { Sheets.Screenshot } + viewModel.setSheet(Sheets.Screenshot) } }, ) } - .pointerInput(areControlsLocked) { - if (!seekGesture || areControlsLocked) return@pointerInput + .pointerInput(uiData.isControlsLocked) { + if (!horizontalGesture || uiData.isControlsLocked) return@pointerInput + var startingPosition = position ?: 0 var startingX = 0f - var wasPlayerAlreadyPause = false + + fun dragEnd() { + viewModel.updateGestureSeekAmount(null) + viewModel.hideSeekBar() + } + detectHorizontalDragGestures( onDragStart = { startingPosition = position ?: 0 startingX = it.x - wasPlayerAlreadyPause = paused ?: false - viewModel.pause() - }, - onDragEnd = { - viewModel.gestureSeekAmount.update { null } - viewModel.hideSeekBar() - if (!wasPlayerAlreadyPause) viewModel.unpause() + viewModel.updateIsSeeking(true) }, - ) { change, dragAmount -> - if ((position ?: 0) <= 0f && dragAmount < 0) return@detectHorizontalDragGestures - if ((position ?: 0) >= (duration ?: 0) && dragAmount > 0) return@detectHorizontalDragGestures - calculateNewHorizontalGestureValue(startingPosition, startingX, change.position.x, 0.15f).let { - viewModel.gestureSeekAmount.update { _ -> - Pair( - startingPosition, - (it - startingPosition) - .coerceIn(0 - startingPosition, ((duration ?: 0) - startingPosition)), + onDragEnd = { dragEnd() }, + onDragCancel = { dragEnd() }, + onHorizontalDrag = { change, dragAmount -> + if ((position ?: 0) <= 0f && dragAmount < 0) return@detectHorizontalDragGestures + if ((position ?: 0) >= (duration ?: 0) && dragAmount > 0) return@detectHorizontalDragGestures + calculateNewHorizontalGestureValue(startingPosition, startingX, change.position.x, 0.15f).let { + viewModel.updateGestureSeekAmount( + Pair( + startingPosition, + (it - startingPosition) + .coerceIn(0 - startingPosition, ((duration ?: 0) - startingPosition)), + ), ) + viewModel.seekTo(it.coerceIn(0, (duration ?: 0))) } - viewModel.seekTo(it.coerceIn(0, (duration ?: 0)), preciseSeeking) - } - if (showSeekbar) viewModel.showSeekBar() - } + if (showSeekbar) viewModel.showSeekBar() + }, + ) } - .pointerInput(areControlsLocked) { - if (!gestureVolumeBrightness || areControlsLocked) return@pointerInput + .pointerInput(uiData.isControlsLocked) { + if (!gestureVolumeBrightness || uiData.isControlsLocked) return@pointerInput + var startingY = 0f var mpvVolumeStartingY = 0f - var originalVolume = currentVolume + var originalVolume = playbackData.currentVolume var originalMPVVolume = currentMPVVolume - var originalBrightness = currentBrightness + var originalBrightness = playbackData.currentBrightness val brightnessGestureSens = 0.001f - val volumeGestureSens = 0.001f * viewModel.maxVolume + val volumeGestureSens = 0.001f * stateData.maxVolume val mpvVolumeGestureSens = 0.001f * volumeBoostingCap val isIncreasingVolumeBoost: (Float) -> Boolean = { volumeBoostingCap > 0 && - currentVolume == viewModel.maxVolume && + playbackData.currentVolume == stateData.maxVolume && (currentMPVVolume ?: 100) - 100 < volumeBoostingCap && it < 0 } val isDecreasingVolumeBoost: (Float) -> Boolean = { volumeBoostingCap > 0 && - currentVolume == viewModel.maxVolume && + playbackData.currentVolume == stateData.maxVolume && (currentMPVVolume ?: 100) - 100 in 1..volumeBoostingCap && it > 0 } + detectVerticalDragGestures( onDragEnd = { startingY = 0f }, onDragStart = { startingY = 0f mpvVolumeStartingY = 0f - originalVolume = currentVolume + originalVolume = playbackData.currentVolume originalMPVVolume = currentMPVVolume - originalBrightness = currentBrightness + originalBrightness = playbackData.currentBrightness }, ) { change, amount -> val changeVolume: () -> Unit = { if (isIncreasingVolumeBoost(amount) || isDecreasingVolumeBoost(amount)) { if (mpvVolumeStartingY == 0f) { startingY = 0f - originalVolume = currentVolume + originalVolume = playbackData.currentVolume mpvVolumeStartingY = change.position.y } viewModel.changeMPVVolumeTo( @@ -276,7 +253,7 @@ fun GestureHandler( ), ) } - viewModel.displayVolumeSlider() + viewModel.displayVolumeSlider(true) } val changeBrightness: () -> Unit = { if (startingY == 0f) startingY = change.position.y @@ -288,7 +265,7 @@ fun GestureHandler( brightnessGestureSens, ), ) - viewModel.displayBrightnessSlider() + viewModel.displayBrightnessSlider(true) } if (swapVolumeBrightness) { if (change.position.x > size.width / 2) changeBrightness() else changeVolume() 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 a2fe579a5b..7fb979ad9d 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 @@ -1,23 +1,5 @@ -/* - * Copyright 2024 Abdallah Mehiz - * https://github.com/abdallahmehiz/mpvKt - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package eu.kanade.tachiyomi.ui.player.controls -import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FiniteAnimationSpec @@ -31,915 +13,444 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalRippleConfiguration import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension -import androidx.core.graphics.toColorInt -import eu.kanade.presentation.theme.playerRippleConfiguration -import eu.kanade.tachiyomi.ui.player.DebandSettings -import eu.kanade.tachiyomi.ui.player.Debanding -import eu.kanade.tachiyomi.ui.player.Decoder.Companion.getDecoderFromValue -import eu.kanade.tachiyomi.ui.player.Dialogs import eu.kanade.tachiyomi.ui.player.Panels -import eu.kanade.tachiyomi.ui.player.PlayerActivity import eu.kanade.tachiyomi.ui.player.PlayerUpdates import eu.kanade.tachiyomi.ui.player.PlayerViewModel +import eu.kanade.tachiyomi.ui.player.PlayerViewModel.PlayerEvent import eu.kanade.tachiyomi.ui.player.Sheets -import eu.kanade.tachiyomi.ui.player.VideoAspect -import eu.kanade.tachiyomi.ui.player.VideoFilters -import eu.kanade.tachiyomi.ui.player.VideoTrack -import eu.kanade.tachiyomi.ui.player.controls.components.BrightnessOverlay import eu.kanade.tachiyomi.ui.player.controls.components.BrightnessSlider import eu.kanade.tachiyomi.ui.player.controls.components.ControlsButton import eu.kanade.tachiyomi.ui.player.controls.components.SeekbarWithTimers import eu.kanade.tachiyomi.ui.player.controls.components.TextPlayerUpdate import eu.kanade.tachiyomi.ui.player.controls.components.VolumeSlider -import eu.kanade.tachiyomi.ui.player.controls.components.panels.SubColorType -import eu.kanade.tachiyomi.ui.player.controls.components.panels.SubtitlesBorderStyle -import eu.kanade.tachiyomi.ui.player.controls.components.panels.resetColors -import eu.kanade.tachiyomi.ui.player.controls.components.panels.resetTypography -import eu.kanade.tachiyomi.ui.player.controls.components.panels.toColorHexString -import eu.kanade.tachiyomi.ui.player.controls.components.sheets.toFixed -import eu.kanade.tachiyomi.ui.player.execute -import eu.kanade.tachiyomi.ui.player.executeLongPress -import eu.kanade.tachiyomi.ui.player.settings.AdvancedPlayerPreferences -import eu.kanade.tachiyomi.ui.player.settings.AudioChannels -import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences -import eu.kanade.tachiyomi.ui.player.settings.DecoderPreferences -import eu.kanade.tachiyomi.ui.player.settings.GesturePreferences -import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences -import eu.kanade.tachiyomi.ui.player.settings.SubtitleAssOverride -import eu.kanade.tachiyomi.ui.player.settings.SubtitleJustification -import eu.kanade.tachiyomi.ui.player.settings.SubtitlePreferences -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.update -import tachiyomi.core.common.preference.deleteAndGet -import tachiyomi.core.common.preference.minusAssign -import tachiyomi.core.common.preference.plusAssign import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource -import tachiyomi.presentation.core.util.collectAsState -import tachiyomi.source.local.isLocal -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.seconds @Suppress("CompositionLocalAllowlist") val LocalPlayerButtonsClickEvent = staticCompositionLocalOf { {} } @Composable fun PlayerControls( - viewModel: PlayerViewModel, - onBackPress: () -> Unit, + stateData: PlayerViewModel.PlayerStateData, + uiData: PlayerViewModel.PlayerUiData, + playbackData: PlayerViewModel.PlayerPlaybackData, + onBack: () -> Unit, + onPlayerEvent: (PlayerEvent) -> Unit, + mpvVolume: Int?, + pausedForCache: Boolean?, + coreIdle: Boolean?, + readAhead: Float?, + remaining: Float?, + playbackSpeed: Float?, + currentChapter: Int?, modifier: Modifier = Modifier, ) { - val spacing = MaterialTheme.padding - val playerPreferences = remember { Injekt.get() } - val advancedPreferences = remember { Injekt.get() } - val decoderPreferences = remember { Injekt.get() } - val gesturePreferences = remember { Injekt.get() } - val audioPreferences = remember { Injekt.get() } - val subtitlePreferences = remember { Injekt.get() } - val interactionSource = remember { MutableInteractionSource() } - - val controlsShown by viewModel.controlsShown.collectAsState() - val areControlsLocked by viewModel.areControlsLocked.collectAsState() - val seekBarShown by viewModel.seekBarShown.collectAsState() - val isLoadingEpisode by viewModel.isLoadingEpisode.collectAsState() - val isStopped by viewModel.isStopped.collectAsState() - val pausedForCache by viewModel.mpv.propFlow("paused-for-cache").collectAsState() - val coreIdle by viewModel.mpv.propFlow("core-idle").collectAsState() - val paused by viewModel.mpv.propFlow("pause").collectAsState() - val duration by viewModel.mpv.propFlow("duration").collectAsState() - val position by viewModel.mpv.propFlow("time-pos").collectAsState() - val playbackSpeed by viewModel.mpv.propFlow("speed").collectAsState() - val gestureSeekAmount by viewModel.gestureSeekAmount.collectAsState() - val doubleTapSeekAmount by viewModel.doubleTapSeekAmount.collectAsState() - val seekText by viewModel.seekText.collectAsState() - val currentChapter by viewModel.mpv.propFlow("chapter").collectAsState() - val mpvDecoder by viewModel.mpv.propFlow("hwdec-current").collectAsState() - val decoder by remember { derivedStateOf { getDecoderFromValue(mpvDecoder ?: "auto") } } - val chapters by viewModel.chapters.collectAsState(persistentListOf()) - val currentBrightness by viewModel.currentBrightness.collectAsState() - - val playerTimeToDisappear by playerPreferences.playerTimeToDisappear.collectAsState() - var isSeeking by remember { mutableStateOf(false) } - var resetControls by remember { mutableStateOf(true) } - - val customButtons by viewModel.customButtons.collectAsState() - val customButton by viewModel.primaryButton.collectAsState() - - LaunchedEffect( - controlsShown, - paused, - isSeeking, - resetControls, - ) { - if (controlsShown && paused == false && !isSeeking) { - delay(playerTimeToDisappear.toLong()) - viewModel.hideControls() - } - } - val transparentOverlay by animateFloatAsState( - if (controlsShown && !areControlsLocked) .8f else 0f, + if (uiData.controlsShown && !uiData.isControlsLocked) .8f else 0f, animationSpec = playerControlsExitAnimationSpec(), label = "controls_transparent_overlay", ) - GestureHandler( - viewModel = viewModel, - interactionSource = interactionSource, - ) - DoubleTapToSeekOvals(doubleTapSeekAmount, seekText, interactionSource) + CompositionLocalProvider( - LocalRippleConfiguration provides playerRippleConfiguration, - LocalPlayerButtonsClickEvent provides { resetControls = !resetControls }, - LocalContentColor provides Color.White, + LocalLayoutDirection provides LayoutDirection.Ltr, ) { - CompositionLocalProvider( - LocalLayoutDirection provides LayoutDirection.Ltr, + ConstraintLayout( + modifier = modifier.fillMaxSize() + .background( + Brush.verticalGradient( + Pair(0f, Color.Black), + Pair(.2f, Color.Transparent), + Pair(.7f, Color.Transparent), + Pair(1f, Color.Black), + ), + alpha = transparentOverlay, + ) + .padding(horizontal = MaterialTheme.padding.medium), ) { - ConstraintLayout( - modifier = modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - Pair(0f, Color.Black), - Pair(.2f, Color.Transparent), - Pair(.7f, Color.Transparent), - Pair(1f, Color.Black), - ), - alpha = transparentOverlay, - ) - .padding(horizontal = MaterialTheme.padding.medium), + val (topLeftControls, topRightControls) = createRefs() + val (volumeSlider, brightnessSlider) = createRefs() + val unlockControlsButton = createRef() + val (bottomRightControls, bottomLeftControls) = createRefs() + val centerControls = createRef() + val seekbar = createRef() + val (playerUpdates) = createRefs() + + LaunchedEffect(playbackData.currentVolume, mpvVolume, uiData.isVolumeSliderShown) { + delay(2.seconds) + if (uiData.isVolumeSliderShown) onPlayerEvent(PlayerEvent.ShowVolumeSlider(false)) + } + LaunchedEffect(playbackData.currentBrightness, uiData.isBrightnessSliderShown) { + delay(2.seconds) + if (uiData.isBrightnessSliderShown) onPlayerEvent(PlayerEvent.ShowBrightnessSlider(false)) + } + AnimatedVisibility( + visible = uiData.isBrightnessSliderShown, + enter = if (!uiData.reduceMotion) { + slideInHorizontally(playerControlsEnterAnimationSpec()) { + if (uiData.swapVolumeAndBrightness) it else -it + } + + fadeIn( + playerControlsEnterAnimationSpec(), + ) + } else { + fadeIn(playerControlsEnterAnimationSpec()) + }, + exit = if (!uiData.reduceMotion) { + slideOutHorizontally(playerControlsExitAnimationSpec()) { + if (uiData.swapVolumeAndBrightness) it else -it + } + + fadeOut( + playerControlsExitAnimationSpec(), + ) + } else { + fadeOut(playerControlsExitAnimationSpec()) + }, + modifier = Modifier.constrainAs(brightnessSlider) { + if (uiData.swapVolumeAndBrightness) { + start.linkTo(parent.start, MaterialTheme.padding.medium) + } else { + end.linkTo(parent.end, MaterialTheme.padding.medium) + } + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, ) { - val (topLeftControls, topRightControls) = createRefs() - val (volumeSlider, brightnessSlider) = createRefs() - val unlockControlsButton = createRef() - val (bottomRightControls, bottomLeftControls) = createRefs() - val centerControls = createRef() - val seekbar = createRef() - val (playerUpdates) = createRefs() + BrightnessSlider( + brightness = playbackData.currentBrightness, + positiveRange = 0f..1f, + negativeRange = 0f..0.75f, + ) + } - val hasPreviousEpisode by viewModel.hasPreviousEpisode.collectAsState() - val hasNextEpisode by viewModel.hasNextEpisode.collectAsState() - val isBrightnessSliderShown by viewModel.isBrightnessSliderShown.collectAsState() - val isVolumeSliderShown by viewModel.isVolumeSliderShown.collectAsState() - val brightness by viewModel.currentBrightness.collectAsState() - val volume by viewModel.currentVolume.collectAsState() - val mpvVolume by viewModel.mpv.propFlow("volume").collectAsState() - val swapVolumeAndBrightness by gesturePreferences.swapVolumeBrightness.collectAsState() - val reduceMotion by playerPreferences.reduceMotion.collectAsState() + AnimatedVisibility( + visible = uiData.isVolumeSliderShown, + enter = if (!uiData.reduceMotion) { + slideInHorizontally(playerControlsEnterAnimationSpec()) { + if (uiData.swapVolumeAndBrightness) it else -it + } + + fadeIn( + playerControlsEnterAnimationSpec(), + ) + } else { + fadeIn(playerControlsEnterAnimationSpec()) + }, + exit = if (!uiData.reduceMotion) { + slideOutHorizontally(playerControlsExitAnimationSpec()) { + if (uiData.swapVolumeAndBrightness) it else -it + } + + fadeOut( + playerControlsExitAnimationSpec(), + ) + } else { + fadeOut(playerControlsExitAnimationSpec()) + }, + modifier = Modifier.constrainAs(volumeSlider) { + if (uiData.swapVolumeAndBrightness) { + end.linkTo(parent.end, MaterialTheme.padding.medium) + } else { + start.linkTo(parent.start, MaterialTheme.padding.medium) + } + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + ) { + VolumeSlider( + volume = playbackData.currentVolume, + mpvVolume = mpvVolume ?: 100, + range = 0..stateData.maxVolume, + boostRange = if (uiData.boostCap > 0) 0..uiData.boostCap else null, + displayAsPercentage = uiData.displayVolumeAsPercentage, + ) + } - LaunchedEffect(volume, mpvVolume, isVolumeSliderShown) { - delay(2000) - if (isVolumeSliderShown) viewModel.isVolumeSliderShown.update { false } - } - LaunchedEffect(brightness, isBrightnessSliderShown) { - delay(2000) - if (isBrightnessSliderShown) viewModel.isBrightnessSliderShown.update { false } + LaunchedEffect(uiData.playerUpdate) { + if (uiData.playerUpdate is PlayerUpdates.DoubleSpeed || uiData.playerUpdate is PlayerUpdates.None) { + return@LaunchedEffect } - AnimatedVisibility( - isBrightnessSliderShown, - enter = - if (!reduceMotion) { - slideInHorizontally(playerControlsEnterAnimationSpec()) { - if (swapVolumeAndBrightness) -it else it - } + - fadeIn( - playerControlsEnterAnimationSpec(), - ) - } else { - fadeIn(playerControlsEnterAnimationSpec()) - }, - exit = - if (!reduceMotion) { - slideOutHorizontally(playerControlsExitAnimationSpec()) { - if (swapVolumeAndBrightness) -it else it - } + - fadeOut( - playerControlsExitAnimationSpec(), - ) - } else { - fadeOut(playerControlsExitAnimationSpec()) - }, - modifier = Modifier.constrainAs(brightnessSlider) { - if (swapVolumeAndBrightness) { - start.linkTo(parent.start, spacing.medium) - } else { - end.linkTo(parent.end, spacing.medium) - } - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, - ) { - BrightnessSlider( - brightness = brightness, - positiveRange = 0f..1f, - negativeRange = 0f..0.75f, + delay(2.seconds) + onPlayerEvent(PlayerEvent.ShowPlayerUpdate(PlayerUpdates.None)) + } + AnimatedVisibility( + visible = uiData.playerUpdate !is PlayerUpdates.None, + enter = fadeIn(playerControlsEnterAnimationSpec()), + exit = fadeOut(playerControlsExitAnimationSpec()), + modifier = Modifier.constrainAs(playerUpdates) { + linkTo(parent.start, parent.end) + linkTo(parent.top, parent.bottom, bias = 0.2f) + }, + ) { + when (uiData.playerUpdate) { + PlayerUpdates.None -> {} + PlayerUpdates.DoubleSpeed -> {} + is PlayerUpdates.AspectRatio -> TextPlayerUpdate( + stringResource(uiData.playerUpdate.aspect.titleRes), ) - } - - AnimatedVisibility( - isVolumeSliderShown, - enter = - if (!reduceMotion) { - slideInHorizontally(playerControlsEnterAnimationSpec()) { - if (swapVolumeAndBrightness) it else -it - } + - fadeIn( - playerControlsEnterAnimationSpec(), - ) - } else { - fadeIn(playerControlsEnterAnimationSpec()) - }, - exit = - if (!reduceMotion) { - slideOutHorizontally(playerControlsExitAnimationSpec()) { - if (swapVolumeAndBrightness) it else -it - } + - fadeOut( - playerControlsExitAnimationSpec(), - ) - } else { - fadeOut(playerControlsExitAnimationSpec()) - }, - modifier = Modifier.constrainAs(volumeSlider) { - if (swapVolumeAndBrightness) { - end.linkTo(parent.end, spacing.medium) - } else { - start.linkTo(parent.start, spacing.medium) - } - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, - ) { - val boostCap by audioPreferences.volumeBoostCap.collectAsState() - val displayVolumeAsPercentage by playerPreferences.displayVolPer.collectAsState() - VolumeSlider( - volume = volume, - mpvVolume = mpvVolume ?: 100, - range = 0..viewModel.maxVolume, - boostRange = if (boostCap > 0) 0..audioPreferences.volumeBoostCap.get() else null, - displayAsPercentage = displayVolumeAsPercentage, + is PlayerUpdates.ShowText -> TextPlayerUpdate(uiData.playerUpdate.value) + is PlayerUpdates.ShowTextResource -> TextPlayerUpdate( + stringResource(uiData.playerUpdate.textResource), ) } + } - val currentPlayerUpdate by viewModel.playerUpdate.collectAsState() - val aspectRatio by playerPreferences.aspectState.collectAsState() - LaunchedEffect(currentPlayerUpdate, aspectRatio) { - if (currentPlayerUpdate is PlayerUpdates.DoubleSpeed || currentPlayerUpdate is PlayerUpdates.None) { - return@LaunchedEffect - } - delay(2000) - viewModel.playerUpdate.update { PlayerUpdates.None } - } - AnimatedVisibility( - currentPlayerUpdate !is PlayerUpdates.None, - enter = fadeIn(playerControlsEnterAnimationSpec()), - exit = fadeOut(playerControlsExitAnimationSpec()), - modifier = Modifier.constrainAs(playerUpdates) { - linkTo(parent.start, parent.end) - linkTo(parent.top, parent.bottom, bias = 0.2f) - }, - ) { - when (currentPlayerUpdate) { - // is PlayerUpdates.DoubleSpeed -> DoubleSpeedPlayerUpdate() - is PlayerUpdates.AspectRatio -> TextPlayerUpdate(stringResource(aspectRatio.titleRes)) - is PlayerUpdates.ShowText -> TextPlayerUpdate( - (currentPlayerUpdate as PlayerUpdates.ShowText).value, - ) - is PlayerUpdates.ShowTextResource -> TextPlayerUpdate( - stringResource((currentPlayerUpdate as PlayerUpdates.ShowTextResource).textResource), - ) - else -> {} - } - } + AnimatedVisibility( + visible = uiData.controlsShown && uiData.isControlsLocked, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.constrainAs(unlockControlsButton) { + top.linkTo(parent.top, MaterialTheme.padding.medium) + start.linkTo(parent.start, MaterialTheme.padding.medium) + }, + ) { + ControlsButton( + Icons.Filled.Lock, + onClick = { onPlayerEvent(PlayerEvent.LockControls(false)) }, + ) + } - AnimatedVisibility( - controlsShown && areControlsLocked, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.constrainAs(unlockControlsButton) { - top.linkTo(parent.top, spacing.medium) - start.linkTo(parent.start, spacing.medium) - }, - ) { - ControlsButton( - Icons.Filled.Lock, - onClick = { viewModel.unlockControls() }, - ) - } - AnimatedVisibility( - visible = - (controlsShown && (!areControlsLocked || gestureSeekAmount != null)) || - (pausedForCache == true || (coreIdle == true && paused == false)) || - isLoadingEpisode, + AnimatedVisibility( + visible = ( + uiData.controlsShown && (!uiData.isControlsLocked || playbackData.gestureSeekAmount != null) + ) || + (pausedForCache == true || (coreIdle == true && !playbackData.paused)) || + uiData.isLoadingEpisode, + enter = fadeIn(playerControlsEnterAnimationSpec()), + exit = fadeOut(playerControlsExitAnimationSpec()), + modifier = Modifier.constrainAs(centerControls) { + end.linkTo(parent.absoluteRight) + start.linkTo(parent.absoluteLeft) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + ) { + MiddlePlayerControls( + hasPrevious = stateData.hasPreviousEpisode, + onSkipPrevious = { onPlayerEvent(PlayerEvent.NextEpisode(false)) }, + hasNext = stateData.hasNextEpisode, + onSkipNext = { onPlayerEvent(PlayerEvent.NextEpisode(true)) }, + isStopped = stateData.isStopped, + isLoading = pausedForCache == true || (coreIdle == true && !playbackData.paused), + isLoadingEpisode = uiData.isLoadingEpisode, + controlsShown = uiData.controlsShown, + areControlsLocked = uiData.isControlsLocked, + showLoadingCircle = uiData.showLoadingCircle, + paused = playbackData.paused, + gestureSeekAmount = playbackData.gestureSeekAmount, + onPlayPauseClick = { onPlayerEvent(PlayerEvent.PlayPause) }, enter = fadeIn(playerControlsEnterAnimationSpec()), exit = fadeOut(playerControlsExitAnimationSpec()), - modifier = Modifier.constrainAs(centerControls) { - end.linkTo(parent.absoluteRight) - start.linkTo(parent.absoluteLeft) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, - ) { - val showLoadingCircle by playerPreferences.showLoadingCircle.collectAsState() - MiddlePlayerControls( - hasPrevious = hasPreviousEpisode, - onSkipPrevious = { viewModel.changeEpisode(true) }, - hasNext = hasNextEpisode, - onSkipNext = { viewModel.changeEpisode(false) }, - isStopped = isStopped, - isLoading = pausedForCache == true || (coreIdle == true && paused == false), - isLoadingEpisode = isLoadingEpisode, - controlsShown = controlsShown, - areControlsLocked = areControlsLocked, - showLoadingCircle = showLoadingCircle, - paused = paused == true, - gestureSeekAmount = gestureSeekAmount, - onPlayPauseClick = viewModel::pauseUnpause, - enter = fadeIn(playerControlsEnterAnimationSpec()), - exit = fadeOut(playerControlsExitAnimationSpec()), - ) - } - AnimatedVisibility( - visible = (controlsShown || seekBarShown) && !areControlsLocked, - enter = if (!reduceMotion) { - slideInVertically(playerControlsEnterAnimationSpec()) { it } + - fadeIn(playerControlsEnterAnimationSpec()) - } else { - fadeIn(playerControlsEnterAnimationSpec()) - }, - exit = if (!reduceMotion) { - slideOutVertically(playerControlsExitAnimationSpec()) { it } + - fadeOut(playerControlsExitAnimationSpec()) - } else { - fadeOut(playerControlsExitAnimationSpec()) - }, - modifier = Modifier.constrainAs(seekbar) { - bottom.linkTo(parent.bottom, spacing.medium) - }, - ) { - val invertDuration by playerPreferences.invertDuration.collectAsState() - val readAhead by viewModel.mpv.propFlow("demuxer-cache-time").collectAsState() - val remaining by viewModel.mpv.propFlow("playtime-remaining").collectAsState() - val preciseSeeking by gesturePreferences.playerSmoothSeek.collectAsState() - SeekbarWithTimers( - position = position?.toFloat() ?: 0f, - duration = duration?.toFloat() ?: 0f, - remaining = remaining ?: 0f, - readAheadValue = readAhead ?: 0f, - onValueChange = { - isSeeking = true - viewModel.seekTo(it.roundToInt(), preciseSeeking) - }, - onValueChangeFinished = { isSeeking = false }, - timersInverted = Pair(false, invertDuration), - durationTimerOnCLick = { playerPreferences.invertDuration.set(!invertDuration) }, - positionTimerOnClick = {}, - chapters = chapters, - ) - } - val mediaTitle by viewModel.mediaTitle.collectAsState() - val animeTitle by viewModel.animeTitle.collectAsState() - AnimatedVisibility( - controlsShown && !areControlsLocked, - enter = if (!reduceMotion) { - slideInHorizontally(playerControlsEnterAnimationSpec()) { -it } + - fadeIn(playerControlsEnterAnimationSpec()) - } else { + ) + } + AnimatedVisibility( + visible = (uiData.controlsShown || uiData.seekBarShown) && !uiData.isControlsLocked, + enter = if (!uiData.reduceMotion) { + slideInVertically(playerControlsEnterAnimationSpec()) { it } + fadeIn(playerControlsEnterAnimationSpec()) - }, - exit = if (!reduceMotion) { - slideOutHorizontally(playerControlsExitAnimationSpec()) { -it } + - fadeOut(playerControlsExitAnimationSpec()) - } else { + } else { + fadeIn(playerControlsEnterAnimationSpec()) + }, + exit = if (!uiData.reduceMotion) { + slideOutVertically(playerControlsExitAnimationSpec()) { it } + fadeOut(playerControlsExitAnimationSpec()) - }, - modifier = Modifier.constrainAs(topLeftControls) { - top.linkTo(parent.top, spacing.medium) - start.linkTo(parent.start) - width = Dimension.fillToConstraints - end.linkTo(topRightControls.start) - }, - ) { - TopLeftPlayerControls( - animeTitle = animeTitle, - mediaTitle = mediaTitle, - onTitleClick = { viewModel.showEpisodeListDialog() }, - onBackClick = onBackPress, - ) - } - // Top right controls - val autoPlayEnabled by playerPreferences.autoplayEnabled.collectAsState() - val isEpisodeOnline by viewModel.isEpisodeOnline.collectAsState() - AnimatedVisibility( - controlsShown && !areControlsLocked, - enter = if (!reduceMotion) { - slideInHorizontally(playerControlsEnterAnimationSpec()) { it } + - fadeIn(playerControlsEnterAnimationSpec()) - } else { + } else { + fadeOut(playerControlsExitAnimationSpec()) + }, + modifier = Modifier.constrainAs(seekbar) { + bottom.linkTo(parent.bottom, MaterialTheme.padding.medium) + }, + ) { + SeekbarWithTimers( + position = playbackData.position.toFloat(), + duration = playbackData.duration.toFloat(), + remaining = remaining ?: 0f, + readAheadValue = readAhead ?: 0f, + onValueChange = { onPlayerEvent(PlayerEvent.Seek(it.roundToInt())) }, + onValueChangeFinished = { onPlayerEvent(PlayerEvent.SeekFinished) }, + timersInverted = Pair(false, uiData.invertDuration), + durationTimerOnCLick = { onPlayerEvent(PlayerEvent.ToggleDurationTimer) }, + positionTimerOnClick = { }, + chapters = stateData.chapters, + ) + } + + AnimatedVisibility( + visible = uiData.controlsShown && !uiData.isControlsLocked, + enter = if (!uiData.reduceMotion) { + slideInHorizontally(playerControlsEnterAnimationSpec()) { -it } + fadeIn(playerControlsEnterAnimationSpec()) - }, - exit = if (!reduceMotion) { - slideOutHorizontally(playerControlsExitAnimationSpec()) { it } + - fadeOut(playerControlsExitAnimationSpec()) - } else { + } else { + fadeIn(playerControlsEnterAnimationSpec()) + }, + exit = if (!uiData.reduceMotion) { + slideOutHorizontally(playerControlsExitAnimationSpec()) { -it } + fadeOut(playerControlsExitAnimationSpec()) - }, - modifier = Modifier.constrainAs(topRightControls) { - top.linkTo(parent.top, spacing.medium) - end.linkTo(parent.end) - }, - ) { - TopRightPlayerControls( - autoPlayEnabled = autoPlayEnabled, - onToggleAutoPlay = { viewModel.setAutoPlay(it) }, - onSubtitlesClick = { viewModel.showSheet(Sheets.SubtitleTracks) }, - onSubtitlesLongClick = { viewModel.showPanel(Panels.SubtitleSettings) }, - onAudioClick = { viewModel.showSheet(Sheets.AudioTracks) }, - onAudioLongClick = { viewModel.showPanel(Panels.AudioDelay) }, - onQualityClick = { viewModel.showSheet(Sheets.QualityTracks) }, - isEpisodeOnline = isEpisodeOnline, - onMoreClick = { viewModel.showSheet(Sheets.More) }, - onMoreLongClick = { viewModel.showPanel(Panels.VideoFilters) }, - ) - } - // Bottom right controls - val skipIntroButton by viewModel.skipIntroText.collectAsState() - val customButtonTitle by viewModel.primaryButtonTitle.collectAsState() - AnimatedVisibility( - controlsShown && !areControlsLocked, - enter = if (!reduceMotion) { - slideInHorizontally(playerControlsEnterAnimationSpec()) { it } + - fadeIn(playerControlsEnterAnimationSpec()) - } else { + } else { + fadeOut(playerControlsExitAnimationSpec()) + }, + modifier = Modifier.constrainAs(topLeftControls) { + top.linkTo(parent.top, MaterialTheme.padding.medium) + start.linkTo(parent.start) + width = Dimension.fillToConstraints + end.linkTo(topRightControls.start) + }, + ) { + TopLeftPlayerControls( + animeTitle = uiData.animeTitle, + mediaTitle = uiData.mediaTitle, + onTitleClick = { onPlayerEvent(PlayerEvent.ShowEpisodeDialog) }, + onBackClick = onBack, + ) + } + AnimatedVisibility( + visible = uiData.controlsShown && !uiData.isControlsLocked, + enter = if (!uiData.reduceMotion) { + slideInHorizontally(playerControlsEnterAnimationSpec()) { it } + fadeIn(playerControlsEnterAnimationSpec()) - }, - exit = if (!reduceMotion) { - slideOutHorizontally(playerControlsExitAnimationSpec()) { it } + - fadeOut(playerControlsExitAnimationSpec()) - } else { + } else { + fadeIn(playerControlsEnterAnimationSpec()) + }, + exit = if (!uiData.reduceMotion) { + slideOutHorizontally(playerControlsExitAnimationSpec()) { it } + fadeOut(playerControlsExitAnimationSpec()) - }, - modifier = Modifier.constrainAs(bottomRightControls) { - bottom.linkTo(seekbar.top) - end.linkTo(seekbar.end) - }, - ) { - val activity = LocalActivity.current as PlayerActivity - BottomRightPlayerControls( - customButton = customButton, - customButtonTitle = customButtonTitle, - skipIntroButton = skipIntroButton, - onPressSkipIntroButton = viewModel::onSkipIntro, - isPipAvailable = activity.isPipSupportedAndEnabled, - onPipClick = { - if (!viewModel.isLoadingEpisode.value) { - activity.enterPictureInPictureMode(activity.createPipParams()) - } - }, - onCustomButtonClick = { - customButton?.execute(viewModel.mpv) - }, - onCustomButtonLongClick = { - customButton?.executeLongPress(viewModel.mpv) - }, - onAspectClick = { - viewModel.changeVideoAspect( - when (aspectRatio) { - VideoAspect.Fit -> VideoAspect.Stretch - VideoAspect.Stretch -> VideoAspect.Crop - VideoAspect.Crop -> VideoAspect.Fit - }, - ) - }, - ) - } - // Bottom left controls - AnimatedVisibility( - controlsShown && !areControlsLocked, - enter = if (!reduceMotion) { - slideInHorizontally(playerControlsEnterAnimationSpec()) { -it } + - fadeIn(playerControlsEnterAnimationSpec()) - } else { + } else { + fadeOut(playerControlsExitAnimationSpec()) + }, + modifier = Modifier.constrainAs(topRightControls) { + top.linkTo(parent.top, MaterialTheme.padding.medium) + end.linkTo(parent.end) + }, + ) { + TopRightPlayerControls( + autoPlayEnabled = uiData.autoPlayEnabled, + onToggleAutoPlay = { onPlayerEvent(PlayerEvent.SetAutoPlay(it)) }, + onSubtitlesClick = { onPlayerEvent(PlayerEvent.SetSheet(Sheets.SubtitleTracks)) }, + onSubtitlesLongClick = { onPlayerEvent(PlayerEvent.SetPanel(Panels.SubtitleSettings)) }, + onAudioClick = { onPlayerEvent(PlayerEvent.SetSheet(Sheets.AudioTracks)) }, + onAudioLongClick = { onPlayerEvent(PlayerEvent.SetPanel(Panels.AudioDelay)) }, + onQualityClick = { onPlayerEvent(PlayerEvent.SetSheet(Sheets.QualityTracks)) }, + isEpisodeOnline = stateData.isEpisodeOnline, + onMoreClick = { onPlayerEvent(PlayerEvent.SetSheet(Sheets.More)) }, + onMoreLongClick = { onPlayerEvent(PlayerEvent.SetPanel(Panels.VideoFilters)) }, + ) + } + + AnimatedVisibility( + visible = uiData.controlsShown && !uiData.isControlsLocked, + enter = if (!uiData.reduceMotion) { + slideInHorizontally(playerControlsEnterAnimationSpec()) { it } + fadeIn(playerControlsEnterAnimationSpec()) - }, - exit = if (!reduceMotion) { - slideOutHorizontally(playerControlsExitAnimationSpec()) { -it } + - fadeOut(playerControlsExitAnimationSpec()) - } else { + } else { + fadeIn(playerControlsEnterAnimationSpec()) + }, + exit = if (!uiData.reduceMotion) { + slideOutHorizontally(playerControlsExitAnimationSpec()) { it } + fadeOut(playerControlsExitAnimationSpec()) - }, - modifier = Modifier.constrainAs(bottomLeftControls) { - bottom.linkTo(seekbar.top) - start.linkTo(seekbar.start) - width = Dimension.fillToConstraints - end.linkTo(bottomRightControls.start) - }, - ) { - val showChapterIndicator by playerPreferences.showCurrentChapter.collectAsState() - BottomLeftPlayerControls( - playbackSpeed = playbackSpeed ?: playerPreferences.playerSpeed.get(), - showChapterIndicator = showChapterIndicator, - currentChapter = chapters.getOrNull(currentChapter ?: 0), - onLockControls = viewModel::lockControls, - onCycleRotation = viewModel::cycleScreenRotations, - onPlaybackSpeedChange = { - viewModel.mpv.setPropertyFloat("speed", it) - playerPreferences.playerSpeed.set(it) - }, - onOpenSheet = viewModel::showSheet, - ) - } + } else { + fadeOut(playerControlsExitAnimationSpec()) + }, + modifier = Modifier.constrainAs(bottomRightControls) { + bottom.linkTo(seekbar.top) + end.linkTo(seekbar.end) + }, + ) { + BottomRightPlayerControls( + customButton = uiData.primaryButton, + customButtonTitle = uiData.primaryButtonTitle, + skipIntroButton = uiData.skipIntroText, + onPressSkipIntroButton = { onPlayerEvent(PlayerEvent.SkipIntro) }, + isPipAvailable = stateData.isPipAvailable, + onPipClick = { onPlayerEvent(PlayerEvent.EnterPip) }, + onCustomButtonClick = { onPlayerEvent(PlayerEvent.ExecuteCustomButton(false)) }, + onCustomButtonLongClick = { onPlayerEvent(PlayerEvent.ExecuteCustomButton(true)) }, + onAspectClick = { onPlayerEvent(PlayerEvent.ChangeAspect) }, + ) } - } - - val sheetShown by viewModel.sheetShown.collectAsState() - val dismissSheet by viewModel.dismissSheet.collectAsState() - val isLoadingHosters by viewModel.isLoadingHosters.collectAsState() - val hosterState by viewModel.hosterState.collectAsState() - val expandedState by viewModel.hosterExpandedList.collectAsState() - val selectedHosterVideoIndex by viewModel.selectedHosterVideoIndex.collectAsState() - val sleepTimerTimeRemaining by viewModel.remainingTime.collectAsState() - val speedPresets by playerPreferences.speedPresets.collectAsState() - - val showSubtitles by subtitlePreferences.screenshotSubtitles.collectAsState() - val currentSource by viewModel.currentSource.collectAsState() - val showFailedHosters by playerPreferences.showFailedHosters.collectAsState() - val emptyHosters by playerPreferences.showEmptyHosters.collectAsState() - - val internalSubtitles by viewModel.subtitleTracks.collectAsState(persistentListOf()) - val externalSubtitles by viewModel.externalSubtitleTracks.collectAsState() - val subtitles = remember(internalSubtitles, externalSubtitles) { - internalSubtitles.map { VideoTrack.Internal(it) } + externalSubtitles - } - - val audioChannels by audioPreferences.audioChannels.collectAsState() - val pitchCorrection by audioPreferences.enablePitchCorrection.collectAsState() - val mpvAudioPitchCorrection by viewModel.mpv.propFlow("audio-pitch-correction").collectAsState() - val internalAudioTracks by viewModel.audioTracks.collectAsState(persistentListOf()) - val externalAudioTracks by viewModel.externalAudioTracks.collectAsState() - val audioTracks = remember(internalAudioTracks, externalAudioTracks) { - internalAudioTracks.map { VideoTrack.Internal(it) } + externalAudioTracks - } - - val statisticsPage by advancedPreferences.playerStatisticsPage.collectAsState() - - PlayerSheets( - sheetShown = sheetShown, - subtitles = subtitles.toImmutableList(), - onAddSubtitle = viewModel::addSubtitle, - onSelectSubtitle = viewModel::selectSub, - audioTracks = audioTracks.toImmutableList(), - onAddAudio = viewModel::addAudio, - onSelectAudio = viewModel::selectAudio, - - isLoadingHosters = isLoadingHosters, - - hosterState = hosterState.toPersistentList(), - expandedState = expandedState.toPersistentList(), - selectedVideoIndex = selectedHosterVideoIndex, - onClickHoster = viewModel::onHosterClicked, - onClickVideo = viewModel::onVideoClicked, - displayHosters = Pair(showFailedHosters, emptyHosters), - - chapter = chapters.getOrNull(currentChapter ?: 0), - chapters = chapters, - onSeekToChapter = { - viewModel.mpv.setPropertyInt("chapter", it) - viewModel.dismissSheet() - viewModel.unpause() - }, - decoder = decoder, - onUpdateDecoder = { viewModel.mpv.setPropertyString("hwdec", it.value) }, - - speed = playbackSpeed ?: playerPreferences.playerSpeed.get(), - speedPresets = speedPresets.map { it.toFloat() }.sorted().toPersistentList(), - onSpeedChange = { viewModel.mpv.setPropertyFloat("speed", it.toFixed(2)) }, - onMakeDefaultSpeed = { playerPreferences.playerSpeed.set(it.toFixed(2)) }, - onAddSpeedPreset = { playerPreferences.speedPresets += it.toFixed(2).toString() }, - onRemoveSpeedPreset = { playerPreferences.speedPresets -= it.toFixed(2).toString() }, - onResetSpeedPresets = playerPreferences.speedPresets::delete, - onResetDefaultSpeed = { - viewModel.mpv.setPropertyFloat("speed", playerPreferences.playerSpeed.deleteAndGet().toFixed(2)) - }, - // More sheet state - statisticsPage = statisticsPage, - audioChannels = audioChannels, - sleepTimerTimeRemaining = sleepTimerTimeRemaining, - onStartSleepTimer = viewModel::startTimer, - onStatisticsPageChange = { page -> - if ((page == 0) xor - (statisticsPage == 0) - ) { - viewModel.mpv.command("script-binding", "stats/display-stats-toggle") - } - if (page != 0) viewModel.mpv.command("script-binding", "stats/display-page-$page") - advancedPreferences.playerStatisticsPage.set(page) - }, - onAudioChannelsChange = { - audioPreferences.audioChannels.set(it) - if (it == AudioChannels.ReverseStereo) { - viewModel.mpv.setPropertyString(AudioChannels.AutoSafe.property, AudioChannels.AutoSafe.value) + AnimatedVisibility( + visible = uiData.controlsShown && !uiData.isControlsLocked, + enter = if (!uiData.reduceMotion) { + slideInHorizontally(playerControlsEnterAnimationSpec()) { -it } + + fadeIn(playerControlsEnterAnimationSpec()) } else { - viewModel.mpv.setPropertyString(AudioChannels.ReverseStereo.property, "") - } - viewModel.mpv.setPropertyString(it.property, it.value) - }, - onCustomButtonClick = { it.execute(viewModel.mpv) }, - onCustomButtonLongClick = { it.executeLongPress(viewModel.mpv) }, - buttons = customButtons, - onPitchCorrectionChange = { - audioPreferences.enablePitchCorrection.set(it) - viewModel.mpv.setPropertyBoolean("audio-pitch-correction", it) - }, - pitchCorrection = pitchCorrection || mpvAudioPitchCorrection == true, - - isLocalSource = currentSource?.isLocal() == true, - showSubtitles = showSubtitles, - onToggleShowSubtitles = { subtitlePreferences.screenshotSubtitles.set(it) }, - cachePath = viewModel.cachePath, - onSetAsArt = viewModel::setAsArt, - onShare = { viewModel.shareImage(it, viewModel.pos) }, - onSave = { viewModel.saveImage(it, viewModel.pos) }, - takeScreenshot = viewModel::takeScreenshot, - onDismissScreenshot = { - viewModel.showSheet(Sheets.None) - viewModel.unpause() - }, - onOpenPanel = viewModel::showPanel, - onDismissRequest = { viewModel.showSheet(Sheets.None) }, - dismissSheet = dismissSheet, - ) - - val panel by viewModel.panelShown.collectAsState() - val subDelayPref by subtitlePreferences.subtitlesDelay.collectAsState() - val subDelay by viewModel.mpv.propFlow("sub-delay").collectAsState() - val subDelaySecondary by viewModel.mpv.propFlow("secondary-sub-delay").collectAsState() - val subDelaySecondaryPref by subtitlePreferences.subtitlesSecondaryDelay.collectAsState() - val subSpeed by viewModel.mpv.propFlow("sub-speed").collectAsState() - val audioDelay by viewModel.mpv.propFlow("audio-delay").collectAsState() - val isBold by viewModel.mpv.propFlow("sub-bold").collectAsState() - val isItalic by viewModel.mpv.propFlow("sub-italic").collectAsState() - val subJustify by viewModel.mpv.propFlow("sub-justify").collectAsState() - val subFont by viewModel.mpv.propFlow("sub-font").collectAsState() - val subFontList by viewModel.fontList.collectAsState() - val subFontSize by viewModel.mpv.propFlow("sub-font-size").collectAsState() - val subBorderStyle by viewModel.mpv.propFlow("sub-border-style").collectAsState() - val subBorderSize by viewModel.mpv.propFlow("sub-outline-size").collectAsState() - val subShadowOffset by viewModel.mpv.propFlow("sub-shadow-offset").collectAsState() - val subColor by viewModel.mpv.propFlow("sub-color").collectAsState() - val subBorderColor by viewModel.mpv.propFlow("sub-outline-color").collectAsState() - val subBackgroundColor by viewModel.mpv.propFlow("sub-back-color").collectAsState() - val overrideAssSubs by viewModel.mpv.propFlow("sub-ass-override").collectAsState() - val subScale by viewModel.mpv.propFlow("sub-scale").collectAsState() - val subPos by viewModel.mpv.propFlow("sub-pos").collectAsState() - val deband by decoderPreferences.debanding.collectAsState() - val mpvGpuNext by viewModel.mpv.propFlow("vo").collectAsState() - val debandSettingsMap = DebandSettings.entries.associateWith { setting -> - viewModel.mpv.propFlow(setting.mpvProperty).collectAsState().value ?: 0 - } - val filterValuesMap = VideoFilters.entries.associateWith { filter -> - viewModel.mpv.propFlow(filter.mpvProperty).collectAsState().value ?: 0 - } - var subtitleColorType by remember { mutableStateOf(SubColorType.Text) } - - PlayerPanels( - panelShown = panel, - onDismissRequest = { viewModel.showPanel(Panels.None) }, - // Subtitle settings panel state - isBold = isBold ?: subtitlePreferences.boldSubtitles.get(), - isItalic = isItalic ?: subtitlePreferences.italicSubtitles.get(), - subJustify = - subJustify?.let { SubtitleJustification.byValue(it) } - ?: subtitlePreferences.subtitleJustification.get(), - subFont = subFont ?: subtitlePreferences.subtitleFont.get(), - subFontList = subFontList, - subFontSize = subFontSize ?: subtitlePreferences.subtitleFontSize.get(), - subBorderStyle = subBorderStyle?.let { SubtitlesBorderStyle.byValue(it) } - ?: subtitlePreferences.borderStyleSubtitles.get(), - subBorderSize = subBorderSize ?: subtitlePreferences.subtitleBorderSize.get(), - subShadowOffset = subShadowOffset ?: subtitlePreferences.shadowOffsetSubtitles.get(), - subColor = subtitleColorType, - currentSubtitleColor = when (subtitleColorType) { - SubColorType.Text -> subColor?.toColorInt() ?: subtitlePreferences.textColorSubtitles.get() - SubColorType.Border -> subBorderColor?.toColorInt() ?: subtitlePreferences.borderColorSubtitles.get() - SubColorType.Background -> subBackgroundColor?.toColorInt() - ?: subtitlePreferences.backgroundColorSubtitles.get() - }, - overrideAssSubs = overrideAssSubs?.let { SubtitleAssOverride.byValue(it) } - ?: subtitlePreferences.overrideSubsASS.get(), - subScale = subScale ?: subtitlePreferences.subtitleFontScale.get(), - subPos = subPos ?: subtitlePreferences.subtitlePos.get(), - onSubBoldChange = { - viewModel.mpv.setPropertyBoolean("sub-bold", it) - subtitlePreferences.boldSubtitles.set(it) - }, - onSubItalicChange = { - viewModel.mpv.setPropertyBoolean("sub-italic", it) - subtitlePreferences.italicSubtitles.set(it) - }, - onSubJustifyChange = { - viewModel.mpv.setPropertyString("sub-justify", it.value) - subtitlePreferences.subtitleJustification.set(it) - }, - onSubFontChange = { - viewModel.mpv.setPropertyString("sub-font", it) - subtitlePreferences.subtitleFont.set(it) - }, - onSubFontSizeChange = { - viewModel.mpv.setPropertyInt("sub-font-size", it) - subtitlePreferences.subtitleFontSize.set(it) - }, - onSubBorderStyleChange = { - viewModel.mpv.setPropertyString("sub-border-style", it.value) - subtitlePreferences.borderStyleSubtitles.set(it) - }, - onSubBorderSizeChange = { - viewModel.mpv.setPropertyInt("sub-outline-size", it) - subtitlePreferences.subtitleBorderSize.set(it) - }, - onSubShadowOffsetChange = { - viewModel.mpv.setPropertyInt("sub-shadow-offset", it) - subtitlePreferences.shadowOffsetSubtitles.set(it) - }, - onSubColorChange = { - when (subtitleColorType) { - SubColorType.Text -> { - viewModel.mpv.setPropertyString("sub-color", it.toColorHexString()) - subtitlePreferences.textColorSubtitles.set(it) - } - - SubColorType.Border -> { - viewModel.mpv.setPropertyString("sub-outline-color", it.toColorHexString()) - subtitlePreferences.borderColorSubtitles.set(it) - } - - SubColorType.Background -> { - viewModel.mpv.setPropertyString("sub-back-color", it.toColorHexString()) - subtitlePreferences.backgroundColorSubtitles.set(it) - } - } - }, - onOverrideAssSubsChange = { - viewModel.mpv.setPropertyString("sub-ass-override", it.value) - subtitlePreferences.overrideSubsASS.set(it) - }, - onSubScaleChange = { - viewModel.mpv.setPropertyFloat("sub-scale", it) - subtitlePreferences.subtitleFontScale.set(it) - }, - onSubPosChange = { - viewModel.mpv.setPropertyInt("sub-pos", it) - subtitlePreferences.subtitlePos.set(it) - }, - onSubColorTypeChange = { subtitleColorType = it }, - onSubColorReset = { - resetColors(subtitlePreferences, viewModel.mpv, subtitleColorType) - }, - onSubtitleSettingsReset = { - resetTypography(viewModel.mpv, subtitlePreferences) - }, - onSubtitleMiscReset = { - subtitlePreferences.subtitlePos.deleteAndGet().let { - viewModel.mpv.setPropertyInt("sub-pos", it) - } - subtitlePreferences.subtitleFontScale.deleteAndGet().let { - viewModel.mpv.setPropertyFloat("sub-scale", it) - } - subtitlePreferences.overrideSubsASS.deleteAndGet().let { - viewModel.mpv.setPropertyString("sub-ass-override", it.value) - } - }, - subDelayMsPrimary = subDelay?.times(1000)?.roundToInt() ?: subDelayPref, - subDelayMsSecondary = subDelaySecondary?.times(1000)?.roundToInt() ?: subDelaySecondaryPref, - subSpeed = subSpeed ?: subtitlePreferences.subtitlesSpeed.get().toDouble(), - onSubDelayPrimaryChange = { - viewModel.mpv.setPropertyDouble("sub-delay", it / 1000.0) - }, - onSubDelaySecondaryChange = { - viewModel.mpv.setPropertyDouble("secondary-sub-delay", it / 1000.0) - }, - onSubSpeedChange = { - viewModel.mpv.setPropertyDouble("sub-speed", it) - }, - onSubDelayApply = { - subtitlePreferences.subtitlesDelay.set((subDelay?.times(1000)?.roundToInt()) ?: 0) - subtitlePreferences.subtitlesSecondaryDelay.set((subDelaySecondary?.times(1000)?.roundToInt()) ?: 0) - }, - onSubDelayReset = { - viewModel.mpv.setPropertyDouble("sub-delay", subtitlePreferences.subtitlesDelay.get() / 1000.0) - viewModel.mpv.setPropertyDouble( - "secondary-sub-delay", - subtitlePreferences.subtitlesSecondaryDelay.get() / 1000.0, + fadeIn(playerControlsEnterAnimationSpec()) + }, + exit = if (!uiData.reduceMotion) { + slideOutHorizontally(playerControlsExitAnimationSpec()) { -it } + + fadeOut(playerControlsExitAnimationSpec()) + } else { + fadeOut(playerControlsExitAnimationSpec()) + }, + modifier = Modifier.constrainAs(bottomLeftControls) { + bottom.linkTo(seekbar.top) + start.linkTo(seekbar.start) + width = Dimension.fillToConstraints + end.linkTo(bottomRightControls.start) + }, + ) { + BottomLeftPlayerControls( + playbackSpeed = playbackSpeed ?: uiData.playerSpeedPref, + showChapterIndicator = uiData.showChapterIndicator, + currentChapter = stateData.chapters.getOrNull(currentChapter ?: 0), + onLockControls = { onPlayerEvent(PlayerEvent.LockControls(true)) }, + onCycleRotation = { onPlayerEvent(PlayerEvent.CycleRotation) }, + onPlaybackSpeedChange = { onPlayerEvent(PlayerEvent.ChangeSpeed(it)) }, + onOpenSheet = { onPlayerEvent(PlayerEvent.SetSheet(it)) }, ) - viewModel.mpv.setPropertyDouble("sub-speed", subtitlePreferences.subtitlesSpeed.get().toDouble()) - }, - audioDelayMs = (audioDelay?.times(1000))?.roundToInt() ?: audioPreferences.audioDelay.get(), - onAudioDelayChange = { viewModel.mpv.setPropertyDouble("audio-delay", it / 1000.0) }, - onAudioDelayApply = { - audioPreferences.audioDelay.set((audioDelay?.times(1000)?.roundToInt()) ?: 0) - }, - onAudioDelayReset = { - viewModel.mpv.setPropertyDouble("audio-delay", audioPreferences.audioDelay.get() / 1000.0) - }, - onDebandChange = { - decoderPreferences.debanding.set(it) - when (it) { - Debanding.None -> { - viewModel.mpv.setPropertyString("deband", "no") - viewModel.mpv.command("vf", "remove", "@deband") - } - - Debanding.CPU -> { - viewModel.mpv.setPropertyString("deband", "no") - viewModel.mpv.command("vf", "add", "@deband:gradfun=radius=12") - } - - Debanding.GPU -> { - viewModel.mpv.setPropertyString("deband", "yes") - viewModel.mpv.command("vf", "remove", "@deband") - } - } - }, - onDebandReset = { - viewModel.mpv.setPropertyString("deband", "no") - viewModel.mpv.command("vf", "remove", "@deband") - DebandSettings.entries.forEach { - viewModel.mpv.setPropertyInt(it.mpvProperty, it.preference(decoderPreferences).deleteAndGet()) - } - }, - onDebandSettingsChange = { setting, value -> - setting.preference(decoderPreferences).set(value) - viewModel.mpv.setPropertyInt(setting.mpvProperty, value) - }, - onVideoFilterChange = { filter, value -> - filter.preference(decoderPreferences).set(value) - viewModel.mpv.setPropertyInt(filter.mpvProperty, value) - }, - onFilterReset = { - VideoFilters.entries.forEach { - viewModel.mpv.setPropertyInt(it.mpvProperty, it.preference(decoderPreferences).deleteAndGet()) - } - }, - deband = deband, - isGpuNextEnabled = mpvGpuNext == "gpu-next", - filterValue = { filterValuesMap[it] ?: 0 }, - debandSettings = { debandSettingsMap[it] ?: 0 }, - modifier = Modifier, - ) - - val activity = LocalActivity.current as PlayerActivity - val dialog by viewModel.dialogShown.collectAsState() - val anime by viewModel.currentAnime.collectAsState() - val playlist by viewModel.currentPlaylist.collectAsState() - - PlayerDialogs( - dialogShown = dialog, - episodeDisplayMode = anime?.displayMode, - episodeList = playlist, - currentEpisodeIndex = viewModel.getCurrentEpisodeIndex(), - dateRelativeTime = viewModel.relativeTime, - dateFormat = viewModel.dateFormat, - onBookmarkClicked = viewModel::bookmarkEpisode, - onFillermarkClicked = viewModel::fillermarkEpisode, - onEpisodeClicked = { - viewModel.showDialog(Dialogs.None) - activity.changeEpisode(it) - }, - onDismissRequest = { viewModel.showDialog(Dialogs.None) }, - ) + } + } + } +} - BrightnessOverlay( - brightness = currentBrightness, +@Composable +@Preview( + device = "spec:width=411dp,height=891dp,dpi=420,isRound=false,chinSize=0dp,orientation=landscape", +) +private fun PlayerControlsPreview() { + MaterialTheme { + PlayerControls( + stateData = PlayerViewModel.PlayerStateData( + maxVolume = 0, + isEpisodeOnline = true, + isPipAvailable = true, + ), + uiData = PlayerViewModel.PlayerUiData( + animeTitle = "ef - a tale of memories.", + mediaTitle = "Ep. 2 - Upon a Time", + playerUpdate = PlayerUpdates.DoubleSpeed, + ), + playbackData = PlayerViewModel.PlayerPlaybackData( + currentVolume = 0, + currentBrightness = 0f, + ), + onBack = { }, + onPlayerEvent = { }, + mpvVolume = 0, + pausedForCache = false, + coreIdle = false, + readAhead = 0f, + remaining = 0f, + playbackSpeed = 1f, + currentChapter = 0, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt index d10d3c5e7e..48731a86be 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt @@ -15,7 +15,7 @@ fun PlayerDialogs( // Episode list episodeDisplayMode: Long?, currentEpisodeIndex: Int, - episodeList: ImmutableList, + episodeList: List, dateRelativeTime: Boolean, dateFormat: String, onBookmarkClicked: (Long?, Boolean) -> Unit, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerPanels.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerPanels.kt index 9c1fad4230..674e964dbb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerPanels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerPanels.kt @@ -59,7 +59,7 @@ fun PlayerPanels( isItalic: Boolean, subJustify: SubtitleJustification, subFont: String, - subFontList: ImmutableList, + subFontList: List, subFontSize: Int, subBorderStyle: SubtitlesBorderStyle, subBorderSize: Int, 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 2106fac7bc..542b0865bf 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 @@ -26,7 +26,6 @@ import eu.kanade.tachiyomi.ui.player.ArtType import eu.kanade.tachiyomi.ui.player.Decoder import eu.kanade.tachiyomi.ui.player.Panels import eu.kanade.tachiyomi.ui.player.Sheets -import eu.kanade.tachiyomi.ui.player.VideoTrack import eu.kanade.tachiyomi.ui.player.controls.components.sheets.AudioTracksSheet import eu.kanade.tachiyomi.ui.player.controls.components.sheets.ChaptersSheet import eu.kanade.tachiyomi.ui.player.controls.components.sheets.HosterState @@ -35,6 +34,7 @@ import eu.kanade.tachiyomi.ui.player.controls.components.sheets.PlaybackSpeedShe import eu.kanade.tachiyomi.ui.player.controls.components.sheets.QualitySheet import eu.kanade.tachiyomi.ui.player.controls.components.sheets.ScreenshotSheet import eu.kanade.tachiyomi.ui.player.controls.components.sheets.SubtitlesSheet +import eu.kanade.tachiyomi.ui.player.mpv.VideoTrack import eu.kanade.tachiyomi.ui.player.settings.AudioChannels import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -46,19 +46,19 @@ fun PlayerSheets( sheetShown: Sheets, // subtitles sheet - subtitles: ImmutableList, + subtitles: List, onAddSubtitle: (Uri) -> Unit, onSelectSubtitle: (VideoTrack) -> Unit, // audio sheet - audioTracks: ImmutableList, + audioTracks: List, onAddAudio: (Uri) -> Unit, onSelectAudio: (VideoTrack) -> Unit, // video sheet isLoadingHosters: Boolean, - hosterState: ImmutableList, - expandedState: ImmutableList, + hosterState: List, + expandedState: List, selectedVideoIndex: Pair, onClickHoster: (Int) -> Unit, onClickVideo: (Int, Int) -> Unit, @@ -66,7 +66,7 @@ fun PlayerSheets( // chapters sheet chapter: Segment?, - chapters: ImmutableList, + chapters: List, onSeekToChapter: (Int) -> Unit, // Decoders sheet @@ -77,7 +77,7 @@ fun PlayerSheets( pitchCorrection: Boolean, onPitchCorrectionChange: (Boolean) -> Unit, speed: Float, - speedPresets: ImmutableList, + speedPresets: List, onSpeedChange: (Float) -> Unit, onAddSpeedPreset: (Float) -> Unit, onRemoveSpeedPreset: (Float) -> Unit, @@ -94,17 +94,16 @@ fun PlayerSheets( onAudioChannelsChange: (AudioChannels) -> Unit, onCustomButtonClick: (CustomButton) -> Unit, onCustomButtonLongClick: (CustomButton) -> Unit, - buttons: ImmutableList, + buttons: List, // Screenshot sheet isLocalSource: Boolean, showSubtitles: Boolean, onToggleShowSubtitles: (Boolean) -> Unit, - cachePath: String, onSetAsArt: (ArtType, (() -> InputStream)) -> Unit, onShare: (() -> InputStream) -> Unit, onSave: (() -> InputStream) -> Unit, - takeScreenshot: (String, Boolean) -> InputStream?, + takeScreenshot: (Boolean) -> InputStream?, onDismissScreenshot: () -> Unit, onOpenPanel: (Panels) -> Unit, @@ -211,7 +210,6 @@ fun PlayerSheets( hasSubTracks = subtitles.isNotEmpty(), showSubtitles = showSubtitles, onToggleShowSubtitles = onToggleShowSubtitles, - cachePath = cachePath, onSetAsArt = onSetAsArt, onShare = onShare, onSave = onSave, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/BrightnessOverlay.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/BrightnessOverlay.kt deleted file mode 100644 index 2118bd1bdf..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/BrightnessOverlay.kt +++ /dev/null @@ -1,33 +0,0 @@ -package eu.kanade.tachiyomi.ui.player.controls.components - -import androidx.annotation.FloatRange -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import kotlin.math.abs - -@Composable -fun BrightnessOverlay( - @FloatRange(from = -0.75, to = 1.0) brightness: Float, - modifier: Modifier = Modifier, -) { - if (brightness < 0) { - val brightnessAlpha = remember(brightness) { - abs(brightness) - } - - Canvas( - modifier = modifier - .fillMaxSize() - .graphicsLayer { - alpha = brightnessAlpha - }, - ) { - drawRect(Color.Black) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/SeekBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/SeekBar.kt index 12d4290488..7c125e0561 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/SeekBar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/SeekBar.kt @@ -73,7 +73,7 @@ fun SeekbarWithTimers( timersInverted: Pair, positionTimerOnClick: () -> Unit, durationTimerOnCLick: () -> Unit, - chapters: ImmutableList, + chapters: List, modifier: Modifier = Modifier, ) { val clickEvent = LocalPlayerButtonsClickEvent.current diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt index 1b5d1dd628..ae0cc6073b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt @@ -38,7 +38,6 @@ import eu.kanade.presentation.anime.components.DotSeparatorText import eu.kanade.presentation.util.formatEpisodeNumber import eu.kanade.tachiyomi.data.database.models.Episode import eu.kanade.tachiyomi.util.lang.toRelativeString -import kotlinx.collections.immutable.ImmutableList import tachiyomi.domain.anime.model.Anime import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.components.VerticalFastScroller @@ -48,13 +47,12 @@ import tachiyomi.presentation.core.i18n.stringResource import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun EpisodeListDialog( displayMode: Long?, currentEpisodeIndex: Int, - episodeList: ImmutableList, + episodeList: List, dateRelativeTime: Boolean, dateFormat: String, onBookmarkClicked: (Long?, Boolean) -> Unit, @@ -63,6 +61,7 @@ fun EpisodeListDialog( onDismissRequest: () -> Unit, ) { val context = LocalContext.current + val itemScrollIndex = (episodeList.size - currentEpisodeIndex) - 1 val episodeListState = rememberLazyListState(initialFirstVisibleItemIndex = itemScrollIndex) val dateFormatter = remember(dateFormat) { UiPreferences.dateFormat(dateFormat) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsColorsCard.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsColorsCard.kt index cbd6e2be81..05351c7175 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsColorsCard.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsColorsCard.kt @@ -167,23 +167,23 @@ enum class SubColorType( fun resetColors( preferences: SubtitlePreferences, - mpv: MPV?, + setStringValue: (String, String) -> Unit, type: SubColorType, ) { when (type) { SubColorType.Text -> { val textColor = preferences.textColorSubtitles.deleteAndGet().toColorHexString() - mpv?.setPropertyString("sub-color", textColor) + setStringValue("sub-color", textColor) } SubColorType.Border -> { val borderColor = preferences.borderColorSubtitles.deleteAndGet().toColorHexString() - mpv?.setPropertyString("sub-outline-color", borderColor) + setStringValue("sub-outline-color", borderColor) } SubColorType.Background -> { val backgroundColor = preferences.backgroundColorSubtitles.deleteAndGet().toColorHexString() - mpv?.setPropertyString("sub-back-color", backgroundColor) + setStringValue("sub-back-color", backgroundColor) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsPanel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsPanel.kt index a01de7625a..44a048b8cd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsPanel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsPanel.kt @@ -34,7 +34,7 @@ fun SubtitleSettingsPanel( isItalic: Boolean, justify: SubtitleJustification, font: String, - fontList: ImmutableList, + fontList: List, fontSize: Int, borderStyle: SubtitlesBorderStyle, borderSize: Int, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsTypographyCard.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsTypographyCard.kt index 34e349d3b0..e07ec66266 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsTypographyCard.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsTypographyCard.kt @@ -78,7 +78,7 @@ fun SubtitleSettingsTypographyCard( isItalic: Boolean, justify: SubtitleJustification, font: String, - fontList: ImmutableList, + fontList: List, fontSize: Int, borderStyle: SubtitlesBorderStyle, borderSize: Int, @@ -260,17 +260,19 @@ fun SubtitleSettingsTypographyCard( } fun resetTypography( - mpv: MPV, + setStringValue: (String, String) -> Unit, + setBooleanValue: (String, Boolean) -> Unit, + setIntValue: (String, Int) -> Unit, preferences: SubtitlePreferences, ) { - mpv.setPropertyBoolean("sub-bold", preferences.boldSubtitles.deleteAndGet()) - mpv.setPropertyBoolean("sub-italic", preferences.italicSubtitles.deleteAndGet()) - mpv.setPropertyString("sub-justify", preferences.subtitleJustification.deleteAndGet().value) - mpv.setPropertyString("sub-font", preferences.subtitleFont.deleteAndGet()) - mpv.setPropertyInt("sub-font-size", preferences.subtitleFontSize.deleteAndGet()) - mpv.setPropertyInt("sub-outline-size", preferences.subtitleBorderSize.deleteAndGet()) - mpv.setPropertyInt("sub-shadow-offset", preferences.shadowOffsetSubtitles.deleteAndGet()) - mpv.setPropertyString("sub-border-style", preferences.borderStyleSubtitles.deleteAndGet().value) + setBooleanValue("sub-bold", preferences.boldSubtitles.deleteAndGet()) + setBooleanValue("sub-italic", preferences.italicSubtitles.deleteAndGet()) + setStringValue("sub-justify", preferences.subtitleJustification.deleteAndGet().value) + setStringValue("sub-font", preferences.subtitleFont.deleteAndGet()) + setIntValue("sub-font-size", preferences.subtitleFontSize.deleteAndGet()) + setIntValue("sub-outline-size", preferences.subtitleBorderSize.deleteAndGet()) + setIntValue("sub-shadow-offset", preferences.shadowOffsetSubtitles.deleteAndGet()) + setStringValue("sub-border-style", preferences.borderStyleSubtitles.deleteAndGet().value) } enum class SubtitlesBorderStyle( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/AudioTracksSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/AudioTracksSheet.kt index 22225395d9..814b23a139 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/AudioTracksSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/AudioTracksSheet.kt @@ -39,8 +39,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import eu.kanade.tachiyomi.ui.player.TrackState -import eu.kanade.tachiyomi.ui.player.VideoTrack +import eu.kanade.tachiyomi.ui.player.mpv.TrackState +import eu.kanade.tachiyomi.ui.player.mpv.VideoTrack import kotlinx.collections.immutable.ImmutableList import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.components.material.padding @@ -48,7 +48,7 @@ import tachiyomi.presentation.core.i18n.stringResource @Composable fun AudioTracksSheet( - tracks: ImmutableList, + tracks: List, onSelect: (VideoTrack) -> Unit, onAddAudioTrack: () -> Unit, onOpenDelayPanel: () -> Unit, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ChaptersSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ChaptersSheet.kt index c17d41aea0..060d7a78c1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ChaptersSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ChaptersSheet.kt @@ -38,7 +38,7 @@ import tachiyomi.presentation.core.i18n.stringResource @Composable fun ChaptersSheet( - chapters: ImmutableList, + chapters: List, currentChapter: Segment, onClick: (Segment) -> Unit, onDismissRequest: () -> Unit, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/GenericTracksSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/GenericTracksSheet.kt index 33ad4721b3..a704b23da9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/GenericTracksSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/GenericTracksSheet.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import eu.kanade.presentation.player.components.PlayerSheet -import eu.kanade.tachiyomi.ui.player.VideoTrack +import eu.kanade.tachiyomi.ui.player.mpv.VideoTrack import kotlinx.collections.immutable.ImmutableList import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.components.material.padding @@ -47,7 +47,7 @@ import tachiyomi.presentation.core.i18n.stringResource @Composable fun GenericTracksSheet( - tracks: ImmutableList, + tracks: List, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, dismissEvent: Boolean = false, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/MoreSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/MoreSheet.kt index 2a9673bad3..9ac1fd51d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/MoreSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/MoreSheet.kt @@ -87,7 +87,7 @@ fun MoreSheet( onAudioChannelsChange: (AudioChannels) -> Unit, onDismissRequest: () -> Unit, onEnterFiltersPanel: () -> Unit, - customButtons: ImmutableList, + customButtons: List, modifier: Modifier = Modifier, ) { PlayerSheet( 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 76c94e3c99..36597b2af5 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 @@ -77,8 +77,8 @@ fun HosterState.Ready.getChangedAt(index: Int, newVideo: Video, newState: Video. @Composable fun QualitySheet( isLoadingHosters: Boolean, - hosterState: ImmutableList, - expandedState: ImmutableList, + hosterState: List, + expandedState: List, selectedVideoIndex: Pair, onClickHoster: (Int) -> Unit, onClickVideo: (Int, Int) -> Unit, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt index e616741ae3..48bfd9536a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt @@ -35,11 +35,10 @@ fun ScreenshotSheet( showSubtitles: Boolean, onToggleShowSubtitles: (Boolean) -> Unit, - cachePath: String, onSetAsArt: (ArtType, (() -> InputStream)) -> Unit, onShare: (() -> InputStream) -> Unit, onSave: (() -> InputStream) -> Unit, - takeScreenshot: (String, Boolean) -> InputStream?, + takeScreenshot: (Boolean) -> InputStream?, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, @@ -80,7 +79,7 @@ fun ScreenshotSheet( title = stringResource(MR.strings.action_share), icon = Icons.Outlined.Share, onClick = { - onShare { takeScreenshot(cachePath, showSubtitles)!! } + onShare { takeScreenshot(showSubtitles)!! } }, ) ActionButton( @@ -88,7 +87,7 @@ fun ScreenshotSheet( title = stringResource(MR.strings.action_save), icon = Icons.Outlined.Save, onClick = { - onSave { takeScreenshot(cachePath, showSubtitles)!! } + onSave { takeScreenshot(showSubtitles)!! } }, ) } @@ -116,10 +115,7 @@ fun ScreenshotSheet( modifier = Modifier.fillMaxWidth(fraction = 0.6F).padding(MaterialTheme.padding.medium), onConfirmRequest = { onSetAsArt(setArtTypeAs!!) { - takeScreenshot( - cachePath, - showSubtitles, - )!! + takeScreenshot(showSubtitles)!! } }, onDismissRequest = { setArtTypeAs = null }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/SubtitleTracksSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/SubtitleTracksSheet.kt index 18cbe5416b..3f69ab53ac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/SubtitleTracksSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/SubtitleTracksSheet.kt @@ -42,8 +42,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import eu.kanade.tachiyomi.ui.player.TrackState -import eu.kanade.tachiyomi.ui.player.VideoTrack +import eu.kanade.tachiyomi.ui.player.mpv.TrackState +import eu.kanade.tachiyomi.ui.player.mpv.VideoTrack import kotlinx.collections.immutable.ImmutableList import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.components.material.padding diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/BrightnessManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/BrightnessManager.kt index 1d813b2859..d71f5ee3cf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/BrightnessManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/BrightnessManager.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.player.domain import android.content.Context import android.provider.Settings -import eu.kanade.tachiyomi.ui.player.normalize class BrightnessManager( private val context: Context, @@ -13,4 +12,8 @@ class BrightnessManager( .normalize(0f, 255f, 0f, 1f) }.getOrElse { 0f } } + + private fun Float.normalize(inMin: Float, inMax: Float, outMin: Float, outMax: Float): Float { + return (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/TrackSelect.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/TrackSelect.kt index b9567d4617..5546a47f3d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/TrackSelect.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/domain/TrackSelect.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.ui.player.domain import androidx.core.os.LocaleListCompat -import eu.kanade.tachiyomi.ui.player.VideoTrack +import eu.kanade.tachiyomi.ui.player.mpv.VideoTrack import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences import eu.kanade.tachiyomi.ui.player.settings.SubtitlePreferences import uy.kohesive.injekt.Injekt diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerModels.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVModels.kt similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerModels.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVModels.kt index 9326ad375f..091d869870 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVModels.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.player +package eu.kanade.tachiyomi.ui.player.mpv import androidx.compose.runtime.Immutable import dev.vivvvek.seeker.Segment @@ -90,6 +90,12 @@ data class TrackNode( fun hasMetadata(): Boolean = !metadata.isNullOrEmpty() } +@Serializable +data class VideoParamNode( + @SerialName("w") val width: Long? = null, + @SerialName("h") val height: Long? = null, +) + enum class TrackState { Idle, Loading, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt new file mode 100644 index 0000000000..30335f4905 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt @@ -0,0 +1,416 @@ +package eu.kanade.tachiyomi.ui.player.mpv + +import android.content.Context +import android.content.Context.AUDIO_SERVICE +import android.media.AudioManager +import android.os.Build +import android.os.Environment +import android.os.Handler +import android.view.KeyCharacterMap +import android.view.KeyEvent +import androidx.media.AudioAttributesCompat +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat +import animiru.feature.mpvfiles.MpvConfig +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.network.NetworkPreferences +import eu.kanade.tachiyomi.ui.player.Debanding +import eu.kanade.tachiyomi.ui.player.VideoFilters +import eu.kanade.tachiyomi.ui.player.controls.components.panels.toColorHexString +import eu.kanade.tachiyomi.ui.player.settings.AdvancedPlayerPreferences +import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences +import eu.kanade.tachiyomi.ui.player.settings.DecoderPreferences +import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences +import eu.kanade.tachiyomi.ui.player.settings.SubtitleAssOverride +import eu.kanade.tachiyomi.ui.player.settings.SubtitlePreferences +import `is`.xyz.mpv.KeyMapping +import `is`.xyz.mpv.MPV +import `is`.xyz.mpv.MPVNode +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import logcat.LogPriority +import logcat.logcat +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlin.collections.component1 +import kotlin.collections.component2 + +class MPVPlayer( + context: Context, + videoOutput: String, + playerPreferences: PlayerPreferences = Injekt.get(), + decoderPreferences: DecoderPreferences = Injekt.get(), + networkPreferences: NetworkPreferences = Injekt.get(), + advancedPreferences: AdvancedPlayerPreferences = Injekt.get(), + private val subtitlePreferences: SubtitlePreferences = Injekt.get(), + private val audioPreferences: AudioPreferences = Injekt.get(), +) : MPV.EventObserver, MPV.LogObserver, AudioManager.OnAudioFocusChangeListener { + + val mpv: MPV + private val handler = Handler(context.mainLooper) + + private val audioManager by lazy { context.getSystemService(AUDIO_SERVICE) as AudioManager } + private var restoreAudioFocus: () -> Unit = {} + private var audioFocusRequest: AudioFocusRequestCompat? = null + + @Volatile + var isExiting = false + private var httpError: String? = null + + private val _eventFlow = MutableSharedFlow( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val eventFlow = _eventFlow.asSharedFlow() + + init { + val cachePath: String = context.cacheDir.path + + val mpvDir = UniFile.fromFile(context.filesDir)!!.createDirectory(MPV_DIR)!! + + val mpvConfFile = mpvDir.createFile("mpv.conf")!! + advancedPreferences.mpvConf.get().let { mpvConfFile.writeText(it) } + val mpvInputFile = mpvDir.createFile("input.conf")!! + advancedPreferences.mpvInput.get().let { mpvInputFile.writeText(it) } + + mpv = MPV(context) { + it.setOptionString("config", "yes") + it.setOptionString("config-dir", context.filesDir.resolve(MPV_DIR).toString()) + it.setOptionString("gpu-shader-cache-dir", cachePath) + it.setOptionString("icc-cache-dir", cachePath) + it.setOptionString("keep-open", "yes") + } + + val optionNameRegex = Regex("""^(?:--)?([\w-]+)(?:=|$)""", RegexOption.MULTILINE) + val mpvOptionNames = optionNameRegex.findAll(advancedPreferences.mpvConf.get()).map { + it.groupValues[1].removePrefix("no-") + }.toSet() + + // Set mpv option unless it's present in mpv.conf + fun setSafeOptionString(name: String, value: String) { + if (name in mpvOptionNames) return + mpv.setOptionString(name, value) + } + + mpv.setOptionString("vo", videoOutput) + setSafeOptionString("profile", "fast") + mpv.setOptionString("hwdec", if (decoderPreferences.tryHWDecoding.get()) "auto" else "no") + if (decoderPreferences.useYUV420P.get()) { + mpv.setOptionString("vf", "format=yuv420p") + } + + mpv.setOptionString("msg-level", "all=" + if (networkPreferences.verboseLogging.get()) "v" else "warn") + mpv.setPropertyBoolean("input-default-bindings", true) + mpv.setOptionString("idle", "yes") + mpv.setOptionString("ytdl", "no") + setSafeOptionString("tls-verify", "yes") + setSafeOptionString("tls-ca-file", "${context.filesDir.path}/${MpvConfig.MPV_DIR}/cacert.pem") + + // Selection is handled in viewmodel + mpv.setOptionString("sid", "no") + mpv.setOptionString("aid", "no") + + // Limit demuxer cache since the defaults are too high for mobile devices + val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32 + setSafeOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") + setSafeOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") + + val screenshotDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).also { + it.mkdirs() + } + mpv.setOptionString("screenshot-directory", screenshotDir.path) + + VideoFilters.entries.forEach { + mpv.setOptionString(it.mpvProperty, it.preference(decoderPreferences).get().toString()) + } + + mpv.setOptionString("speed", playerPreferences.playerSpeed.get().toString()) + // workaround for + setSafeOptionString("vd-lavc-film-grain", "cpu") + + when (decoderPreferences.debanding.get()) { + Debanding.None -> {} + Debanding.CPU -> mpv.setOptionString("vf", "gradfun=radius=12") + Debanding.GPU -> mpv.setOptionString("deband", "yes") + } + + advancedPreferences.playerStatisticsPage.get().let { + if (it != 0) { + mpv.command("script-binding", "stats/display-stats-toggle") + mpv.command("script-binding", "stats/display-page-$it") + } + } + + mpv.addObserver(this) + mpv.addLogObserver(this) + + setupSubtitlesOptions() + setupAudio() + + mapOf( + "eof-reached" to MPV.mpvFormat.MPV_FORMAT_FLAG, + + "user-data/aniyomi/show_text" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/toggle_ui" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/show_panel" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/software_keyboard" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/set_button_title" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/reset_button_title" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/toggle_button" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/switch_episode" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/pause" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/seek_by" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/seek_to" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/seek_by_with_text" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/seek_to_with_text" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/launch_int_picker" to MPV.mpvFormat.MPV_FORMAT_STRING, + "user-data/aniyomi/show_seek_text" to MPV.mpvFormat.MPV_FORMAT_STRING, + ).forEach { (name, format) -> + mpv.observeProperty(name, format) + } + } + + private fun UniFile.writeText(text: String) { + this.openOutputStream().use { + it.write(text.toByteArray()) + } + } + + private fun setupAudio() { + mpv.setOptionString("alang", audioPreferences.preferredAudioLanguages.get()) + mpv.setOptionString("audio-delay", (audioPreferences.audioDelay.get() / 1000.0).toString()) + mpv.setOptionString("audio-pitch-correction", audioPreferences.enablePitchCorrection.get().toString()) + mpv.setOptionString("volume-max", (audioPreferences.volumeBoostCap.get() + 100).toString()) + + audioPreferences.audioChannels.get().let { + mpv.setPropertyString(it.property, it.value) + } + + val request = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN).also { + it.setAudioAttributes( + AudioAttributesCompat.Builder().setUsage(AudioAttributesCompat.USAGE_MEDIA) + .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC).build(), + ) + it.setOnAudioFocusChangeListener(this) + }.build() + AudioManagerCompat.requestAudioFocus(audioManager, request).let { + if (it == AudioManager.AUDIOFOCUS_REQUEST_FAILED) return@let + audioFocusRequest = request + } + } + + private fun setupSubtitlesOptions() { + mpv.setOptionString("sub-delay", (subtitlePreferences.subtitlesDelay.get() / 1000.0).toString()) + mpv.setOptionString("sub-speed", subtitlePreferences.subtitlesSpeed.get().toString()) + mpv.setOptionString( + "secondary-sub-delay", + (subtitlePreferences.subtitlesSecondaryDelay.get() / 1000.0).toString(), + ) + + mpv.setOptionString("sub-font", subtitlePreferences.subtitleFont.get()) + subtitlePreferences.overrideSubsASS.get().let { + mpv.setOptionString("sub-ass-override", it.value) + if (it != SubtitleAssOverride.No) { + mpv.setOptionString("sub-ass-justify", "yes") + } + } + mpv.setOptionString("sub-font-size", subtitlePreferences.subtitleFontSize.get().toString()) + mpv.setOptionString("sub-bold", if (subtitlePreferences.boldSubtitles.get()) "yes" else "no") + mpv.setOptionString("sub-italic", if (subtitlePreferences.italicSubtitles.get()) "yes" else "no") + mpv.setOptionString("sub-justify", subtitlePreferences.subtitleJustification.get().value) + mpv.setOptionString("sub-color", subtitlePreferences.textColorSubtitles.get().toColorHexString()) + mpv.setOptionString( + "sub-back-color", + subtitlePreferences.backgroundColorSubtitles.get().toColorHexString(), + ) + mpv.setOptionString("sub-outline-color", subtitlePreferences.borderColorSubtitles.get().toColorHexString()) + mpv.setOptionString("sub-outline-size", subtitlePreferences.subtitleBorderSize.get().toString()) + mpv.setOptionString("sub-border-style", subtitlePreferences.borderStyleSubtitles.get().value) + mpv.setOptionString("sub-shadow-offset", subtitlePreferences.shadowOffsetSubtitles.get().toString()) + mpv.setOptionString("sub-pos", subtitlePreferences.subtitlePos.get().toString()) + mpv.setOptionString("sub-scale", subtitlePreferences.subtitleFontScale.get().toString()) + + val showBlackBars = if (subtitlePreferences.subtitleBlackBars.get()) "yes" else "no" + mpv.setOptionString("sub-ass-force-margins", showBlackBars) + mpv.setOptionString("sub-use-margins", showBlackBars) + } + + override fun eventProperty(property: String) { + handler.post { + if (isExiting) return@post + } + } + + override fun eventProperty(property: String, value: Long) { + handler.post { + if (isExiting) return@post + } + } + + override fun eventProperty(property: String, value: Boolean) { + handler.post { + if (isExiting) return@post + when (property) { + "eof-reached" -> _eventFlow.tryEmit(Event.EOF(value)) + } + } + } + + override fun eventProperty(property: String, value: String) { + handler.post { + if (isExiting) return@post + when (property.substringBeforeLast("/")) { + "user-data/aniyomi" -> _eventFlow.tryEmit(Event.LuaEvent(property, value)) + } + } + } + + override fun eventProperty(property: String, value: Double) { + handler.post { + if (isExiting) return@post + } + } + + override fun eventProperty(property: String, value: MPVNode) { + handler.post { + if (isExiting) return@post + } + } + + override fun event(eventId: Int, data: MPVNode) { + handler.post { + if (isExiting) return@post + when (eventId) { + MPV.mpvEvent.MPV_EVENT_FILE_LOADED -> _eventFlow.tryEmit(Event.FileLoaded) + MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> isExiting = false + MPV.mpvEvent.MPV_EVENT_END_FILE -> _eventFlow.tryEmit(Event.EndFile(data)) + } + } + } + + override fun logMessage(prefix: String, level: Int, text: String) { + if (level == MPV.mpvLogLevel.MPV_LOG_LEVEL_ERROR) { + if (text.startsWith(TRACK_LOAD_FAILURE)) { + val url = text.removePrefix(TRACK_LOAD_FAILURE).substringBeforeLast(".") + _eventFlow.tryEmit(Event.TrackLoadFailure(url)) + } + } + + val logPriority = when (level) { + MPV.mpvLogLevel.MPV_LOG_LEVEL_FATAL, MPV.mpvLogLevel.MPV_LOG_LEVEL_ERROR -> LogPriority.ERROR + MPV.mpvLogLevel.MPV_LOG_LEVEL_WARN -> LogPriority.WARN + MPV.mpvLogLevel.MPV_LOG_LEVEL_INFO -> LogPriority.INFO + else -> LogPriority.VERBOSE + } + if (text.contains("HTTP error")) httpError = text.removePrefix("http: ") + logcat("$TAG/$prefix", logPriority) { text } + } + + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + val oldRestore = restoreAudioFocus + val wasPlayerPaused = mpv.getPropertyBoolean("pause") ?: true + mpv.setPropertyBoolean("pause", true) + restoreAudioFocus = { + oldRestore() + if (!wasPlayerPaused) mpv.setPropertyBoolean("pause", false) + } + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + mpv.command("multiply", "volume", "0.5") + restoreAudioFocus = { + mpv.command("multiply", "volume", "2") + } + } + + AudioManager.AUDIOFOCUS_GAIN -> { + restoreAudioFocus() + restoreAudioFocus = {} + } + + AudioManager.AUDIOFOCUS_REQUEST_FAILED -> { + logcat(TAG, LogPriority.DEBUG) { "didn't get audio focus" } + } + } + } + + fun onKey(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_MULTIPLE || KeyEvent.isModifierKey(event.keyCode)) { + return false + } + + var mapped = KeyMapping[event.keyCode] + if (mapped == null) { + // Fallback to produced glyph + if (!event.isPrintingKey) { + if (event.repeatCount == 0) { + logcat(TAG, LogPriority.DEBUG) { "Unmapped non-printable key ${event.keyCode}" } + } + return false + } + + val ch = event.unicodeChar + if (ch.and(KeyCharacterMap.COMBINING_ACCENT) != 0) { + return false // dead key + } + mapped = ch.toChar().toString() + } + + if (event.repeatCount > 0) { + return true // eat event but ignore it, mpv has its own key repeat + } + + val mod: MutableList = mutableListOf() + event.isShiftPressed && mod.add("shift") + event.isCtrlPressed && mod.add("ctrl") + event.isAltPressed && mod.add("alt") + event.isMetaPressed && mod.add("meta") + + val action = if (event.action == KeyEvent.ACTION_DOWN) "keydown" else "keyup" + mod.add(mapped) + mpv.command(action, mod.joinToString("+")) + + return true + } + + // ===== End events ===== + + fun getHttpError(): String? { + return httpError + } + + fun resetHttpError() { + httpError = null + } + + fun release() { + isExiting = true + + audioFocusRequest?.let { + AudioManagerCompat.abandonAudioFocusRequest(audioManager, it) + } + audioFocusRequest = null + + handler.removeCallbacksAndMessages(null) + mpv.removeObserver(this) + mpv.removeLogObserver(this) + mpv.close() + } + + sealed interface Event { + data object FileLoaded : Event + data class EOF(val value: Boolean) : Event + data class TrackLoadFailure(val url: String) : Event + data class EndFile(val node: MPVNode) : Event + data class LuaEvent(val property: String, val value: String) : Event + } + + companion object { + private const val TAG = "mpv" + private const val MPV_DIR = "mpv" + const val TRACK_LOAD_FAILURE = "Can not open external file " + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/PlayerPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/PlayerPreferences.kt index fc71a23b06..e659efc8d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/PlayerPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/PlayerPreferences.kt @@ -31,7 +31,7 @@ class PlayerPreferences( val rememberPlayerBrightness: Preference = preferenceStore.getBoolean("pref_remember_brightness", false) val playerBrightnessValue: Preference = preferenceStore.getFloat("player_brightness_value", -1.0F) val rememberPlayerVolume: Preference = preferenceStore.getBoolean("pref_remember_volume", false) - val playerVolumeValue: Preference = preferenceStore.getFloat("player_volume_value", -1.0F) + val playerVolumeValue: Preference = preferenceStore.getInt("player_volume_value_v2", -1) // Hoster From d526d705389c39dffec79bcdbd8fff6588c3afb1 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Fri, 3 Jul 2026 11:01:54 +0200 Subject: [PATCH 02/14] Remove AniyomiMPVView --- .../tachiyomi/ui/player/AniyomiMPVView.kt | 237 ------------------ 1 file changed, 237 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt deleted file mode 100644 index ea7d68cee5..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright 2024 Abdallah Mehiz - * https://github.com/abdallahmehiz/mpvKt - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package eu.kanade.tachiyomi.ui.player - -import android.content.Context -import android.os.Build -import android.os.Environment -import android.util.AttributeSet -import android.view.KeyCharacterMap -import android.view.KeyEvent -import eu.kanade.tachiyomi.network.NetworkPreferences -import eu.kanade.tachiyomi.ui.player.controls.components.panels.toColorHexString -import eu.kanade.tachiyomi.ui.player.settings.AdvancedPlayerPreferences -import eu.kanade.tachiyomi.ui.player.settings.AudioPreferences -import eu.kanade.tachiyomi.ui.player.settings.DecoderPreferences -import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences -import eu.kanade.tachiyomi.ui.player.settings.SubtitleAssOverride -import eu.kanade.tachiyomi.ui.player.settings.SubtitlePreferences -import `is`.xyz.mpv.BaseMPVView -import `is`.xyz.mpv.KeyMapping -import `is`.xyz.mpv.MPV -import logcat.LogPriority -import logcat.logcat -import uy.kohesive.injekt.injectLazy - -class AniyomiMPVView(context: Context, attributes: AttributeSet?) : BaseMPVView(context, attributes) { - - private val playerPreferences: PlayerPreferences by injectLazy() - private val decoderPreferences: DecoderPreferences by injectLazy() - private val subtitlePreferences: SubtitlePreferences by injectLazy() - private val audioPreferences: AudioPreferences by injectLazy() - private val advancedPreferences: AdvancedPlayerPreferences by injectLazy() - private val networkPreferences: NetworkPreferences by injectLazy() - - var isExiting = false - - private val optionNameRegex = Regex("""^(?:--)?([\w-]+)(?:=|$)""", RegexOption.MULTILINE) - private val mpvOptionNames = optionNameRegex.findAll(advancedPreferences.mpvConf.get()).map { - it.groupValues[1].removePrefix("no-") - }.toSet() - - // Set mpv option unless it's present in mpv.conf - private fun setSafeOptionString(name: String, value: String) { - if (name in mpvOptionNames) return - mpv?.setOptionString(name, value) - } - - /** - * Returns the video aspect ratio. Rotation is taken into account. - */ - fun getVideoOutAspect(): Double? { - return mpv?.getPropertyDouble("video-params/aspect")?.let { - if (it < 0.001) return 0.0 - if ((mpv?.getPropertyInt("video-params/rotate") ?: 0) % 180 == 90) 1.0 / it else it - } - } - - fun init(mpvInst: MPV) { - this.mpv = mpvInst - setVo(if (decoderPreferences.gpuNext.get()) "gpu-next" else "gpu") - mpv?.setPropertyBoolean("pause", true) - setSafeOptionString("profile", "fast") - mpv?.setOptionString("hwdec", if (decoderPreferences.tryHWDecoding.get()) "auto" else "no") - - if (decoderPreferences.useYUV420P.get()) { - mpv?.setOptionString("vf", "format=yuv420p") - } - mpv?.setOptionString("msg-level", "all=" + if (networkPreferences.verboseLogging.get()) "v" else "warn") - - mpv?.setPropertyBoolean("input-default-bindings", true) - - mpv?.setOptionString("idle", "yes") - mpv?.setOptionString("ytdl", "no") - setSafeOptionString("tls-verify", "yes") - // setSafeOptionString("tls-ca-file", "${context.filesDir.path}/${PlayerActivity.MPV_DIR}/cacert.pem") - - // We handle selecting this in the viewmodel - mpv?.setOptionString("sid", "no") - mpv?.setOptionString("aid", "no") - - // Limit demuxer cache since the defaults are too high for mobile devices - val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32 - setSafeOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") - setSafeOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") - - val screenshotDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) - screenshotDir.mkdirs() - mpv?.setOptionString("screenshot-directory", screenshotDir.path) - - VideoFilters.entries.forEach { - mpv?.setOptionString(it.mpvProperty, it.preference(decoderPreferences).get().toString()) - } - - mpv?.setOptionString("speed", playerPreferences.playerSpeed.get().toString()) - // workaround for - setSafeOptionString("vd-lavc-film-grain", "cpu") - - postInitOptions() - setupSubtitlesOptions() - setupAudioOptions() - observeProperties() - } - - fun observeProperties() { - for ((name, format) in observedProps) mpv?.observeProperty(name, format) - } - - fun postInitOptions() { - when (decoderPreferences.debanding.get()) { - Debanding.None -> {} - Debanding.CPU -> mpv?.setOptionString("vf", "gradfun=radius=12") - Debanding.GPU -> mpv?.setOptionString("deband", "yes") - } - - advancedPreferences.playerStatisticsPage.get().let { - if (it != 0) { - mpv?.command("script-binding", "stats/display-stats-toggle") - mpv?.command("script-binding", "stats/display-page-$it") - } - } - } - - fun onKey(event: KeyEvent): Boolean { - if (event.action == KeyEvent.ACTION_MULTIPLE || KeyEvent.isModifierKey(event.keyCode)) { - return false - } - - var mapped = KeyMapping[event.keyCode] - if (mapped == null) { - // Fallback to produced glyph - if (!event.isPrintingKey) { - if (event.repeatCount == 0) { - logcat(LogPriority.DEBUG) { "Unmapped non-printable key ${event.keyCode}" } - } - return false - } - - val ch = event.unicodeChar - if (ch.and(KeyCharacterMap.COMBINING_ACCENT) != 0) { - return false // dead key - } - mapped = ch.toChar().toString() - } - - if (event.repeatCount > 0) { - return true // eat event but ignore it, mpv has its own key repeat - } - - val mod: MutableList = mutableListOf() - event.isShiftPressed && mod.add("shift") - event.isCtrlPressed && mod.add("ctrl") - event.isAltPressed && mod.add("alt") - event.isMetaPressed && mod.add("meta") - - val action = if (event.action == KeyEvent.ACTION_DOWN) "keydown" else "keyup" - mod.add(mapped) - mpv?.command(action, mod.joinToString("+")) - - return true - } - - private val observedProps = mapOf( - "pause" to MPV.mpvFormat.MPV_FORMAT_FLAG, - "video-params/aspect" to MPV.mpvFormat.MPV_FORMAT_DOUBLE, - "eof-reached" to MPV.mpvFormat.MPV_FORMAT_FLAG, - - "user-data/aniyomi/show_text" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/toggle_ui" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/show_panel" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/software_keyboard" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/set_button_title" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/reset_button_title" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/toggle_button" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/switch_episode" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/pause" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/seek_by" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/seek_to" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/seek_by_with_text" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/seek_to_with_text" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/launch_int_picker" to MPV.mpvFormat.MPV_FORMAT_STRING, - "user-data/aniyomi/show_seek_text" to MPV.mpvFormat.MPV_FORMAT_STRING, - ) - - private fun setupAudioOptions() { - mpv?.setOptionString("alang", audioPreferences.preferredAudioLanguages.get()) - mpv?.setOptionString("audio-delay", (audioPreferences.audioDelay.get() / 1000.0).toString()) - mpv?.setOptionString("audio-pitch-correction", audioPreferences.enablePitchCorrection.get().toString()) - mpv?.setOptionString("volume-max", (audioPreferences.volumeBoostCap.get() + 100).toString()) - } - - private fun setupSubtitlesOptions() { - mpv?.setOptionString("sub-delay", (subtitlePreferences.subtitlesDelay.get() / 1000.0).toString()) - mpv?.setOptionString("sub-speed", subtitlePreferences.subtitlesSpeed.get().toString()) - mpv?.setOptionString( - "secondary-sub-delay", - (subtitlePreferences.subtitlesSecondaryDelay.get() / 1000.0).toString(), - ) - - mpv?.setOptionString("sub-font", subtitlePreferences.subtitleFont.get()) - subtitlePreferences.overrideSubsASS.get().let { - mpv?.setOptionString("sub-ass-override", it.value) - if (it != SubtitleAssOverride.No) { - mpv?.setOptionString("sub-ass-justify", "yes") - } - } - mpv?.setOptionString("sub-font-size", subtitlePreferences.subtitleFontSize.get().toString()) - mpv?.setOptionString("sub-bold", if (subtitlePreferences.boldSubtitles.get()) "yes" else "no") - mpv?.setOptionString("sub-italic", if (subtitlePreferences.italicSubtitles.get()) "yes" else "no") - mpv?.setOptionString("sub-justify", subtitlePreferences.subtitleJustification.get().value) - mpv?.setOptionString("sub-color", subtitlePreferences.textColorSubtitles.get().toColorHexString()) - mpv?.setOptionString( - "sub-back-color", - subtitlePreferences.backgroundColorSubtitles.get().toColorHexString(), - ) - mpv?.setOptionString("sub-outline-color", subtitlePreferences.borderColorSubtitles.get().toColorHexString()) - mpv?.setOptionString("sub-outline-size", subtitlePreferences.subtitleBorderSize.get().toString()) - mpv?.setOptionString("sub-border-style", subtitlePreferences.borderStyleSubtitles.get().value) - mpv?.setOptionString("sub-shadow-offset", subtitlePreferences.shadowOffsetSubtitles.get().toString()) - mpv?.setOptionString("sub-pos", subtitlePreferences.subtitlePos.get().toString()) - mpv?.setOptionString("sub-scale", subtitlePreferences.subtitleFontScale.get().toString()) - } -} From 6da487cee2a2284ebf84c22da79a1c87b784a4e3 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Fri, 3 Jul 2026 21:01:06 +0200 Subject: [PATCH 03/14] Fix some PiP issues --- .../tachiyomi/ui/player/PlayerActivity.kt | 38 ++++++++++++++++--- .../tachiyomi/ui/player/mpv/MPVPlayer.kt | 1 + 2 files changed, 33 insertions(+), 6 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 d8b3e5c18c..a680bf726f 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 @@ -47,6 +47,8 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -90,6 +92,7 @@ class PlayerActivity : BaseActivity() { // private val connectionPreferences: ConnectionPreferences = Injekt.get() // <-- AM (DISCORD_RPC) + private var isResumed: Boolean = false private var pipRect: Rect? = null val isPipSupportedAndEnabled by lazy { packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && @@ -243,7 +246,7 @@ class PlayerActivity : BaseActivity() { PlayerScreen( viewModel = viewModel, onBack = { - if (isPipSupportedAndEnabled && viewModel.playbackData.value.paused && + if (isPipSupportedAndEnabled && !viewModel.playbackData.value.paused && playerPreferences.pipOnExit.get() ) { enterPictureInPictureMode(createPipParams()) @@ -251,7 +254,17 @@ class PlayerActivity : BaseActivity() { finish() } }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().onGloballyPositioned { + pipRect = run { + val boundsInWindow = it.boundsInWindow() + Rect( + boundsInWindow.left.toInt(), + boundsInWindow.top.toInt(), + boundsInWindow.right.toInt(), + boundsInWindow.bottom.toInt(), + ) + } + }, ) } } @@ -276,6 +289,7 @@ class PlayerActivity : BaseActivity() { } override fun onPause() { + isResumed = false viewModel.saveCurrentEpisodeWatchingProgress() if (isInPictureInPictureMode) { @@ -337,6 +351,7 @@ class PlayerActivity : BaseActivity() { } override fun onResume() { + isResumed = true if (!viewModel.isPlayerExiting()) { super.onResume() return @@ -395,11 +410,12 @@ class PlayerActivity : BaseActivity() { ) builder.setSourceRectHint(pipRect) viewModel.stateData.value.let { - if (it.videoWidth > 0 && it.videoHeight > 0) { - builder.setAspectRatio( - Rational(it.videoWidth, it.videoHeight), - ) + val rational = if (it.videoWidth > 0 && it.videoHeight > 0) { + Rational(it.videoWidth, it.videoHeight) + } else { + Rational(16, 9) } + builder.setAspectRatio(rational) } return builder.build() } @@ -411,6 +427,16 @@ class PlayerActivity : BaseActivity() { unregisterReceiver(pipReceiver) pipReceiver = null } + + window.decorView.postDelayed( + { + if (!isResumed) { + viewModel.player.release() + finish() + } + }, + 100, + ) } else { setPictureInPictureParams(createPipParams()) viewModel.hideControls() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt index 30335f4905..c6f72c062d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/mpv/MPVPlayer.kt @@ -387,6 +387,7 @@ class MPVPlayer( } fun release() { + if (isExiting) return isExiting = true audioFocusRequest?.let { From 169eba663e44446d28f999ba04394a8f560c0715 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Fri, 3 Jul 2026 21:06:42 +0200 Subject: [PATCH 04/14] Fix system bar --- .../main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt index 36488209c4..301969c433 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt @@ -29,6 +29,7 @@ import eu.kanade.tachiyomi.ui.player.components.BrightnessOverlay import eu.kanade.tachiyomi.ui.player.components.MpvSurface import eu.kanade.tachiyomi.ui.player.components.OrientationOverlay import eu.kanade.tachiyomi.ui.player.components.SystemAwakeOverlay +import eu.kanade.tachiyomi.ui.player.components.SystemBarOverlay import eu.kanade.tachiyomi.ui.player.controls.DoubleTapToSeekOvals import eu.kanade.tachiyomi.ui.player.controls.GestureHandler import eu.kanade.tachiyomi.ui.player.controls.LocalPlayerButtonsClickEvent @@ -129,6 +130,10 @@ fun PlayerScreen( brightness = playbackData.currentBrightness, ) + SystemBarOverlay( + showStatusBar = uiData.statusBarShown + ) + var resetControls by remember { mutableStateOf(true) } LaunchedEffect( From dec4dea5a03bd57574384b6187ae0a80b561edeb Mon Sep 17 00:00:00 2001 From: Secozzi Date: Fri, 3 Jul 2026 21:27:28 +0200 Subject: [PATCH 05/14] Fix isEpisodeOnline --- .../java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 5e5ca00119..ee1f4b4e6c 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 @@ -746,11 +746,10 @@ class PlayerViewModel @JvmOverloads constructor( updateStateData { it.copy(currentPlaylist = filtered.toList()) } } - private fun isEpisodeOnline(): Boolean? { + private fun isEpisodeOnline(episode: Episode): Boolean? { val currentState = stateData.value val anime = currentState.currentAnime ?: return null - val episode = currentState.currentEpisode ?: return null val source = currentState.currentSource ?: return null return source is AnimeHttpSource && !EpisodeLoader.isDownload( @@ -770,7 +769,7 @@ class PlayerViewModel @JvmOverloads constructor( it.copy( currentEpisode = episode, currentPlaylistIndex = currentEpisodeIndex, - isEpisodeOnline = isEpisodeOnline() == true, + isEpisodeOnline = isEpisodeOnline(episode) == true, hasPreviousEpisode = currentEpisodeIndex != 0, hasNextEpisode = currentEpisodeIndex != currentState.currentPlaylist.size - 1, ) From fd64ee5054bef3d24e4491379df024527f119e55 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Fri, 3 Jul 2026 21:35:09 +0200 Subject: [PATCH 06/14] Fix toast for episode title when in pip --- .../java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt | 5 +++++ .../main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt | 2 +- .../java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt | 3 ++- 3 files changed, 8 insertions(+), 2 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 a680bf726f..77e3772cf8 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 @@ -206,6 +206,11 @@ class PlayerActivity : BaseActivity() { PlayerViewModel.Event.EnterPip -> { enterPictureInPictureMode(createPipParams()) } + is PlayerViewModel.Event.EpisodeTitle -> { + if (isInPictureInPictureMode) { + showToast(event.name) + } + } PlayerViewModel.Event.Finish -> { finish() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt index 301969c433..870d857457 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt @@ -131,7 +131,7 @@ fun PlayerScreen( ) SystemBarOverlay( - showStatusBar = uiData.statusBarShown + showStatusBar = uiData.statusBarShown, ) var resetControls by remember { mutableStateOf(true) } 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 ee1f4b4e6c..4ee77b4ec7 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 @@ -1722,7 +1722,7 @@ class PlayerViewModel @JvmOverloads constructor( } if (pipEpisodeToasts) { - _eventFlow.emit(Event.ToastString(switchMethod.episodeTitle)) + _eventFlow.emit(Event.EpisodeTitle(switchMethod.episodeTitle)) } } } @@ -2866,6 +2866,7 @@ class PlayerViewModel @JvmOverloads constructor( sealed interface Event { data object EnterPip : Event + data class EpisodeTitle(val name: String) : Event data object Finish : Event data class InitialEpisodeError(val error: Throwable) : Event data class SavedImage(val result: SaveImageResult) : Event From a2c7e8fc79369079e897e71112e758c658affb2f Mon Sep 17 00:00:00 2001 From: Secozzi Date: Sat, 4 Jul 2026 00:26:35 +0200 Subject: [PATCH 07/14] Add compose stability plugin --- app/src/main/java/eu/kanade/tachiyomi/App.kt | 5 +++++ build.gradle.kts | 1 + gradle/build-logic/src/main/kotlin/PluginComposeAndroid.kt | 1 + gradle/libs.versions.toml | 4 +++- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index b376c1b31f..1d3ec82819 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -22,6 +22,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.allowRgb565 import coil3.request.crossfade import coil3.util.DebugLogger +import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer import dev.mihon.injekt.patchInjekt import eu.kanade.domain.DomainModule import eu.kanade.domain.base.BasePreferences @@ -155,6 +156,10 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor } initializeMigrator() + + // AM --> + ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) + // <-- AM } private fun initializeMigrator() { diff --git a/build.gradle.kts b/build.gradle.kts index 253ff367fd..17c7d1c0f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.moko.resources) apply false alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.stability.analyzer) apply false alias(mihonx.plugins.spotless) } diff --git a/gradle/build-logic/src/main/kotlin/PluginComposeAndroid.kt b/gradle/build-logic/src/main/kotlin/PluginComposeAndroid.kt index 8f961e06f3..72702037ab 100644 --- a/gradle/build-logic/src/main/kotlin/PluginComposeAndroid.kt +++ b/gradle/build-logic/src/main/kotlin/PluginComposeAndroid.kt @@ -16,6 +16,7 @@ class PluginComposeAndroid : Plugin { override fun apply(target: Project): Unit = with(target) { plugins { alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.stability.analyzer) } android { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d7089631d..50e6d67c0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ injekt = "91edab2317" jsoup = "1.22.2" junit = "6.1.0" kotest-assertions = "6.1.11" -kotlin-gradle = "2.3.21" +kotlin-gradle = "2.4.0" kotlinx-collections-immutable = "0.4.0" kotlinx-coroutines = "1.11.0" kotlinx-serialization = "1.11.0" @@ -64,6 +64,7 @@ shizuku = "13.1.5" spotless = "8.5.1" sqldelight = "2.3.2" sqldelight-androidx-driver = "0.2.0" +stability-analyzer = "0.10.0" stringSimilarity = "0.1.0" subsamplingScaleImageView = "66e0db195d" swipe = "1.3.0" @@ -200,6 +201,7 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi moko-resources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko-resources" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "stability-analyzer" } [bundles] androidx-lifecycle = ["androidx-lifecycle-common", "androidx-lifecycle-process", "androidx-lifecycle-runtime"] From 0d2e2656c659321236ca901c098e7f32accf871c Mon Sep 17 00:00:00 2001 From: Secozzi Date: Sat, 4 Jul 2026 00:27:13 +0200 Subject: [PATCH 08/14] Decrease composition count for player controls --- .../main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt | 2 +- .../kanade/tachiyomi/ui/player/controls/PlayerControls.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt index 870d857457..c180c28464 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerScreen.kt @@ -87,7 +87,7 @@ fun PlayerScreen( val pausedForCache by viewModel.propFlow("paused-for-cache").collectAsStateWithLifecycle() val coreIdle by viewModel.propFlow("core-idle").collectAsStateWithLifecycle() val readAhead by viewModel.propFlow("demuxer-cache-time").collectAsStateWithLifecycle() - val remaining by viewModel.propFlow("playtime-remaining").collectAsStateWithLifecycle() + val remaining by viewModel.propFlow("playtime-remaining").collectAsStateWithLifecycle() val playbackSpeed by viewModel.propFlow("speed").collectAsStateWithLifecycle() val currentChapter by viewModel.propFlow("chapter").collectAsStateWithLifecycle() 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 7fb979ad9d..42310172bf 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 @@ -61,7 +61,7 @@ fun PlayerControls( pausedForCache: Boolean?, coreIdle: Boolean?, readAhead: Float?, - remaining: Float?, + remaining: Int?, playbackSpeed: Float?, currentChapter: Int?, modifier: Modifier = Modifier, @@ -282,7 +282,7 @@ fun PlayerControls( SeekbarWithTimers( position = playbackData.position.toFloat(), duration = playbackData.duration.toFloat(), - remaining = remaining ?: 0f, + remaining = remaining?.toFloat() ?: 0f, readAheadValue = readAhead ?: 0f, onValueChange = { onPlayerEvent(PlayerEvent.Seek(it.roundToInt())) }, onValueChangeFinished = { onPlayerEvent(PlayerEvent.SeekFinished) }, @@ -448,7 +448,7 @@ private fun PlayerControlsPreview() { pausedForCache = false, coreIdle = false, readAhead = 0f, - remaining = 0f, + remaining = 0, playbackSpeed = 1f, currentChapter = 0, ) From 1ca132ccda2bc23445e7bf4ed3726865ad5eda04 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Sat, 4 Jul 2026 23:19:05 +0200 Subject: [PATCH 09/14] More PiP fixes --- .../tachiyomi/ui/player/PlayerActivity.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 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 77e3772cf8..639512ce93 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 @@ -52,6 +52,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import dev.icerock.moko.resources.StringResource import eu.kanade.presentation.theme.TachiyomiTheme @@ -433,16 +434,23 @@ class PlayerActivity : BaseActivity() { pipReceiver = null } - window.decorView.postDelayed( - { - if (!isResumed) { + if (lifecycle.currentState == Lifecycle.State.CREATED) { + window.decorView.postDelayed( + { viewModel.player.release() finish() - } - }, - 100, - ) + }, + 100, + ) + } else { + window.attributes = window.attributes.apply { + screenBrightness = viewModel.playbackData.value.currentBrightness.coerceIn(0f, 1f) + } + } } else { + window.attributes = window.attributes.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } setPictureInPictureParams(createPipParams()) viewModel.hideControls() viewModel.hideSeekBar() From f5dc23dcde51d41574dcb800a386e81fc3f53154 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Sat, 4 Jul 2026 23:19:55 +0200 Subject: [PATCH 10/14] Remove unused stuff --- .../main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt | 3 --- 1 file changed, 3 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 639512ce93..1bd74ae88d 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 @@ -93,7 +93,6 @@ class PlayerActivity : BaseActivity() { // private val connectionPreferences: ConnectionPreferences = Injekt.get() // <-- AM (DISCORD_RPC) - private var isResumed: Boolean = false private var pipRect: Rect? = null val isPipSupportedAndEnabled by lazy { packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && @@ -295,7 +294,6 @@ class PlayerActivity : BaseActivity() { } override fun onPause() { - isResumed = false viewModel.saveCurrentEpisodeWatchingProgress() if (isInPictureInPictureMode) { @@ -357,7 +355,6 @@ class PlayerActivity : BaseActivity() { } override fun onResume() { - isResumed = true if (!viewModel.isPlayerExiting()) { super.onResume() return From 11c1ee1a6d6c1779479abb98d2b6f1ed90d274e2 Mon Sep 17 00:00:00 2001 From: Secozzi Date: Sun, 5 Jul 2026 01:18:57 +0200 Subject: [PATCH 11/14] Fix play/pause button in PiP --- .../eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 4ee77b4ec7..d58d0e5a55 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 @@ -1738,8 +1738,16 @@ class PlayerViewModel @JvmOverloads constructor( } fun pauseUnpause() = mpvCommand("cycle", "pause") - fun pause() = setPropertyBoolean("pause", true) - fun unpause() = setPropertyBoolean("pause", false) + fun pause() { + setPropertyBoolean("pause", true) + + // PiP needs to know the state immediately, so we update it here + updatePlaybackData { it.copy(paused = true) } + } + fun unpause() { + setPropertyBoolean("pause", false) + updatePlaybackData { it.copy(paused = false) } + } private val showStatusBar = playerPreferences.showSystemStatusBar.get() fun showControls() { From 3dd7fb80e79c7e5991bb22ca020d2096c660b4bd Mon Sep 17 00:00:00 2001 From: Secozzi Date: Tue, 7 Jul 2026 09:16:31 +0200 Subject: [PATCH 12/14] Fix handling on broken videos --- .../tachiyomi/ui/player/PlayerViewModel.kt | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) 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 d58d0e5a55..a0e94806cf 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 @@ -1094,7 +1094,9 @@ class PlayerViewModel @JvmOverloads constructor( } logcat(LogPriority.ERROR) { errorMessage } - _eventFlow.tryEmit(Event.ToastString(errorMessage)) + viewModelScope.launch { + _eventFlow.emit(Event.ToastString(errorMessage)) + } setCurrentVideoError() @@ -1112,10 +1114,16 @@ class PlayerViewModel @JvmOverloads constructor( val currentHosterState = (stateData.value.hosterState[hosterIdx] as? HosterState.Ready) ?: return val currentVideo = currentHosterState.videoList[videoIdx] - updateHosterStateAt( - index = hosterIdx, - state = currentHosterState.getChangedAt(videoIdx, currentVideo, Video.State.ERROR), - ) + updateStateData { + it.copy( + currentVideo = null, + hosterState = getHosterStateAt( + hosters = it.hosterState, + index = hosterIdx, + state = currentHosterState.getChangedAt(videoIdx, currentVideo, Video.State.ERROR), + ), + ) + } } fun onVideoClicked(hosterIndex: Int, videoIndex: Int) { @@ -2629,15 +2637,17 @@ class PlayerViewModel @JvmOverloads constructor( } else { if (netflixStyle) { // show a toast with the seconds before the skip - _eventFlow.tryEmit( - Event.ToastString( - "Skip Intro: ${context.stringResource( - AYMR.strings.player_aniskip_dontskip_toast, - chapter.chapterTitle, - defaultWaitingTime, - )}", - ), - ) + viewModelScope.launch { + _eventFlow.emit( + Event.ToastString( + "Skip Intro: ${context.stringResource( + AYMR.strings.player_aniskip_dontskip_toast, + chapter.chapterTitle, + defaultWaitingTime, + )}", + ), + ) + } updateUiData { it.copy(skipIntroText = context.stringResource(AYMR.strings.player_aniskip_dontskip)) } updatePlaybackData { it.copy(netflixTimeout = defaultWaitingTime) } } else if (autoSkip) { From c36769afaa43d0ee940c848b21ae8aaa4d7b5a8a Mon Sep 17 00:00:00 2001 From: Secozzi Date: Tue, 7 Jul 2026 21:29:34 +0200 Subject: [PATCH 13/14] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a39b440ab..6513fd6603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ### Added - Added option to skip broken tracks on download ([@Secozzi](https://github.com/Secozzi)) ([#169](https://github.com/quickdesh/Animiru/pull/169)) +### Other +- Refactor video player code ([@Secozzi](https://github.com/Secozzi)) ([#170](https://github.com/quickdesh/Animiru/pull/170)) + ## [v0.19.7.7] - 2026-06-24 ### Fixed - Fix custom buttons not being added ([@Secozzi](https://github.com/Secozzi)) ([#164](https://github.com/quickdesh/Animiru/pull/164)) From f08409286fa6a69a3073ce7f0b444443e403803c Mon Sep 17 00:00:00 2001 From: Secozzi Date: Tue, 7 Jul 2026 21:32:22 +0200 Subject: [PATCH 14/14] fix pull request ref number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6513fd6603..b2bf417936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Added option to skip broken tracks on download ([@Secozzi](https://github.com/Secozzi)) ([#169](https://github.com/quickdesh/Animiru/pull/169)) ### Other -- Refactor video player code ([@Secozzi](https://github.com/Secozzi)) ([#170](https://github.com/quickdesh/Animiru/pull/170)) +- Refactor video player code ([@Secozzi](https://github.com/Secozzi)) ([#167](https://github.com/quickdesh/Animiru/pull/167)) ## [v0.19.7.7] - 2026-06-24 ### Fixed