implement: compose player controls with a working timer

This commit is contained in:
Quickdesh 2024-06-14 23:55:31 +05:30
parent 3485cf33e5
commit ba330d7db5
11 changed files with 821 additions and 965 deletions

View file

@ -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<VideoChapter> = 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<ConstraintLayout.LayoutParams> {
rightToLeft = playerControls.binding.toggleAutoplay.id
rightToRight = ConstraintLayout.LayoutParams.UNSET
}
playerControls.binding.settingsBtn.updateLayoutParams<ConstraintLayout.LayoutParams> {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
topToBottom = ConstraintLayout.LayoutParams.UNSET
}
playerControls.binding.toggleAutoplay.updateLayoutParams<ConstraintLayout.LayoutParams> {
leftToLeft = ConstraintLayout.LayoutParams.UNSET
leftToRight = playerControls.binding.episodeListBtn.id
}
} else {
if (deviceWidth >= deviceHeight) {
deviceWidth = deviceHeight.also { deviceHeight = deviceWidth }
}
playerControls.binding.episodeListBtn.updateLayoutParams<ConstraintLayout.LayoutParams> {
rightToLeft = ConstraintLayout.LayoutParams.UNSET
rightToRight = ConstraintLayout.LayoutParams.PARENT_ID
}
playerControls.binding.settingsBtn.updateLayoutParams<ConstraintLayout.LayoutParams> {
topToTop = ConstraintLayout.LayoutParams.UNSET
topToBottom = playerControls.binding.episodeListBtn.id
}
playerControls.binding.toggleAutoplay.updateLayoutParams<ConstraintLayout.LayoutParams> {
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)

View file

@ -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<Episode> = 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<MPVView.Chapter> = 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<Track> = 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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -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(

View file

@ -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<Chapter>,
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<Chapter> = 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<Chapter>? = 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<Segment>,
) {
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

View file

@ -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<Stamp>? {
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<Stamp>,
) {
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)

View file

@ -26,6 +26,11 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/controls_root"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -7,386 +7,6 @@
android:layout_height="match_parent"
tools:ignore="RtlHardcoded,HardcodedText">
<!-- Locked player check -->
<LinearLayout
android:id="@+id/lockedView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentLeft="true"
android:layout_marginTop="10dp"
android:layout_marginLeft="10dp"
android:visibility="gone">
<ImageButton
android:id="@+id/unlockBtn"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="Unlock player"
android:src="@drawable/ic_unlock_20dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:tint="?attr/colorOnPrimarySurface" />
</LinearLayout>
<RelativeLayout
android:id="@+id/unlocked_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#70000000">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/topControlsGroup"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible">
<!-- Top Controls (Right)-->
<ImageButton
android:id="@+id/backArrowBtn"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginLeft="10dp"
android:layout_marginTop="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Go back"
android:src="@drawable/ic_arrow_back_20dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/titleMainTxt"
app:layout_constraintTop_toTopOf="parent"
app:tint="?attr/colorOnPrimarySurface" />
<TextView
android:id="@+id/titleMainTxt"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="16dp"
android:textColor="?attr/colorOnPrimarySurface"
android:textSize="14sp"
android:textStyle="bold"
android:maxLines="1"
android:clickable="true"
android:focusable="true"
app:layout_constraintLeft_toRightOf="@id/backArrowBtn"
app:layout_constraintRight_toLeftOf="@id/episodeListBtn"
app:layout_constraintTop_toTopOf="parent"
tools:text="Anime Name" />
<TextView
android:id="@+id/titleSecondaryTxt"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:alpha="0.5"
android:textColor="?attr/colorOnPrimarySurface"
android:textSize="12sp"
android:textStyle="italic"
android:maxLines="1"
android:clickable="true"
android:focusable="true"
app:layout_constraintLeft_toRightOf="@id/backArrowBtn"
app:layout_constraintRight_toLeftOf="@id/episodeListBtn"
app:layout_constraintTop_toBottomOf="@id/titleMainTxt"
tools:text="Episode Name" />
<ImageButton
android:id="@+id/episodeListBtn"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:background="@android:color/transparent"
android:contentDescription="Episode list"
android:src="@drawable/ic_navigate_next_20dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toRightOf="@id/titleMainTxt"
app:layout_constraintRight_toRightOf="parent"
app:tint="?attr/colorOnPrimarySurface" />
<!-- Top Controls (Left)-->
<ImageButton
android:id="@+id/settingsBtn"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="10dp"
android:layout_marginRight="10dp"
android:background="?attr/selectableItemBackground"
android:contentDescription="Settings"
android:src="@drawable/ic_overflow_20dp"
app:layout_constraintLeft_toRightOf="@id/streamsBtn"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/episodeListBtn"
app:tint="?attr/colorOnPrimarySurface" />
<ImageButton
android:id="@+id/streamsBtn"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="?attr/selectableItemBackground"
android:contentDescription="Tracks"
android:src="@drawable/ic_video_settings_20dp"
app:layout_constraintLeft_toRightOf="@id/toggleAutoplay"
app:layout_constraintRight_toLeftOf="@id/settingsBtn"
app:layout_constraintTop_toTopOf="@id/settingsBtn"
app:tint="?attr/colorOnPrimarySurface" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/toggleAutoplay"
android:layout_width="50dp"
android:layout_height="50dp"
tools:checked="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/streamsBtn"
app:layout_constraintTop_toTopOf="@id/streamsBtn" />
<!-- Audio -->
<TextView
android:id="@+id/titleTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="-"
android:textColor="@color/tint_normal"
android:textSize="24sp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/minorTitleTextView"
app:layout_constraintLeft_toLeftOf="parent" />
<TextView
android:id="@+id/minorTitleTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="-"
android:textColor="@color/tint_normal"
android:textSize="12sp"
android:layout_marginBottom="96dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/middleControlsGroup"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible">
<ImageButton
android:id="@+id/prevBtn"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginRight="256dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_previous_episode"
android:padding="@dimen/screen_edge_margin"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_skip_previous_40dp"
app:tint="?attr/colorOnPrimarySurface" />
<ImageButton
android:id="@+id/play_btn"
android:layout_width="64dp"
android:layout_height="64dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Play/Pause"
android:textColor="#FFF"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?attr/colorOnPrimarySurface"
tools:src="@drawable/ic_play_arrow_64dp"
tools:visibility="visible" />
<ImageButton
android:id="@+id/nextBtn"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginLeft="256dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_next_episode"
android:padding="@dimen/screen_edge_margin"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_skip_next_40dp"
app:tint="?attr/colorOnPrimarySurface" />
<TextView
android:id="@+id/playerInformation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="left"
android:text="Information"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/play_btn"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottomControlsGroup"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="50dp"
android:layout_marginHorizontal="10dp"
android:layoutDirection="ltr"
android:visibility="visible">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/currentChapter"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_gravity="center_vertical"
android:layout_weight="80"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/bottomRightControlsGroup"
app:layout_constraintStart_toEndOf="@+id/bottomLeftControlsGroup" />
<LinearLayout
android:id="@+id/bottomLeftControlsGroup"
android:layout_width="wrap_content"
android:layout_height="50dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent">
<ImageButton
android:id="@+id/lockBtn"
android:layout_width="50dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="Lock player"
android:src="@drawable/ic_lock_20dp"
app:tint="?attr/colorOnPrimarySurface" />
<ImageButton
android:id="@+id/rotateBtn"
android:layout_width="50dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="Rotate player"
android:onClick="rotatePlayer"
android:src="@drawable/ic_screen_rotation_20dp"
app:tint="?attr/colorOnPrimarySurface" />
<TextView
android:id="@+id/cycleSpeedBtn"
android:layout_width="80dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:onClick="cycleSpeed"
android:gravity="center"
android:text="1.00x"
android:textSize="12sp"
android:textColor="?attr/colorOnPrimarySurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/bottomRightControlsGroup"
android:layout_width="wrap_content"
android:layout_height="50dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent">
<Button
android:id="@+id/controls_skip_intro_btn"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:backgroundTint="?attr/colorPrimary"
android:onClick="skipIntro"
android:text=""
android:textSize="12sp"
tools:text="+85s"
android:textColor="?attr/colorOnPrimary" />
<ImageButton
android:id="@+id/cycleViewModeBtn"
android:layout_width="50dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="Cycle view modes"
android:src="@drawable/ic_fullscreen_black_20dp"
app:tint="?attr/colorOnPrimarySurface" />
<ImageButton
android:id="@+id/pipBtn"
android:layout_width="50dp"
android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/action_player_pip"
android:src="@drawable/ic_picture_in_picture_20dp"
android:visibility="visible"
app:tint="?attr/colorOnPrimarySurface" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/seekBarGroup"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginHorizontal="10dp"
android:layout_marginBottom="10dp"
android:visibility="visible">
<TextView
android:id="@+id/playbackPositionBtn"
android:layout_width="80dp"
android:layout_height="50dp"
android:gravity="center"
android:text="0:00"
android:textSize="12sp"
android:textColor="#FFF"
android:background="?attr/selectableItemBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/playbackSeekbar"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_gravity="center_vertical"
android:layout_weight="80"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toRightOf="@id/playbackPositionBtn"
app:layout_constraintRight_toLeftOf="@id/playbackDurationBtn" />
<TextView
android:id="@+id/playbackDurationBtn"
android:layout_width="80dp"
android:layout_height="50dp"
android:gravity="center"
android:text="0:00"
android:textSize="12sp"
android:textColor="#FFF"
android:background="?attr/selectableItemBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</RelativeLayout>
<!-- Extra Controls -->
<LinearLayout