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 e2cfc4846..2c529db49 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 @@ -51,6 +51,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.databinding.PlayerActivityBinding import eu.kanade.tachiyomi.ui.base.activity.BaseActivity +import eu.kanade.tachiyomi.ui.player.controls.PlayerControls import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences import eu.kanade.tachiyomi.ui.player.settings.PlayerSettingsScreenModel import eu.kanade.tachiyomi.ui.player.settings.dialogs.EpisodeListDialog @@ -158,9 +159,7 @@ class PlayerActivity : BaseActivity() { viewModel.saveCurrentEpisodeWatchingProgress() lifecycleScope.launchNonCancellable { - viewModel.mutableState.update { - it.copy(isLoadingEpisode = true) - } + viewModel.mutableState.update { it.copy(isLoadingEpisode = true) } val initResult = viewModel.init(animeId, episodeId, vidList, vidIndex) if (!initResult.second.getOrDefault(false)) { @@ -281,14 +280,9 @@ class PlayerActivity : BaseActivity() { private var hadPreviousAudio = false - private var videoChapters: List = emptyList() - set(value) { - field = value - runOnUiThread { - playerControls.seekbar.updateSeekbar(chapters = value) - playerControls.chapterText.updateCurrentChapterText(chapters = value) - } - } + private var videoChapters + get() = viewModel.state.value.videoChapters + set(value) { viewModel.mutableState.update { it.copy(videoChapters = value) } } override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { playerControls.resetControlsFade() @@ -519,6 +513,12 @@ class PlayerActivity : BaseActivity() { } } + binding.controlsRoot.setComposeContent { + val state by viewModel.state.collectAsState() + viewModel.onSecondReached(state.timeData.position, state.timeData.duration) + PlayerControls(activity = this) + } + playerIsDestroyed = false registerReceiver( @@ -563,7 +563,7 @@ class PlayerActivity : BaseActivity() { } if (playerPreferences.defaultIntroLength().get() == 0) { - playerControls.binding.controlsSkipIntroBtn.visibility = View.GONE + // TODO: A } refreshUi() @@ -573,7 +573,6 @@ class PlayerActivity : BaseActivity() { } else { playerControls.showAndFadeControls() } - playerControls.toggleAutoplay(playerPreferences.autoplayEnabled().get()) } private fun setupPlayerMPV() { @@ -886,8 +885,6 @@ class PlayerActivity : BaseActivity() { } else { playerPreferences.aspectState().get() } - - playerControls.setViewMode(showText = false) } private fun pauseForDialogSheet(fadeControls: Boolean = false): () -> Unit { @@ -1015,36 +1012,10 @@ class PlayerActivity : BaseActivity() { if (deviceWidth <= deviceHeight) { deviceWidth = deviceHeight.also { deviceHeight = deviceWidth } } - - playerControls.binding.episodeListBtn.updateLayoutParams { - rightToLeft = playerControls.binding.toggleAutoplay.id - rightToRight = ConstraintLayout.LayoutParams.UNSET - } - playerControls.binding.settingsBtn.updateLayoutParams { - topToTop = ConstraintLayout.LayoutParams.PARENT_ID - topToBottom = ConstraintLayout.LayoutParams.UNSET - } - playerControls.binding.toggleAutoplay.updateLayoutParams { - leftToLeft = ConstraintLayout.LayoutParams.UNSET - leftToRight = playerControls.binding.episodeListBtn.id - } } else { if (deviceWidth >= deviceHeight) { deviceWidth = deviceHeight.also { deviceHeight = deviceWidth } } - - playerControls.binding.episodeListBtn.updateLayoutParams { - rightToLeft = ConstraintLayout.LayoutParams.UNSET - rightToRight = ConstraintLayout.LayoutParams.PARENT_ID - } - playerControls.binding.settingsBtn.updateLayoutParams { - topToTop = ConstraintLayout.LayoutParams.UNSET - topToBottom = playerControls.binding.episodeListBtn.id - } - playerControls.binding.toggleAutoplay.updateLayoutParams { - leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID - leftToRight = ConstraintLayout.LayoutParams.UNSET - } } setupGestures() viewModel.closeDialogSheet() @@ -1114,7 +1085,6 @@ class PlayerActivity : BaseActivity() { internal fun showLoadingIndicator(visible: Boolean) { viewModel.viewModelScope.launchUI { binding.loadingIndicator.isVisible = visible - playerControls.binding.playBtn.isVisible = !visible } } @@ -1124,9 +1094,8 @@ class PlayerActivity : BaseActivity() { showLoadingIndicator(position >= cachePosition && seeking) } - @Suppress("UNUSED_PARAMETER") @SuppressLint("SourceLockedOrientationActivity") - fun rotatePlayer(view: View) { + fun rotatePlayer() { if (this.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { this.requestedOrientation = playerPreferences.defaultPlayerOrientationLandscape().get() } else { @@ -1144,27 +1113,6 @@ class PlayerActivity : BaseActivity() { internal fun doubleTapPlayPause() { animationHandler.removeCallbacks(doubleTapPlayPauseRunnable) playerControls.playPause() - - if (!playerControls.binding.unlockedView.isVisible) { - when { - player.paused!! -> { - binding.playPauseView.setImageResource(R.drawable.ic_pause_64dp) - } - - !player.paused!! -> { - binding.playPauseView.setImageResource(R.drawable.ic_play_arrow_64dp) - } - } - - AnimationUtils.loadAnimation(this, R.anim.player_fade_in).also { fadeAnimation -> - binding.playPauseView.startAnimation(fadeAnimation) - binding.playPauseView.visibility = View.VISIBLE - } - - animationHandler.postDelayed(doubleTapPlayPauseRunnable, 500L) - } else { - binding.playPauseView.visibility = View.GONE - } } private lateinit var doubleTapBg: ImageView @@ -1425,14 +1373,12 @@ class PlayerActivity : BaseActivity() { ) } - @Suppress("UNUSED_PARAMETER") - fun cycleSpeed(view: View) { + fun cycleSpeed() { player.cycleSpeed() refreshUi() } - @Suppress("UNUSED_PARAMETER") - fun skipIntro(view: View) { + fun skipIntro() { if (skipType != null) { // this stops the counter if (waitingAniSkip > 0 && netflixStyle) { @@ -1449,7 +1395,7 @@ class PlayerActivity : BaseActivity() { ) } AniSkipApi.PlayerUtils(binding, aniSkipInterval!!).skipAnimation(skipType!!) - } else if (playerControls.binding.controlsSkipIntroBtn.text != "") { + } else { doubleTapSeek(viewModel.getAnimeSkipIntroLength(), isDoubleTap = false) playerControls.resetControlsFade() } @@ -1461,14 +1407,16 @@ class PlayerActivity : BaseActivity() { internal fun refreshUi() { viewModel.viewModelScope.launchUI { setVisibilities() - player.timePos?.let { playerControls.updatePlaybackPos(it) } - player.duration?.let { playerControls.updatePlaybackDuration(it) } updatePlaybackStatus(player.paused ?: return@launchUI) updatePip(start = false) - playerControls.updateEpisodeText() - playerControls.updatePlaylistButtons() - playerControls.updateSpeedButton() withIOContext { player.loadTracks() } + player.playbackSpeed?.let { playerPreferences.playerSpeed().set(it.toFloat()) } + viewModel.updateSkipIntroText( + getString( + R.string.player_controls_skip_intro_text, + viewModel.getAnimeSkipIntroLength(), + ) + ) } } @@ -1490,9 +1438,6 @@ class PlayerActivity : BaseActivity() { } private fun updatePlaybackStatus(paused: Boolean) { - val r = if (paused) R.drawable.ic_play_arrow_64dp else R.drawable.ic_pause_64dp - playerControls.binding.playBtn.setImageResource(r) - if (paused) { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -1610,7 +1555,6 @@ class PlayerActivity : BaseActivity() { episode.last_second_seen } MPVLib.command(arrayOf("set", "start", "${resumePosition / 1000F}")) - playerControls.updatePlaybackDuration(resumePosition.toInt() / 1000) } } else { player.timePos?.let { @@ -1868,7 +1812,7 @@ class PlayerActivity : BaseActivity() { private var skipType: SkipType? = null - private suspend fun aniSkipStuff(position: Long) { + private fun aniSkipStuff(position: Long) { if (!aniSkipEnable) return // if it doesn't find any interval it will show the +85 button if (aniSkipInterval == null) return @@ -1914,14 +1858,14 @@ class PlayerActivity : BaseActivity() { internal fun eventPropertyUi(property: String, value: Long) { when (property) { - "demuxer-cache-time" -> playerControls.updateBufferPosition(value.toInt()) + "demuxer-cache-time" -> viewModel.updatePlayerTime(readAhead = value) "time-pos" -> { - playerControls.updatePlaybackPos(value.toInt()) + viewModel.updatePlayerTime(position = value) viewModel.viewModelScope.launchUI { aniSkipStuff(value) } updatePlaybackState() } "duration" -> { - playerControls.updatePlaybackDuration(value.toInt()) + viewModel.updatePlayerTime(duration = value) mediaSession.isActive = true updatePlaybackState() } @@ -1941,6 +1885,7 @@ class PlayerActivity : BaseActivity() { updatePlaybackStatus(value) updatePlaybackState(pause = true) refreshUi() + viewModel.updatePlayerTime(paused = value) } } "eof-reached" -> endFile(value) 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 f83843a6c..c1f811231 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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.icerock.moko.resources.StringResource import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.entries.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.items.episode.model.toDbEpisode @@ -30,6 +31,7 @@ import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.ui.player.loader.EpisodeLoader import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences +import eu.kanade.tachiyomi.ui.player.viewer.SeekState import eu.kanade.tachiyomi.ui.player.viewer.SetAsCover import eu.kanade.tachiyomi.ui.reader.SaveImageNotifier import eu.kanade.tachiyomi.util.AniSkipApi @@ -40,6 +42,7 @@ 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.MPVView import `is`.xyz.mpv.Utils import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -69,6 +72,7 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.InputStream import java.util.Date +import tachiyomi.i18n.MR class PlayerViewModel @JvmOverloads constructor( private val savedState: SavedStateHandle, @@ -354,7 +358,7 @@ class PlayerViewModel @JvmOverloads constructor( * 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: Int, duration: Int) { + fun onSecondReached(position: Long, duration: Long) { if (state.value.isLoadingEpisode) return val currentEp = currentEpisode ?: return if (episodeId == -1L) return @@ -729,8 +733,31 @@ class PlayerViewModel @JvmOverloads constructor( mutableState.update { it.copy(dialog = null, sheet = null) } } + fun updateSkipIntroText(text: String) { + mutableState.update { it.copy(skipIntroText = text) } + } + + fun updatePlayerInformation(stringResource: StringResource) { + mutableState.update { it.copy(playerInformation = stringResource) } + } + + fun updateSeekState(seekState: SeekState) { + mutableState.update { it.copy(seekState = seekState) } + } + + fun updatePlayerTime(paused: Boolean? = null, position: Long? = null, duration: Long? = null, readAhead: Long? = null) { + with(state.value.timeData) { + val pause = paused ?: this.paused + val pos = position ?: this.position + val dur = duration ?: this.duration + val rea = readAhead ?: this.readAhead + mutableState.update { it.copy(timeData = TimeData(pause, pos, dur, rea)) } + } + } + @Immutable data class State( + val timeData: TimeData = TimeData(), val episodeList: List = emptyList(), val episode: Episode? = null, val anime: Anime? = null, @@ -739,25 +766,33 @@ class PlayerViewModel @JvmOverloads constructor( val isLoadingEpisode: Boolean = false, val dialog: Dialog? = null, val sheet: Sheet? = null, + val videoChapters: List = emptyList(), + val skipIntroText: String = "", + val playerInformation: StringResource = MR.strings.enable_auto_play, + val seekState: SeekState = SeekState.NONE, ) + class TimeData(val paused: Boolean, val position: Long, val duration: Long, val readAhead: Long) { + constructor() : this(false,0L, 0L, 0L) + } + class VideoStreams(val quality: Stream, val subtitle: Stream, val audio: Stream) { constructor() : this(Stream(), Stream(), Stream()) class Stream(var index: Int = 0, var tracks: Array = emptyArray()) } sealed class Dialog { - object EpisodeList : Dialog() - object SpeedPicker : Dialog() - object SkipIntroLength : Dialog() + data object EpisodeList : Dialog() + data object SpeedPicker : Dialog() + data object SkipIntroLength : Dialog() } sealed class Sheet { - object SubtitleSettings : Sheet() - object ScreenshotOptions : Sheet() - object PlayerSettings : Sheet() - object VideoChapters : Sheet() - object StreamsCatalog : Sheet() + data object SubtitleSettings : Sheet() + data object ScreenshotOptions : Sheet() + data object PlayerSettings : Sheet() + data object VideoChapters : Sheet() + data object StreamsCatalog : Sheet() } sealed class Event { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/BottomPlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/BottomPlayerControls.kt new file mode 100644 index 000000000..2a72ecfa1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/BottomPlayerControls.kt @@ -0,0 +1,285 @@ +package eu.kanade.tachiyomi.ui.player.controls + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoStories +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.player.PlayerActivity +import eu.kanade.tachiyomi.ui.player.viewer.AspectState +import eu.kanade.tachiyomi.ui.player.viewer.InvertedPlayback +import eu.kanade.tachiyomi.ui.player.viewer.SeekState +import eu.kanade.tachiyomi.ui.player.viewer.components.Seekbar +import `is`.xyz.mpv.MPVLib +import `is`.xyz.mpv.MPVView +import `is`.xyz.mpv.Utils +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.util.collectAsState + +@Composable +fun BottomPlayerControls( + activity: PlayerActivity, + modifier: Modifier = Modifier, +) { + val viewModel = activity.viewModel + val state by viewModel.state.collectAsState() + val preferences = activity.playerPreferences + + val includePip = !preferences.pipOnExit().get() && activity.supportedAndEnabled + + fun onPositionChange(value: Float, wasSeeking: Boolean) { + if (!wasSeeking) { + SeekState.mode = SeekState.SEEKBAR + activity.initSeek() + } + + MPVLib.command(arrayOf("seek", value.toInt().toString(), "absolute+keyframes")) + + val duration = activity.player.duration ?: 0 + if (duration == 0 || activity.initialSeek < 0) { + return + } + + val difference = value.toInt() - activity.initialSeek + + activity.playerControls.showSeekText(value.toInt(), difference) + } + + fun onPositionChangeFinished(value: Float) { + if (SeekState.mode == SeekState.SEEKBAR) { + if (preferences.playerSmoothSeek().get()) { + activity.player.timePos = value.toInt() + } else { + MPVLib.command( + arrayOf("seek", value.toInt().toString(), "absolute+keyframes"), + ) + } + SeekState.mode = SeekState.NONE + } else { + MPVLib.command(arrayOf("seek", value.toInt().toString(), "absolute+keyframes")) + } + } + + + + fun setViewMode() { + viewModel.updatePlayerInformation(AspectState.mode.stringRes) + var aspect = "-1" + var pan = "1.0" + when (AspectState.mode) { + AspectState.CROP -> { + pan = "1.0" + } + AspectState.FIT -> { + pan = "0.0" + } + AspectState.STRETCH -> { + aspect = "${activity.deviceWidth}/${activity.deviceHeight}" + pan = "0.0" + } + AspectState.CUSTOM -> { + aspect = MPVLib.getPropertyString("video-aspect-override") + } + } + + MPVLib.setPropertyString("video-aspect-override", aspect) + MPVLib.setPropertyString("panscan", pan) + preferences.aspectState().set(AspectState.mode) + } + + fun cycleViewMode() { + AspectState.mode = when (AspectState.mode) { + AspectState.FIT -> AspectState.CROP + AspectState.CROP -> AspectState.STRETCH + else -> AspectState.FIT + } + setViewMode() + } + + BoxWithConstraints( + contentAlignment = Alignment.BottomStart, + modifier = modifier.padding(all = 10.dp) + ) { + Column(verticalArrangement = Arrangement.Bottom) { + PlayerRow(modifier = Modifier.fillMaxWidth()) { + + // Bottom - Left Controls + PlayerRow { + PlayerIcon(icon = Icons.Outlined.Lock) { viewModel.updateSeekState(SeekState.LOCKED) } + PlayerIcon(icon = Icons.Outlined.ScreenRotation) { activity.rotatePlayer() } + PlayerTextButton( + text = stringResource( + id = R.string.ui_speed, + preferences.playerSpeed().collectAsState().value, + ), + onClick = activity::cycleSpeed, + onLongClick = activity.viewModel::showSpeedPicker, + ) + + if (state.videoChapters.isNotEmpty()) { + val currentChapter = state.videoChapters.last { it.time <= state.timeData.position } + ChapterButton(chapter = currentChapter) + } + + } + + // Bottom - Right Controls + PlayerRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + PlayerTextButton( + text = state.skipIntroText, + onClick = activity::skipIntro, + onLongClick = activity.viewModel::showSkipIntroLength, + ) + + PlayerIcon(icon = Icons.Outlined.Fullscreen) { cycleViewMode() } + + if (includePip) PlayerIcon(icon = Icons.Outlined.PictureInPictureAlt) { activity.updatePip(start = true) } + } + } + + PlayerRow(modifier = Modifier.fillMaxWidth()) { + fun getTimeText(time: Long) = Utils.prettyTime(time.toInt(), true).replace("+", "") + val invertedPlayback = preferences.invertedPlayback().collectAsState().value + + val position = when (invertedPlayback) { + InvertedPlayback.NONE, InvertedPlayback.DURATION -> state.timeData.position + InvertedPlayback.POSITION -> state.timeData.position - state.timeData.duration + } + val onPositionCLicked = { + preferences.invertedPlayback().set( + when (invertedPlayback) { + InvertedPlayback.NONE, InvertedPlayback.DURATION -> InvertedPlayback.POSITION + InvertedPlayback.POSITION -> InvertedPlayback.NONE + }, + ) + } + + val duration = when (invertedPlayback) { + InvertedPlayback.NONE, InvertedPlayback.POSITION -> state.timeData.duration + InvertedPlayback.DURATION -> state.timeData.position - state.timeData.duration + } + val onDurationCLicked = { + preferences.invertedPlayback().set( + when (invertedPlayback) { + InvertedPlayback.NONE, InvertedPlayback.POSITION -> InvertedPlayback.DURATION + InvertedPlayback.DURATION -> InvertedPlayback.NONE + } + ) + } + + PlayerTextButton(text = getTimeText(position), onClick = onPositionCLicked) + + Seekbar( + position = state.timeData.position.toFloat(), + duration = state.timeData.duration.toFloat(), + readAhead = state.timeData.readAhead.toFloat(), + chapters = state.videoChapters, + onPositionChange = ::onPositionChange, + onPositionChangeFinished = ::onPositionChangeFinished, + modifier = Modifier.widthIn(max = this@BoxWithConstraints.maxWidth - (textButtonWidth * 2)) + ) + + PlayerTextButton(text = getTimeText(duration), onClick = onDurationCLicked) + } + } + } +} + +@Composable +private fun ChapterButton( + chapter: MPVView.Chapter, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(25)) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6F)) + .padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small), + ) { + AnimatedContent( + targetState = chapter, + transitionSpec = { + if (targetState.time > initialState.time) { + (slideInVertically { height -> height } + fadeIn()) + .togetherWith(slideOutVertically { height -> -height } + fadeOut()) + } else { + (slideInVertically { height -> -height } + fadeIn()) + .togetherWith(slideOutVertically { height -> height } + fadeOut()) + }.using( + SizeTransform(clip = false), + ) + }, + label = "Chapter", + ) { currentChapter -> + Row { + Icon( + imageVector = Icons.Outlined.AutoStories, + contentDescription = null, + modifier = Modifier + .padding(end = MaterialTheme.padding.small) + .size(16.dp), + ) + Text( + text = Utils.prettyTime(currentChapter.time.toInt()), + textAlign = TextAlign.Center, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Clip, + color = MaterialTheme.colorScheme.tertiary, + ) + currentChapter.title?.let { + Text( + text = " • ", + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + Text( + text = it, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/MiddlePlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/MiddlePlayerControls.kt new file mode 100644 index 000000000..385a594b0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/MiddlePlayerControls.kt @@ -0,0 +1,59 @@ +package eu.kanade.tachiyomi.ui.player.controls + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import eu.kanade.tachiyomi.ui.player.PlayerActivity +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun MiddlePlayerControls( + activity: PlayerActivity, + modifier: Modifier = Modifier, +) { + val viewModel = activity.viewModel + val state by viewModel.state.collectAsState() + + val playPauseIcon = if(state.timeData.paused) Icons.Filled.PlayArrow else Icons.Filled.Pause + + fun switchEpisode(previous: Boolean) { + return activity.changeEpisode(viewModel.getAdjacentEpisodeId(previous = previous)) + } + + val plCount = viewModel.currentPlaylist.size + val plPos = viewModel.getCurrentEpisodeIndex() + + Box(contentAlignment = Alignment.Center, modifier = modifier) { + PlayerRow(horizontalArrangement = Arrangement.spacedBy(iconSize * 2)) { + PlayerIcon( + icon = Icons.Filled.SkipPrevious, + multiplier = 2, + enabled = plPos != 0 + ) { switchEpisode(previous = true) } + + PlayerIcon(icon = playPauseIcon, multiplier = 3) { activity.playerControls.playPause() } + + PlayerIcon( + icon = Icons.Filled.SkipNext, + multiplier = 2, + enabled = plPos != plCount - 1 + ) { switchEpisode(previous = false) } + } + + Text( + text = stringResource(resource = state.playerInformation), + modifier = Modifier.padding(top = iconSize * 8), + ) + } +} 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 new file mode 100644 index 000000000..12ef4e493 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt @@ -0,0 +1,177 @@ +package eu.kanade.tachiyomi.ui.player.controls + +import android.annotation.SuppressLint +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.commonClickable +import eu.kanade.tachiyomi.ui.player.PlayerActivity +import eu.kanade.tachiyomi.ui.player.viewer.SeekState +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay + +@Composable +fun PlayerControls( + activity: PlayerActivity, + modifier: Modifier = Modifier, + timerState: TimerState = rememberTimerState(), +) { + val state by activity.viewModel.state.collectAsState() + + val onPlayerPressed = { timerState.controls = if (timerState.controls > 0L) 0L else 3500L } + val playerModifier = modifier.pointerInput(Unit) { detectTapGestures(onPress = { onPlayerPressed() }) } + + Surface(modifier = playerModifier, color = Color.Transparent) { + if (state.seekState == SeekState.LOCKED) { + LockedPlayerControls { activity.viewModel.updateSeekState(SeekState.NONE) } + } else { + if (timerState.controls > 0L) UnlockedPlayerControls(activity) + } + + Text(timerState.controls.toString()) + } + + LaunchedEffect(key1 = timerState.controls, key2 = state.timeData.paused) { + if(timerState.controls > 0L && !state.timeData.paused) { + delay(500L) + timerState.controls -= 500L + } + } +} + +@Composable +private fun LockedPlayerControls( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.TopStart, + modifier = modifier.padding(all = 10.dp) + ) { + PlayerIcon(icon = Icons.Outlined.LockOpen, onClick = onClick) + } +} + +@SuppressLint("ComposeModifierReused") +@Composable +private fun UnlockedPlayerControls( + activity: PlayerActivity, + modifier: Modifier = Modifier, +) { + Surface(color = Color(color = 0x70000000)) { + TopPlayerControls(activity) + MiddlePlayerControls(activity) + BottomPlayerControls(activity) + } +} + +val iconSize = 20.dp +val buttonSize = iconSize + 30.dp +val textButtonWidth = 80.dp + +@Composable +fun PlayerIcon( + icon: ImageVector, + modifier: Modifier = Modifier, + multiplier: Int = 1, + enabled: Boolean = true, + timerState: TimerState = rememberTimerState(), + onClick: () -> Unit = {}, +) { + val iconSize = iconSize * multiplier + val buttonSize = iconSize + 30.dp + val onPlayerPressed = { timerState.controls = if (timerState.controls > 0L) 0L else 3500L } + val buttonModifier = modifier.size(buttonSize).pointerInput(Unit) { detectTapGestures { onClick() } } + + IconButton(onClick = { onClick(); onPlayerPressed() }, modifier = modifier.size(buttonSize), enabled = enabled){ + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(iconSize) + ) + } +} + +@Composable +fun PlayerRow( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + content: @Composable RowScope.() -> Unit +) = Row( + modifier = modifier, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + content = content +) + +@Composable +fun PlayerTextButton( + text: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, +){ + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(width = textButtonWidth, height = buttonSize) + .commonClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick, + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +class TimerState ( + controls: Long = 3500L, +) { + var controls by mutableStateOf(controls) +} + +@Composable +fun rememberTimerState( + controls: Long = 3500L, +): TimerState { + return remember { + TimerState( + controls = controls, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/TopPlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/TopPlayerControls.kt new file mode 100644 index 000000000..6c604b202 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/TopPlayerControls.kt @@ -0,0 +1,159 @@ +package eu.kanade.tachiyomi.ui.player.controls + +import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.AutoStories +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.VideoSettings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.ui.player.PlayerActivity +import eu.kanade.tachiyomi.ui.player.PlayerViewModel +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.util.collectAsState + +@Composable +fun TopPlayerControls( + activity: PlayerActivity, + modifier: Modifier = Modifier, +) { + val viewModel = activity.viewModel + val state by viewModel.state.collectAsState() + + val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + + fun toggleAutoplay(autoplay: Boolean) { + with(activity.playerPreferences.autoplayEnabled()){ + this.set(autoplay) + val playerInformation = if (this.get()) MR.strings.enable_auto_play else MR.strings.disable_auto_play + viewModel.updatePlayerInformation(playerInformation) + } + + } + + Box( + contentAlignment = Alignment.TopStart, + modifier = modifier.padding(all = 10.dp) + ) { + SecondaryTopControlsLayout(isPortrait) { + + PlayerRow { + PlayerIcon(icon = Icons.AutoMirrored.Outlined.ArrowBack, onClick = activity::onBackPressed) + + PlayerRow(modifier = Modifier.clickable { viewModel.showEpisodeList() }) { + Column(Modifier.padding(horizontal = 10.dp)) { + Text( + text = state.anime?.title ?: "", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = state.episode?.name ?: "", + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alpha(0.5f), + ) + } + + val expanded = state.dialog == PlayerViewModel.Dialog.EpisodeList + val rotation by animateFloatAsState(if (expanded) 90f else 0f) + PlayerIcon( + icon = Icons.Outlined.ChevronRight, + modifier = Modifier.rotate(rotation), + ) { viewModel.showEpisodeList() } + } + } + + PlayerRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isPortrait) Arrangement.SpaceEvenly else Arrangement.End, + ) { + AutoplaySwitch( + checked = activity.playerPreferences.autoplayEnabled().collectAsState(), + onClick = ::toggleAutoplay, + ) + + + + PlayerIcon(icon = Icons.Outlined.VideoSettings) { viewModel.showStreamsCatalog() } + PlayerIcon(icon = Icons.Outlined.MoreVert) { viewModel.showPlayerSettings() } + + } + } + } +} + +@Composable +private fun SecondaryTopControlsLayout( + isPortrait: Boolean, + content: @Composable () -> Unit +) { + if (isPortrait) { + Column { content() } + } else { + PlayerRow { content() } + } +} + +@Composable +private fun AutoplaySwitch( + checked: State, + onClick: (Boolean) -> Unit, +) { + Switch( + checked = checked.value, + onCheckedChange = onClick, + modifier = Modifier.padding(horizontal = MaterialTheme.padding.small), + thumbContent = if (checked.value) { + { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } else { + { + Icon( + imageVector = Icons.Filled.Pause, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/PlayerControlsView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/PlayerControlsView.kt index f819b3cd2..acb6db441 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/PlayerControlsView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/PlayerControlsView.kt @@ -39,203 +39,23 @@ class PlayerControlsView @JvmOverloads constructor(context: Context, attrs: Attr private val player get() = activity.player - val seekbar: Seekbar = Seekbar( - view = binding.playbackSeekbar, - onValueChange = ::onValueChange, - onValueChangeFinished = ::onValueChangeFinished, - ) - - val chapterText: CurrentChapter = CurrentChapter( - view = binding.currentChapter, - onClick = { activity.viewModel.showVideoChapters() }, - ) - - private fun onValueChange(value: Float, wasSeeking: Boolean) { - if (!wasSeeking) { - SeekState.mode = SeekState.SEEKBAR - activity.initSeek() - } - - MPVLib.command(arrayOf("seek", value.toInt().toString(), "absolute+keyframes")) - - val duration = player.duration ?: 0 - if (duration == 0 || activity.initialSeek < 0) { - return - } - - val difference = value.toInt() - activity.initialSeek - - showSeekText(value.toInt(), difference) - } - - private fun onValueChangeFinished(value: Float) { - if (SeekState.mode == SeekState.SEEKBAR) { - if (playerPreferences.playerSmoothSeek().get()) { - player.timePos = value.toInt() - } else { - MPVLib.command( - arrayOf("seek", value.toInt().toString(), "absolute+keyframes"), - ) - } - SeekState.mode = SeekState.NONE - animationHandler.removeCallbacks(hideUiForSeekRunnable) - animationHandler.removeCallbacks(fadeOutControlsRunnable) - animationHandler.postDelayed(hideUiForSeekRunnable, 500L) - animationHandler.postDelayed(fadeOutControlsRunnable, 3500L) - } else { - MPVLib.command(arrayOf("seek", value.toInt().toString(), "absolute+keyframes")) - } - } - init { addView(binding.root) } - @Suppress("DEPRECATION") override fun onViewAdded(child: View?) { - binding.backArrowBtn.setOnClickListener { activity.onBackPressed() } - - // Lock and Unlock controls - binding.lockBtn.setOnClickListener { lockControls(true) } - binding.unlockBtn.setOnClickListener { lockControls(false) } - - // Long click controls - binding.cycleSpeedBtn.setOnLongClickListener { - activity.viewModel.showSpeedPicker() - true - } - - binding.prevBtn.setOnClickListener { switchEpisode(previous = true) } - binding.playBtn.setOnClickListener { playPause() } - binding.nextBtn.setOnClickListener { switchEpisode(previous = false) } - - binding.pipBtn.setOnClickListener { activity.updatePip(start = true) } - - binding.pipBtn.isVisible = !playerPreferences.pipOnExit().get() && activity.supportedAndEnabled - - binding.controlsSkipIntroBtn.setOnLongClickListener { - activity.viewModel.showSkipIntroLength() - true - } - - binding.playbackPositionBtn.setOnClickListener { - if (player.timePos != null && player.duration != null) { - with(playerPreferences.invertedPlayback()) { - this.set( - if (this.get() == InvertedPlayback.POSITION) { - InvertedPlayback.NONE - } else { - InvertedPlayback.POSITION - }, - ) - } - updatePlaybackPos(player.timePos!!) - updatePlaybackDuration(player.duration!!) - } - } - - binding.playbackDurationBtn.setOnClickListener { - if (player.timePos != null && player.duration != null) { - with(playerPreferences.invertedPlayback()) { - this.set( - if (this.get() == InvertedPlayback.DURATION) { - InvertedPlayback.NONE - } else { - InvertedPlayback.DURATION - }, - ) - } - updatePlaybackPos(player.timePos!!) - updatePlaybackDuration(player.duration!!) - } - } - - binding.toggleAutoplay.setOnCheckedChangeListener { _, isChecked -> - toggleAutoplay( - isChecked, - ) - } - - binding.cycleViewModeBtn.setOnClickListener { cycleViewMode() } - - binding.settingsBtn.setOnClickListener { activity.viewModel.showPlayerSettings() } - - binding.streamsBtn.setOnClickListener { activity.viewModel.showStreamsCatalog() } - - binding.titleMainTxt.setOnClickListener { activity.viewModel.showEpisodeList() } - - binding.titleSecondaryTxt.setOnClickListener { activity.viewModel.showEpisodeList() } - - binding.episodeListBtn.setOnClickListener { activity.viewModel.showEpisodeList() } - } - - private fun switchEpisode(previous: Boolean) { - return activity.changeEpisode(activity.viewModel.getAdjacentEpisodeId(previous = previous)) - } - - internal suspend fun updateEpisodeText() { - val viewModel = activity.viewModel - val skipIntroText = activity.getString( - R.string.player_controls_skip_intro_text, - viewModel.getAnimeSkipIntroLength(), - ) - withUIContext { - binding.titleMainTxt.text = viewModel.currentAnime?.title - binding.titleSecondaryTxt.text = viewModel.currentEpisode?.name - binding.controlsSkipIntroBtn.text = skipIntroText - } - } - - internal suspend fun updatePlaylistButtons() { - val viewModel = activity.viewModel - val plCount = viewModel.currentPlaylist.size - val plPos = viewModel.getCurrentEpisodeIndex() - - val grey = ContextCompat.getColor(context, R.color.tint_disabled) - val white = ContextCompat.getColor(context, R.color.tint_normal) - withUIContext { - with(binding.prevBtn) { - this.imageTintList = ColorStateList.valueOf(if (plPos == 0) grey else white) - this.isClickable = plPos != 0 - } - with(binding.nextBtn) { - this.imageTintList = - ColorStateList.valueOf(if (plPos == plCount - 1) grey else white) - this.isClickable = plPos != plCount - 1 - } - } - } - - internal suspend fun updateSpeedButton() { - withUIContext { - binding.cycleSpeedBtn.text = context.getString(R.string.ui_speed, player.playbackSpeed) - player.playbackSpeed?.let { playerPreferences.playerSpeed().set(it.toFloat()) } - } } private var showControls = false private var wasPausedBeforeSeeking = false private val nonSeekViewRunnable = Runnable { - binding.topControlsGroup.visibility = View.VISIBLE - binding.middleControlsGroup.visibility = View.VISIBLE - binding.bottomControlsGroup.visibility = View.VISIBLE } private val hideUiForSeekRunnable = Runnable { SeekState.mode = SeekState.NONE player.paused = wasPausedBeforeSeeking if (showControls) { - AnimationUtils.loadAnimation(context, R.anim.player_fade_in).also { fadeAnimation -> - binding.topControlsGroup.startAnimation(fadeAnimation) - binding.topControlsGroup.visibility = View.VISIBLE - - binding.middleControlsGroup.startAnimation(fadeAnimation) - binding.middleControlsGroup.visibility = View.VISIBLE - - binding.bottomControlsGroup.startAnimation(fadeAnimation) - binding.bottomControlsGroup.visibility = View.VISIBLE - } showControls = false } else { showControls = true @@ -254,17 +74,8 @@ class PlayerControlsView @JvmOverloads constructor(context: Context, attrs: Attr animationHandler.removeCallbacks(fadeOutControlsRunnable) animationHandler.removeCallbacks(hideUiForSeekRunnable) - if (!( - binding.topControlsGroup.visibility == View.INVISIBLE && - binding.middleControlsGroup.visibility == INVISIBLE && - binding.bottomControlsGroup.visibility == INVISIBLE - ) - ) { + wasPausedBeforeSeeking = player.paused!! - showControls = binding.unlockedView.isVisible - binding.topControlsGroup.visibility = View.INVISIBLE - binding.middleControlsGroup.visibility = View.INVISIBLE - binding.bottomControlsGroup.visibility = View.INVISIBLE player.paused = true animationHandler.removeCallbacks(volumeViewRunnable) animationHandler.removeCallbacks(brightnessViewRunnable) @@ -272,10 +83,7 @@ class PlayerControlsView @JvmOverloads constructor(context: Context, attrs: Attr binding.volumeView.visibility = View.GONE binding.brightnessView.visibility = View.GONE activity.binding.seekView.visibility = View.GONE - binding.seekBarGroup.visibility = View.VISIBLE - binding.unlockedView.visibility = View.VISIBLE SeekState.mode = SeekState.SCROLL - } val delay = if (SeekState.mode == SeekState.DOUBLE_TAP) 1000L else 500L @@ -288,84 +96,20 @@ class PlayerControlsView @JvmOverloads constructor(context: Context, attrs: Attr internal val fadeOutControlsRunnable = Runnable { fadeOutControls() } internal fun lockControls(locked: Boolean) { - SeekState.mode = if (locked) SeekState.LOCKED else SeekState.NONE - val itemView = if (locked) binding.unlockedView else binding.lockedView - itemView.visibility = View.GONE - showAndFadeControls() } internal fun toggleControls(isTapped: Boolean = false) { - val isControlsVisible = binding.lockedView.isVisible || binding.unlockedView.isVisible - if (!isControlsVisible && !player.paused!!) { - showAndFadeControls() - } else if (!isControlsVisible && player.paused!!) { - fadeInControls() - } else if (isTapped) { - fadeOutControls() - } } internal fun hideControls(hide: Boolean) { animationHandler.removeCallbacks(fadeOutControlsRunnable) - if (hide) { - binding.unlockedView.visibility = View.GONE - binding.lockedView.visibility = View.GONE - } else { - showAndFadeControls() - } - } - - @SuppressLint("SetTextI18n") - internal fun updatePlaybackPos(position: Int) { - val duration = player.duration - val invertedPlayback = playerPreferences.invertedPlayback().get() - - if (duration != null) { - binding.playbackPositionBtn.text = when (invertedPlayback) { - InvertedPlayback.POSITION -> "-${Utils.prettyTime(duration - position)}" - InvertedPlayback.DURATION -> Utils.prettyTime(position) - InvertedPlayback.NONE -> Utils.prettyTime(position) - } - binding.playbackDurationBtn.text = when (invertedPlayback) { - InvertedPlayback.POSITION -> Utils.prettyTime(duration) - InvertedPlayback.DURATION -> "-${Utils.prettyTime(duration - position)}" - InvertedPlayback.NONE -> Utils.prettyTime(duration) - } - activity.viewModel.onSecondReached(position, duration) - } - seekbar.updateSeekbar(value = position.toFloat()) - chapterText.updateCurrentChapterText(value = position.toFloat()) - } - - @SuppressLint("SetTextI18n") - internal fun updatePlaybackDuration(duration: Int) { - val position = player.timePos - val invertedPlayback = playerPreferences.invertedPlayback().get() - if (position != null) { - binding.playbackDurationBtn.text = when (invertedPlayback) { - InvertedPlayback.POSITION -> Utils.prettyTime(duration) - InvertedPlayback.DURATION -> "-${Utils.prettyTime(duration - position)}" - InvertedPlayback.NONE -> Utils.prettyTime(duration) - } - } - - seekbar.updateSeekbar(duration = duration.toFloat()) - } - - internal fun updateBufferPosition(bufferPosition: Int) { - seekbar.updateSeekbar(readAheadValue = bufferPosition.toFloat()) } internal fun showAndFadeControls() { - val itemView = if (SeekState.mode == SeekState.LOCKED) binding.lockedView else binding.unlockedView - if (!itemView.isVisible) fadeInControls() - itemView.visibility = View.VISIBLE resetControlsFade() } internal fun resetControlsFade() { - val itemView = if (SeekState.mode == SeekState.LOCKED) binding.lockedView else binding.unlockedView - if (!itemView.isVisible) return animationHandler.removeCallbacks(fadeOutControlsRunnable) if (SeekState.mode == SeekState.SEEKBAR) return animationHandler.postDelayed(fadeOutControlsRunnable, 3500L) @@ -373,147 +117,20 @@ class PlayerControlsView @JvmOverloads constructor(context: Context, attrs: Attr private fun fadeOutControls() { animationHandler.removeCallbacks(fadeOutControlsRunnable) - - AnimationUtils.loadAnimation(context, R.anim.player_fade_out).also { fadeAnimation -> - val itemView = if (SeekState.mode == SeekState.LOCKED) binding.lockedView else binding.unlockedView - itemView.startAnimation(fadeAnimation) - itemView.visibility = View.GONE - } - - binding.seekBarGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_exit_bottom), - ) - if (!showControls) { - binding.topControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_exit_top), - ) - binding.bottomRightControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_exit_right), - ) - binding.bottomLeftControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_exit_left), - ) - binding.currentChapter.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_exit_left), - ) - binding.middleControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_fade_out), - ) - } showControls = false } private fun fadeInControls() { animationHandler.removeCallbacks(fadeOutControlsRunnable) - - AnimationUtils.loadAnimation(context, R.anim.player_fade_in).also { fadeAnimation -> - val itemView = if (SeekState.mode == SeekState.LOCKED) binding.lockedView else binding.unlockedView - itemView.startAnimation(fadeAnimation) - itemView.visibility = View.VISIBLE - } - - binding.seekBarGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_enter_bottom), - ) - binding.topControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_enter_top), - ) - binding.bottomRightControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_enter_right), - ) - binding.bottomLeftControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_enter_left), - ) - binding.currentChapter.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_enter_left), - ) - binding.middleControlsGroup.startAnimation( - AnimationUtils.loadAnimation(context, R.anim.player_fade_in), - ) } internal fun playPause() { player.cyclePause() when { player.paused!! -> animationHandler.removeCallbacks(fadeOutControlsRunnable) - binding.unlockedView.isVisible -> showAndFadeControls() } } - // Fade out Player information text - private val playerInformationRunnable = Runnable { - AnimationUtils.loadAnimation(context, R.anim.player_fade_out).also { fadeAnimation -> - binding.playerInformation.startAnimation(fadeAnimation) - binding.playerInformation.visibility = View.GONE - } - } - - private fun cycleViewMode() { - AspectState.mode = when (AspectState.mode) { - AspectState.FIT -> AspectState.CROP - AspectState.CROP -> AspectState.STRETCH - else -> AspectState.FIT - } - setViewMode(showText = true) - } - - internal fun setViewMode(showText: Boolean) { - binding.playerInformation.text = activity.stringResource(AspectState.mode.stringRes) - var aspect = "-1" - var pan = "1.0" - when (AspectState.mode) { - AspectState.CROP -> { - pan = "1.0" - } - AspectState.FIT -> { - pan = "0.0" - } - AspectState.STRETCH -> { - aspect = "${activity.deviceWidth}/${activity.deviceHeight}" - pan = "0.0" - } - AspectState.CUSTOM -> { - aspect = MPVLib.getPropertyString("video-aspect-override") - } - } - - mpvUpdateAspect(aspect = aspect, pan = pan) - playerPreferences.aspectState().set(AspectState.mode) - - if (showText) { - animationHandler.removeCallbacks(playerInformationRunnable) - binding.playerInformation.visibility = View.VISIBLE - animationHandler.postDelayed(playerInformationRunnable, 1000L) - } - } - - private fun mpvUpdateAspect(aspect: String, pan: String) { - MPVLib.setPropertyString("video-aspect-override", aspect) - MPVLib.setPropertyString("panscan", pan) - } - - internal fun toggleAutoplay(isAutoplay: Boolean) { - binding.toggleAutoplay.isChecked = isAutoplay - binding.toggleAutoplay.thumbDrawable = if (isAutoplay) { - ContextCompat.getDrawable(context, R.drawable.ic_play_circle_filled_24) - } else { - ContextCompat.getDrawable(context, R.drawable.ic_pause_circle_filled_24) - } - - if (isAutoplay) { - binding.playerInformation.text = activity.getString(R.string.enable_auto_play) - } else { - binding.playerInformation.text = activity.getString(R.string.disable_auto_play) - } - - if (!playerPreferences.autoplayEnabled().get() == isAutoplay) { - animationHandler.removeCallbacks(playerInformationRunnable) - binding.playerInformation.visibility = View.VISIBLE - animationHandler.postDelayed(playerInformationRunnable, 1000L) - } - playerPreferences.autoplayEnabled().set(isAutoplay) - } - // Fade out seek text private val seekTextRunnable = Runnable { activity.binding.seekView.visibility = View.GONE @@ -583,7 +200,6 @@ class PlayerControlsView @JvmOverloads constructor(context: Context, attrs: Attr internal fun showSeekText(position: Int, difference: Int) { hideUiForSeek() - updatePlaybackPos(position) val diffText = Utils.prettyTime(difference, true) activity.binding.seekText.text = activity.getString( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/components/Seekbar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/components/Seekbar.kt index 4895085b8..4918ffa64 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/components/Seekbar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/viewer/components/Seekbar.kt @@ -7,8 +7,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +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.compose.ui.platform.ComposeView import androidx.compose.ui.unit.dp @@ -18,100 +20,64 @@ import dev.vivvvek.seeker.Segment import eu.kanade.tachiyomi.util.view.setComposeContent import `is`.xyz.mpv.MPVView.Chapter -class Seekbar( - private val view: ComposeView, - private val onValueChange: (Float, Boolean) -> Unit, - private val onValueChangeFinished: (Float) -> Unit, +@Composable +fun Seekbar( + position: Float, + duration: Float, + readAhead: Float, + chapters: List, + onPositionChange: (Float, Boolean) -> Unit, + onPositionChangeFinished: (Float) -> Unit, + modifier: Modifier = Modifier, ) { - private var duration: Float = 1F - private var value: Float = 0F - private var readAheadValue: Float = 0F - private var chapters: List = listOf() - private var isDragging: Boolean = false + val range = 0F..duration + val validSegments = chapters.toSegments().filter { it.start in range } + var mutablePosition by remember { mutableFloatStateOf(position) } + val interactionSource = remember { MutableInteractionSource() } + val isDragging by interactionSource.collectIsDraggedAsState() + val gap by animateDpAsState(if (isDragging) 5.dp else 2.dp, label = "gap") + val thumbRadius by animateDpAsState(if (isDragging) 10.dp else 8.dp, label = "thumbRadius") + val trackHeight by animateDpAsState( + targetValue = if (isDragging) 6.dp else 4.dp, + label = "trackHeight", + ) + var isSeeked by remember { mutableStateOf(false) } - fun updateSeekbar( - duration: Float? = null, - value: Float? = null, - readAheadValue: Float? = null, - chapters: List? = null, - ) { - if (duration != null) { - this.duration = duration - } - if (value != null) { - this.value = value - } - if (readAheadValue != null) { - this.readAheadValue = readAheadValue - } - if (chapters != null) { - this.chapters = chapters - } - - view.setComposeContent { - SeekbarComposable( - duration ?: this.duration, - value ?: this.value, - readAheadValue ?: this.readAheadValue, - chapters?.toSegments() - ?: this.chapters.toSegments(), - ) - } - } - - @Composable - private fun SeekbarComposable( - duration: Float, - value: Float, - readAheadValue: Float, - segments: List, - ) { - val range = 0F..duration - val validSegments = segments.filter { it.start in range } - var mutableValue by remember { mutableFloatStateOf(value) } - val interactionSource = remember { MutableInteractionSource() } - val isDragging by interactionSource.collectIsDraggedAsState() - val gap by animateDpAsState(if (isDragging) 5.dp else 2.dp, label = "gap") - val thumbRadius by animateDpAsState(if (isDragging) 10.dp else 8.dp, label = "thumbRadius") - val trackHeight by animateDpAsState( - targetValue = if (isDragging) 6.dp else 4.dp, - label = "trackHeight", - ) - return Seeker( - value = value, - readAheadValue = readAheadValue, - range = range, - onValueChangeFinished = { - if (this.isDragging) { - onValueChangeFinished(mutableValue) - this.isDragging = false - } - }, - onValueChange = { - mutableValue = it - if (isDragging) { - val wasDragging = this.isDragging - this.isDragging = true - onValueChange(mutableValue, wasDragging) - } else { - onValueChangeFinished(mutableValue) - } - }, - segments = validSegments, - colors = SeekerDefaults.seekerColors( - progressColor = MaterialTheme.colorScheme.primary, - readAheadColor = MaterialTheme.colorScheme.onSurface, - trackColor = MaterialTheme.colorScheme.surface, - thumbColor = MaterialTheme.colorScheme.primary, - ), - dimensions = SeekerDefaults.seekerDimensions( - trackHeight = trackHeight, - gap = gap, - thumbRadius = thumbRadius, - ), - interactionSource = interactionSource, - ) - } + return Seeker( + value = position, + readAheadValue = readAhead, + range = range, + onValueChangeFinished = { + if (isSeeked) { + onPositionChangeFinished(mutablePosition) + isSeeked = false + } + }, + onValueChange = { + mutablePosition = it + if (isDragging) { + val wasDragging = isSeeked + isSeeked = true + onPositionChange(mutablePosition, wasDragging) + } else { + onPositionChangeFinished(mutablePosition) + } + }, + segments = validSegments, + colors = SeekerDefaults.seekerColors( + progressColor = MaterialTheme.colorScheme.primary, + readAheadColor = MaterialTheme.colorScheme.onSurface, + trackColor = MaterialTheme.colorScheme.surface, + thumbColor = MaterialTheme.colorScheme.primary, + ), + dimensions = SeekerDefaults.seekerDimensions( + trackHeight = trackHeight, + gap = gap, + thumbRadius = thumbRadius, + ), + interactionSource = interactionSource, + modifier = modifier, + ) } @Composable diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/AniSkipApi.kt b/app/src/main/java/eu/kanade/tachiyomi/util/AniSkipApi.kt index 4ff2ec5d3..986762b90 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/AniSkipApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/AniSkipApi.kt @@ -26,7 +26,7 @@ class AniSkipApi { private val client = OkHttpClient() private val json: Json by injectLazy() - // credits: https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/AniSkip.kt + // credits: Saikou fun getResult(malId: Int, episodeNumber: Int, episodeLength: Long): List? { val url = "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed" + @@ -65,27 +65,21 @@ class AniSkipApi { private val binding: PlayerActivityBinding, private val aniSkipResponse: List, ) { - private val playerControls get() = binding.playerControls private val activity: PlayerActivity get() = binding.root.context as PlayerActivity - internal suspend fun showSkipButton(skipType: SkipType) { + internal fun showSkipButton(skipType: SkipType) { val skipButtonString = when (skipType) { SkipType.ED -> MR.strings.player_aniskip_ed SkipType.OP -> MR.strings.player_aniskip_op SkipType.RECAP -> MR.strings.player_aniskip_recap SkipType.MIXED_OP -> MR.strings.player_aniskip_mixedOp } - withUIContext { - playerControls.binding.controlsSkipIntroBtn.visibility = View.VISIBLE - playerControls.binding.controlsSkipIntroBtn.text = activity.stringResource( - skipButtonString, - ) - } + activity.viewModel.updateSkipIntroText(activity.stringResource(skipButtonString)) } // this is used when netflixStyle is enabled @SuppressLint("SetTextI18n") - suspend fun showSkipButton(skipType: SkipType, waitingTime: Int) { + fun showSkipButton(skipType: SkipType, waitingTime: Int) { val skipTime = when (skipType) { SkipType.ED -> aniSkipResponse.first { it.skipType == SkipType.ED }.interval SkipType.OP -> aniSkipResponse.first { it.skipType == SkipType.OP }.interval @@ -94,12 +88,7 @@ class AniSkipApi { } if (waitingTime > -1) { if (waitingTime > 0) { - withUIContext { - playerControls.binding.controlsSkipIntroBtn.visibility = View.VISIBLE - playerControls.binding.controlsSkipIntroBtn.text = activity.stringResource( - MR.strings.player_aniskip_dontskip, - ) - } + activity.viewModel.updateSkipIntroText(activity.stringResource(MR.strings.player_aniskip_dontskip)) } else { seekTo(skipTime.endTime) skipAnimation(skipType) diff --git a/app/src/main/res/layout/player_activity.xml b/app/src/main/res/layout/player_activity.xml index 6b874e16d..0604ba2e2 100644 --- a/app/src/main/res/layout/player_activity.xml +++ b/app/src/main/res/layout/player_activity.xml @@ -26,6 +26,11 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -