mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add playback controls
This commit is contained in:
parent
3e7f11c588
commit
b69a48407b
27 changed files with 1618 additions and 6 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
package com.github.damontecres.dolphin
|
package com.github.damontecres.dolphin
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.contracts.ExperimentalContracts
|
import kotlin.contracts.ExperimentalContracts
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
|
|
@ -24,3 +27,61 @@ fun FocusRequester.tryRequestFocus(): Boolean =
|
||||||
Timber.w(ex, "Failed to request focus")
|
Timber.w(ex, "Failed to request focus")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to apply modifiers conditionally.
|
||||||
|
*/
|
||||||
|
fun Modifier.ifElse(
|
||||||
|
condition: () -> Boolean,
|
||||||
|
ifTrueModifier: Modifier,
|
||||||
|
ifFalseModifier: Modifier = Modifier,
|
||||||
|
): Modifier = then(if (condition()) ifTrueModifier else ifFalseModifier)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to apply modifiers conditionally.
|
||||||
|
*/
|
||||||
|
fun Modifier.ifElse(
|
||||||
|
condition: Boolean,
|
||||||
|
ifTrueModifier: Modifier,
|
||||||
|
ifFalseModifier: Modifier = Modifier,
|
||||||
|
): Modifier = ifElse({ condition }, ifTrueModifier, ifFalseModifier)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
|
||||||
|
* accidentally move to another element.
|
||||||
|
* */
|
||||||
|
fun Modifier.handleDPadKeyEvents(
|
||||||
|
onLeft: (() -> Unit)? = null,
|
||||||
|
onRight: (() -> Unit)? = null,
|
||||||
|
onCenter: (() -> Unit)? = null,
|
||||||
|
triggerOnAction: Int = KeyEvent.ACTION_UP,
|
||||||
|
) = onPreviewKeyEvent {
|
||||||
|
fun onActionUp(block: () -> Unit) {
|
||||||
|
if (it.nativeKeyEvent.action == triggerOnAction) block()
|
||||||
|
}
|
||||||
|
|
||||||
|
when (it.nativeKeyEvent.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
|
||||||
|
onLeft?.apply {
|
||||||
|
onActionUp(::invoke)
|
||||||
|
return@onPreviewKeyEvent true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
|
||||||
|
onRight?.apply {
|
||||||
|
onActionUp(::invoke)
|
||||||
|
return@onPreviewKeyEvent true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||||
|
onCenter?.apply {
|
||||||
|
onActionUp(::invoke)
|
||||||
|
return@onPreviewKeyEvent true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.github.damontecres.dolphin.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import com.github.damontecres.dolphin.R
|
||||||
|
|
||||||
|
val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900))
|
||||||
|
|
||||||
|
sealed class AppColors private constructor() {
|
||||||
|
companion object {
|
||||||
|
val TransparentBlack25 = Color(0x40000000)
|
||||||
|
val TransparentBlack50 = Color(0x80000000)
|
||||||
|
val TransparentBlack75 = Color(0xBF000000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.annotation.IntRange
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
|
||||||
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
|
||||||
|
class ControllerViewState internal constructor(
|
||||||
|
@IntRange(from = 0)
|
||||||
|
private val hideMilliseconds: Int,
|
||||||
|
val controlsEnabled: Boolean,
|
||||||
|
) {
|
||||||
|
private val channel = Channel<Int>(CONFLATED)
|
||||||
|
private var _controlsVisible by mutableStateOf(false)
|
||||||
|
val controlsVisible get() = _controlsVisible
|
||||||
|
|
||||||
|
fun showControls(milliseconds: Int = hideMilliseconds) {
|
||||||
|
if (controlsEnabled) {
|
||||||
|
_controlsVisible = true
|
||||||
|
}
|
||||||
|
pulseControls(milliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideControls() {
|
||||||
|
_controlsVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pulseControls(milliseconds: Int = hideMilliseconds) {
|
||||||
|
// Log.i("PlaybackPageContent", "pulseControls=$milliseconds")
|
||||||
|
channel.trySend(milliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
suspend fun observe() {
|
||||||
|
channel
|
||||||
|
.consumeAsFlow()
|
||||||
|
.debounce { it.toLong() }
|
||||||
|
.collect {
|
||||||
|
// Log.i("PlaybackPageContent", "collect")
|
||||||
|
_controlsVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
|
||||||
|
fun isDirectionalDpad(event: KeyEvent): Boolean =
|
||||||
|
event.key == Key.DirectionUp ||
|
||||||
|
event.key == Key.DirectionDown ||
|
||||||
|
event.key == Key.DirectionLeft ||
|
||||||
|
event.key == Key.DirectionRight ||
|
||||||
|
event.key == Key.DirectionUpRight ||
|
||||||
|
event.key == Key.DirectionUpLeft ||
|
||||||
|
event.key == Key.DirectionDownRight ||
|
||||||
|
event.key == Key.DirectionDownLeft
|
||||||
|
|
||||||
|
fun isDpad(event: KeyEvent): Boolean = event.key == Key.DirectionCenter || isDirectionalDpad(event)
|
||||||
|
|
||||||
|
fun isEnterKey(event: KeyEvent) = event.key == Key.DirectionCenter || event.key == Key.Enter || event.key == Key.NumPadEnter
|
||||||
|
|
||||||
|
fun isMedia(event: KeyEvent): Boolean =
|
||||||
|
event.key == Key.MediaPlay ||
|
||||||
|
event.key == Key.MediaPause ||
|
||||||
|
event.key == Key.MediaPlayPause ||
|
||||||
|
event.key == Key.MediaFastForward ||
|
||||||
|
event.key == Key.MediaSkipForward ||
|
||||||
|
event.key == Key.MediaRewind ||
|
||||||
|
event.key == Key.MediaSkipBackward ||
|
||||||
|
event.key == Key.MediaNext ||
|
||||||
|
event.key == Key.MediaPrevious
|
||||||
|
|
||||||
|
fun isBackwardButton(event: KeyEvent): Boolean =
|
||||||
|
event.key == Key.PageUp ||
|
||||||
|
event.key == Key.ChannelUp ||
|
||||||
|
event.key == Key.MediaPrevious ||
|
||||||
|
event.key == Key.MediaRewind ||
|
||||||
|
event.key == Key.MediaSkipBackward
|
||||||
|
|
||||||
|
fun isForwardButton(event: KeyEvent): Boolean =
|
||||||
|
event.key == Key.PageDown ||
|
||||||
|
event.key == Key.ChannelDown ||
|
||||||
|
event.key == Key.MediaNext ||
|
||||||
|
event.key == Key.MediaFastForward ||
|
||||||
|
event.key == Key.MediaSkipForward
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
||||||
|
val playbackScaleOptions =
|
||||||
|
mapOf(
|
||||||
|
ContentScale.Fit to "Fit",
|
||||||
|
ContentScale.None to "None",
|
||||||
|
ContentScale.Crop to "Crop",
|
||||||
|
// ContentScale.Inside to "Inside",
|
||||||
|
ContentScale.FillBounds to "Fill",
|
||||||
|
ContentScale.FillWidth to "Fill Width",
|
||||||
|
ContentScale.FillHeight to "Fill Height",
|
||||||
|
)
|
||||||
|
|
@ -1,33 +1,55 @@
|
||||||
package com.github.damontecres.dolphin.ui.playback
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.compose.PlayerSurface
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||||
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
||||||
|
import androidx.media3.ui.compose.state.rememberNextButtonState
|
||||||
|
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberPresentationState
|
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||||
|
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.tryRequestFocus
|
import com.github.damontecres.dolphin.tryRequestFocus
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
|
import com.github.damontecres.stashapp.ui.components.playback.SkipIndicator
|
||||||
|
import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -38,11 +60,17 @@ fun PlaybackContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlaybackViewModel = hiltViewModel(),
|
viewModel: PlaybackViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(destination.itemId) {
|
LaunchedEffect(destination.itemId) {
|
||||||
viewModel.init(destination.itemId)
|
viewModel.init(destination.itemId, destination.item)
|
||||||
}
|
}
|
||||||
val player = viewModel.player
|
val player = viewModel.player
|
||||||
val stream by viewModel.stream.observeAsState(null)
|
val stream by viewModel.stream.observeAsState(null)
|
||||||
|
|
||||||
|
val title by viewModel.title.observeAsState(null)
|
||||||
|
val subtitle by viewModel.subtitle.observeAsState(null)
|
||||||
|
val duration by viewModel.duration.observeAsState(null)
|
||||||
|
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
// TODO loading
|
// TODO loading
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -55,16 +83,52 @@ fun PlaybackContent(
|
||||||
val scaledModifier =
|
val scaledModifier =
|
||||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val playPauseState = rememberPlayPauseButtonState(player)
|
||||||
|
val previousState = rememberPreviousButtonState(player)
|
||||||
|
val nextState = rememberNextButtonState(player)
|
||||||
|
val seekBarState = rememberSeekBarState(player, scope)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
}
|
}
|
||||||
|
val controllerViewState =
|
||||||
|
remember {
|
||||||
|
ControllerViewState(
|
||||||
|
5_000,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}.also {
|
||||||
|
LaunchedEffect(it) {
|
||||||
|
it.observe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
|
||||||
|
LaunchedEffect(controllerViewState.controlsVisible) {
|
||||||
|
// If controller shows/hides, immediately cancel the skip indicator
|
||||||
|
skipIndicatorDuration = 0L
|
||||||
|
}
|
||||||
|
var skipPosition by remember { mutableLongStateOf(0L) }
|
||||||
|
val updateSkipIndicator = { delta: Long ->
|
||||||
|
if (skipIndicatorDuration > 0 && delta < 0 || skipIndicatorDuration < 0 && delta > 0) {
|
||||||
|
skipIndicatorDuration = 0
|
||||||
|
}
|
||||||
|
skipIndicatorDuration += delta
|
||||||
|
skipPosition = player.currentPosition
|
||||||
|
}
|
||||||
|
val keyHandler =
|
||||||
|
PlaybackKeyHandler(
|
||||||
|
player = player,
|
||||||
|
controlsEnabled = true,
|
||||||
|
skipWithLeftRight = true,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
updateSkipIndicator = updateSkipIndicator,
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.onKeyEvent {
|
.onKeyEvent(keyHandler::onKeyEvent)
|
||||||
// TODO handle key events for playback controls
|
.focusRequester(focusRequester)
|
||||||
false
|
|
||||||
}.focusRequester(focusRequester)
|
|
||||||
.focusable(),
|
.focusable(),
|
||||||
) {
|
) {
|
||||||
PlayerSurface(
|
PlayerSurface(
|
||||||
|
|
@ -79,6 +143,70 @@ fun PlaybackContent(
|
||||||
.background(Color.Black),
|
.background(Color.Black),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
||||||
|
SkipIndicator(
|
||||||
|
durationMs = skipIndicatorDuration,
|
||||||
|
onFinish = {
|
||||||
|
skipIndicatorDuration = 0L
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 70.dp),
|
||||||
|
)
|
||||||
|
val showSkipProgress = true // TODO get from preferences
|
||||||
|
if (showSkipProgress) {
|
||||||
|
duration?.let {
|
||||||
|
val percent =
|
||||||
|
skipPosition.toFloat() / (it.inWholeMilliseconds).toFloat()
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.background(MaterialTheme.colorScheme.border)
|
||||||
|
.clip(RectangleShape)
|
||||||
|
.height(3.dp)
|
||||||
|
.fillMaxWidth(percent),
|
||||||
|
) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
controllerViewState.controlsVisible,
|
||||||
|
Modifier,
|
||||||
|
slideInVertically { it },
|
||||||
|
slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
PlaybackOverlay(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Transparent),
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
captions = listOf(),
|
||||||
|
playerControls = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
showPlay = playPauseState.showPlay,
|
||||||
|
previousEnabled = previousState.isEnabled,
|
||||||
|
nextEnabled = nextState.isEnabled,
|
||||||
|
seekEnabled = true,
|
||||||
|
onPlaybackActionClick = {},
|
||||||
|
onSeekBarChange = seekBarState::onValueChange,
|
||||||
|
showDebugInfo = false,
|
||||||
|
scale = contentScale,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
|
moreButtonOptions = MoreButtonOptions(mapOf()),
|
||||||
|
subtitleIndex = null,
|
||||||
|
audioIndex = null,
|
||||||
|
audioOptions = listOf(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,597 @@
|
||||||
|
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import android.view.Gravity
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.R
|
||||||
|
import com.github.damontecres.dolphin.tryRequestFocus
|
||||||
|
import com.github.damontecres.dolphin.ui.AppColors
|
||||||
|
import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
sealed interface PlaybackAction {
|
||||||
|
data object ShowDebug : PlaybackAction
|
||||||
|
|
||||||
|
data object ShowPlaylist : PlaybackAction
|
||||||
|
|
||||||
|
data object ShowVideoFilterDialog : PlaybackAction
|
||||||
|
|
||||||
|
data class ToggleCaptions(
|
||||||
|
val index: Int,
|
||||||
|
) : PlaybackAction
|
||||||
|
|
||||||
|
data class ToggleAudio(
|
||||||
|
val index: Int,
|
||||||
|
) : PlaybackAction
|
||||||
|
|
||||||
|
data class PlaybackSpeed(
|
||||||
|
val value: Float,
|
||||||
|
) : PlaybackAction
|
||||||
|
|
||||||
|
data class Scale(
|
||||||
|
val scale: ContentScale,
|
||||||
|
) : PlaybackAction
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PlaybackControls(
|
||||||
|
captions: List<TrackSupport>,
|
||||||
|
playerControls: Player,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
showDebugInfo: Boolean,
|
||||||
|
onSeekProgress: (Float) -> Unit,
|
||||||
|
showPlay: Boolean,
|
||||||
|
previousEnabled: Boolean,
|
||||||
|
nextEnabled: Boolean,
|
||||||
|
seekEnabled: Boolean,
|
||||||
|
moreButtonOptions: MoreButtonOptions,
|
||||||
|
subtitleIndex: Int?,
|
||||||
|
audioIndex: Int?,
|
||||||
|
audioOptions: List<String>,
|
||||||
|
playbackSpeed: Float,
|
||||||
|
scale: ContentScale,
|
||||||
|
seekBarIntervals: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
initialFocusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
val onControllerInteraction = {
|
||||||
|
scope.launch {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
val onControllerInteractionForDialog = {
|
||||||
|
scope.launch {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
controllerViewState.pulseControls(Int.MAX_VALUE)
|
||||||
|
}
|
||||||
|
LaunchedEffect(controllerViewState.controlsVisible) {
|
||||||
|
if (controllerViewState.controlsVisible) {
|
||||||
|
initialFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
SeekBar(
|
||||||
|
player = playerControls,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
onSeekProgress = onSeekProgress,
|
||||||
|
interactionSource = seekBarInteractionSource,
|
||||||
|
isEnabled = seekEnabled,
|
||||||
|
intervals = seekBarIntervals,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.fillMaxWidth(.95f),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
LeftPlaybackButtons(
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
showDebugInfo = showDebugInfo,
|
||||||
|
moreButtonOptions = moreButtonOptions,
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
|
)
|
||||||
|
PlaybackButtons(
|
||||||
|
player = playerControls,
|
||||||
|
initialFocusRequester = initialFocusRequester,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
showPlay = showPlay,
|
||||||
|
previousEnabled = previousEnabled,
|
||||||
|
nextEnabled = nextEnabled,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
RightPlaybackButtons(
|
||||||
|
captions = captions,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
onControllerInteractionForDialog = onControllerInteractionForDialog,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
subtitleIndex = subtitleIndex,
|
||||||
|
audioOptions = audioOptions,
|
||||||
|
audioIndex = audioIndex,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
|
scale = scale,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SeekBar(
|
||||||
|
player: Player,
|
||||||
|
isEnabled: Boolean,
|
||||||
|
intervals: Int,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
onSeekProgress: (Float) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
var bufferedProgress by remember(player) { mutableFloatStateOf(player.bufferedPosition.toFloat() / player.duration) }
|
||||||
|
var position by remember(player) { mutableLongStateOf(player.currentPosition) }
|
||||||
|
var progress by remember(player) { mutableFloatStateOf(player.currentPosition.toFloat() / player.duration) }
|
||||||
|
LaunchedEffect(player) {
|
||||||
|
while (isActive) {
|
||||||
|
bufferedProgress = player.bufferedPosition.toFloat() / player.duration
|
||||||
|
position = player.currentPosition
|
||||||
|
progress = player.currentPosition.toFloat() / player.duration
|
||||||
|
delay(250L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
SeekBarImpl(
|
||||||
|
progress = progress,
|
||||||
|
bufferedProgress = bufferedProgress,
|
||||||
|
onSeek = {
|
||||||
|
onSeekProgress(it)
|
||||||
|
},
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
intervals = intervals,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
enabled = isEnabled,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = (position / 1000).seconds.toString(),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "-" + ((player.duration - position) / 1000).seconds.toString(),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val buttonSpacing = 4.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LeftPlaybackButtons(
|
||||||
|
onControllerInteraction: () -> Unit,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
showDebugInfo: Boolean,
|
||||||
|
moreButtonOptions: MoreButtonOptions,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showMoreOptions by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
modifier = modifier.focusGroup(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||||
|
) {
|
||||||
|
// More options
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.baseline_more_vert_96,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
showMoreOptions = true
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showMoreOptions) {
|
||||||
|
// TODO options need context about what to display
|
||||||
|
val options =
|
||||||
|
buildList {
|
||||||
|
addAll(moreButtonOptions.options.keys)
|
||||||
|
add(if (showDebugInfo) "Hide transcode info" else "Show transcode info")
|
||||||
|
}
|
||||||
|
BottomDialog(
|
||||||
|
choices = options,
|
||||||
|
onDismissRequest = { showMoreOptions = false },
|
||||||
|
onSelectChoice = { index, choice ->
|
||||||
|
val action = moreButtonOptions.options[choice] ?: PlaybackAction.ShowDebug
|
||||||
|
onPlaybackActionClick.invoke(action)
|
||||||
|
},
|
||||||
|
gravity = Gravity.START,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "2.0")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RightPlaybackButtons(
|
||||||
|
captions: List<TrackSupport>,
|
||||||
|
onControllerInteraction: () -> Unit,
|
||||||
|
onControllerInteractionForDialog: () -> Unit,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
subtitleIndex: Int?,
|
||||||
|
audioOptions: List<String>,
|
||||||
|
audioIndex: Int?,
|
||||||
|
playbackSpeed: Float,
|
||||||
|
scale: ContentScale,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showCaptionDialog by remember { mutableStateOf(false) }
|
||||||
|
var showOptionsDialog by remember { mutableStateOf(false) }
|
||||||
|
var showAudioDialog by remember { mutableStateOf(false) }
|
||||||
|
var showSpeedDialog by remember { mutableStateOf(false) }
|
||||||
|
var showScaleDialog by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
modifier = modifier.focusGroup(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||||
|
) {
|
||||||
|
// Captions
|
||||||
|
PlaybackButton(
|
||||||
|
enabled = captions.isNotEmpty(),
|
||||||
|
iconRes = R.drawable.captions_svgrepo_com,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteractionForDialog.invoke()
|
||||||
|
showCaptionDialog = true
|
||||||
|
},
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
// Playback speed, etc
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.vector_settings,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteractionForDialog.invoke()
|
||||||
|
showOptionsDialog = true
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showCaptionDialog) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val options = captions.map { it.displayString(context) }
|
||||||
|
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
|
||||||
|
BottomDialog(
|
||||||
|
choices = options,
|
||||||
|
currentChoice = subtitleIndex,
|
||||||
|
onDismissRequest = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
showCaptionDialog = false
|
||||||
|
},
|
||||||
|
onSelectChoice = { index, _ ->
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(index))
|
||||||
|
},
|
||||||
|
gravity = Gravity.END,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showOptionsDialog) {
|
||||||
|
val options = listOf("Audio Track", "Playback Speed", "Video Scale")
|
||||||
|
BottomDialog(
|
||||||
|
choices = options,
|
||||||
|
currentChoice = null,
|
||||||
|
onDismissRequest = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
showOptionsDialog = false
|
||||||
|
},
|
||||||
|
onSelectChoice = { index, _ ->
|
||||||
|
when (index) {
|
||||||
|
0 -> showAudioDialog = true
|
||||||
|
1 -> showSpeedDialog = true
|
||||||
|
2 -> showScaleDialog = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gravity = Gravity.END,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAudioDialog) {
|
||||||
|
BottomDialog(
|
||||||
|
choices = audioOptions,
|
||||||
|
currentChoice = audioIndex,
|
||||||
|
onDismissRequest = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
showAudioDialog = false
|
||||||
|
},
|
||||||
|
onSelectChoice = { index, _ ->
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(index))
|
||||||
|
},
|
||||||
|
gravity = Gravity.END,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showSpeedDialog) {
|
||||||
|
BottomDialog(
|
||||||
|
choices = speedOptions,
|
||||||
|
currentChoice = speedOptions.indexOf(playbackSpeed.toString()),
|
||||||
|
onDismissRequest = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
showSpeedDialog = false
|
||||||
|
},
|
||||||
|
onSelectChoice = { _, value ->
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat()))
|
||||||
|
},
|
||||||
|
gravity = Gravity.END,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showScaleDialog) {
|
||||||
|
BottomDialog(
|
||||||
|
choices = playbackScaleOptions.values.toList(),
|
||||||
|
currentChoice = playbackScaleOptions.keys.toList().indexOf(scale),
|
||||||
|
onDismissRequest = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
showScaleDialog = false
|
||||||
|
},
|
||||||
|
onSelectChoice = { index, _ ->
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index]))
|
||||||
|
},
|
||||||
|
gravity = Gravity.END,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PlaybackButtons(
|
||||||
|
player: Player,
|
||||||
|
initialFocusRequester: FocusRequester,
|
||||||
|
onControllerInteraction: () -> Unit,
|
||||||
|
showPlay: Boolean,
|
||||||
|
previousEnabled: Boolean,
|
||||||
|
nextEnabled: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.focusGroup(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||||
|
) {
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.baseline_skip_previous_24,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
player.seekToPrevious()
|
||||||
|
},
|
||||||
|
enabled = previousEnabled,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.baseline_fast_rewind_24,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
player.seekBack()
|
||||||
|
},
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
PlaybackButton(
|
||||||
|
modifier = Modifier.focusRequester(initialFocusRequester),
|
||||||
|
iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
if (showPlay) player.play() else player.pause()
|
||||||
|
},
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.baseline_fast_forward_24,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
player.seekForward()
|
||||||
|
},
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.baseline_skip_next_24,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
player.seekToNext()
|
||||||
|
},
|
||||||
|
enabled = nextEnabled,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaybackButton(
|
||||||
|
@DrawableRes iconRes: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onControllerInteraction: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
val selectedColor = MaterialTheme.colorScheme.border
|
||||||
|
Button(
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ButtonDefaults.shape(CircleShape),
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.colors(
|
||||||
|
containerColor = AppColors.TransparentBlack25,
|
||||||
|
focusedContainerColor = selectedColor,
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(56.dp, 56.dp)
|
||||||
|
.onFocusChanged { onControllerInteraction.invoke() },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
painter = painterResource(iconRes),
|
||||||
|
contentDescription = "",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BottomDialog(
|
||||||
|
choices: List<String>,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelectChoice: (Int, String) -> Unit,
|
||||||
|
gravity: Int,
|
||||||
|
currentChoice: Int? = null,
|
||||||
|
) {
|
||||||
|
// TODO enforcing a width ends up ignore the gravity
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = true),
|
||||||
|
) {
|
||||||
|
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||||
|
dialogWindowProvider?.window?.let { window ->
|
||||||
|
window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre
|
||||||
|
window.setDimAmount(0f) // Remove dimmed background of ongoing playback
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.wrapContentSize()
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(Color.DarkGray),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// .widthIn(max = 240.dp)
|
||||||
|
.wrapContentWidth(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
choices.forEachIndexed { index, choice ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val focused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
val color =
|
||||||
|
if (focused) {
|
||||||
|
MaterialTheme.colorScheme.inverseOnSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
selected = index == currentChoice,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
onSelectChoice(index, choice)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
if (index == currentChoice) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(color)
|
||||||
|
.size(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = choice,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MoreButtonOptions(
|
||||||
|
val options: Map<String, PlaybackAction>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.Util
|
||||||
|
|
||||||
|
class PlaybackKeyHandler(
|
||||||
|
private val player: Player,
|
||||||
|
private val controlsEnabled: Boolean,
|
||||||
|
private val skipWithLeftRight: Boolean,
|
||||||
|
private val controllerViewState: ControllerViewState,
|
||||||
|
private val updateSkipIndicator: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
fun onKeyEvent(it: KeyEvent): Boolean {
|
||||||
|
var result = true
|
||||||
|
if (!controlsEnabled) {
|
||||||
|
result = false
|
||||||
|
} else if (it.type != KeyEventType.KeyUp) {
|
||||||
|
result = false
|
||||||
|
} else if (isDpad(it)) {
|
||||||
|
if (!controllerViewState.controlsVisible) {
|
||||||
|
if (skipWithLeftRight && it.key == Key.DirectionLeft) {
|
||||||
|
updateSkipIndicator(-player.seekBackIncrement)
|
||||||
|
player.seekBack()
|
||||||
|
} else if (skipWithLeftRight && it.key == Key.DirectionRight) {
|
||||||
|
player.seekForward()
|
||||||
|
updateSkipIndicator(player.seekForwardIncrement)
|
||||||
|
} else {
|
||||||
|
controllerViewState.showControls()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When controller is visible, its buttons will handle pulsing
|
||||||
|
}
|
||||||
|
} else if (isMedia(it)) {
|
||||||
|
when (it.key) {
|
||||||
|
Key.MediaPlay -> {
|
||||||
|
Util.handlePlayButtonAction(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.MediaPause -> {
|
||||||
|
Util.handlePauseButtonAction(player)
|
||||||
|
controllerViewState.showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.MediaPlayPause -> {
|
||||||
|
Util.handlePlayPauseButtonAction(player)
|
||||||
|
if (!player.isPlaying) {
|
||||||
|
controllerViewState.showControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.MediaFastForward, Key.MediaSkipForward -> {
|
||||||
|
player.seekForward()
|
||||||
|
updateSkipIndicator(player.seekForwardIncrement)
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.MediaRewind, Key.MediaSkipBackward -> {
|
||||||
|
player.seekBack()
|
||||||
|
updateSkipIndicator(-player.seekBackIncrement)
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.MediaNext -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext()
|
||||||
|
Key.MediaPrevious -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)) player.seekToPrevious()
|
||||||
|
else -> result = false
|
||||||
|
}
|
||||||
|
} else if (it.key == Key.Enter && !controllerViewState.controlsVisible) {
|
||||||
|
controllerViewState.showControls()
|
||||||
|
} else if (it.key == Key.Back && controllerViewState.controlsVisible) {
|
||||||
|
// TODO change this to a BackHandler?
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
} else {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
result = false
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaybackOverlay(
|
||||||
|
title: String?,
|
||||||
|
captions: List<TrackSupport>,
|
||||||
|
playerControls: Player,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
showPlay: Boolean,
|
||||||
|
previousEnabled: Boolean,
|
||||||
|
nextEnabled: Boolean,
|
||||||
|
seekEnabled: Boolean,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
onSeekBarChange: (Float) -> Unit,
|
||||||
|
showDebugInfo: Boolean,
|
||||||
|
scale: ContentScale,
|
||||||
|
playbackSpeed: Float,
|
||||||
|
moreButtonOptions: MoreButtonOptions,
|
||||||
|
subtitleIndex: Int?,
|
||||||
|
audioIndex: Int?,
|
||||||
|
audioOptions: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
// Will be used for preview/trick play images
|
||||||
|
var seekProgress by remember { mutableFloatStateOf(-1f) }
|
||||||
|
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||||
|
var seekBarDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
subtitle?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PlaybackControls(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
captions = captions,
|
||||||
|
playerControls = playerControls,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
showDebugInfo = showDebugInfo,
|
||||||
|
onSeekProgress = {
|
||||||
|
seekProgress = it
|
||||||
|
onSeekBarChange(it)
|
||||||
|
},
|
||||||
|
showPlay = showPlay,
|
||||||
|
previousEnabled = previousEnabled,
|
||||||
|
nextEnabled = nextEnabled,
|
||||||
|
seekEnabled = seekEnabled,
|
||||||
|
seekBarInteractionSource = seekBarInteractionSource,
|
||||||
|
moreButtonOptions = moreButtonOptions,
|
||||||
|
subtitleIndex = subtitleIndex,
|
||||||
|
audioIndex = audioIndex,
|
||||||
|
audioOptions = audioOptions,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
|
scale = scale,
|
||||||
|
seekBarIntervals = 16,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,9 +15,12 @@ import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.nanoseconds
|
||||||
|
|
||||||
enum class TranscodeType {
|
enum class TranscodeType {
|
||||||
DIRECT_PLAY,
|
DIRECT_PLAY,
|
||||||
|
|
@ -56,11 +59,22 @@ class PlaybackViewModel
|
||||||
|
|
||||||
val stream = MutableLiveData<StreamDecision?>(null)
|
val stream = MutableLiveData<StreamDecision?>(null)
|
||||||
|
|
||||||
|
val title = MutableLiveData<String?>(null)
|
||||||
|
val subtitle = MutableLiveData<String?>(null)
|
||||||
|
val duration = MutableLiveData<Duration?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addCloseable { player.release() }
|
addCloseable { player.release() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(itemId: UUID) {
|
fun init(
|
||||||
|
itemId: UUID,
|
||||||
|
item: BaseItemDto?,
|
||||||
|
) {
|
||||||
|
if (item != null) {
|
||||||
|
title.value = item.name
|
||||||
|
subtitle.value = item.episodeTitle
|
||||||
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val response =
|
val response =
|
||||||
api.mediaInfoApi
|
api.mediaInfoApi
|
||||||
|
|
@ -105,6 +119,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
|
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
duration.value = source.runTimeTicks?.nanoseconds
|
||||||
stream.value = decision
|
stream.value = decision
|
||||||
player.setMediaItem(decision.mediaItem)
|
player.setMediaItem(decision.mediaItem)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
package com.github.damontecres.stashapp.ui.components.playback
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Modified from https://github.com/android/tv-samples
|
||||||
|
*
|
||||||
|
* Copyright 2023 Google LLC
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import com.github.damontecres.dolphin.handleDPadKeyEvents
|
||||||
|
import com.github.damontecres.dolphin.ui.playback.ControllerViewState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SeekBarImpl(
|
||||||
|
progress: Float,
|
||||||
|
bufferedProgress: Float,
|
||||||
|
onSeek: (seekProgress: Float) -> Unit,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
intervals: Int = 10,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val color = MaterialTheme.colorScheme.border
|
||||||
|
val animatedIndicatorHeight by animateDpAsState(
|
||||||
|
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
|
||||||
|
)
|
||||||
|
var hasSeeked by remember { mutableStateOf(false) }
|
||||||
|
var seekProgress by remember { mutableFloatStateOf(progress) }
|
||||||
|
val progressToUse = if (isFocused && hasSeeked) seekProgress else progress
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (!isFocused) hasSeeked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val offset = 1f / intervals
|
||||||
|
|
||||||
|
val handleSeekEventModifier =
|
||||||
|
Modifier.handleDPadKeyEvents(
|
||||||
|
onCenter = {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
onSeek(seekProgress)
|
||||||
|
},
|
||||||
|
onLeft = {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
seekProgress = (progressToUse - offset).coerceAtLeast(0f)
|
||||||
|
hasSeeked = true
|
||||||
|
onSeek(seekProgress)
|
||||||
|
},
|
||||||
|
onRight = {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
seekProgress = (progressToUse + offset).coerceAtMost(1f)
|
||||||
|
hasSeeked = true
|
||||||
|
onSeek(seekProgress)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Canvas(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(animatedIndicatorHeight)
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.then(handleSeekEventModifier)
|
||||||
|
.focusable(enabled = enabled, interactionSource = interactionSource),
|
||||||
|
onDraw = {
|
||||||
|
val yOffset = size.height.div(2)
|
||||||
|
drawLine(
|
||||||
|
color = color.copy(alpha = 0.15f),
|
||||||
|
start = Offset(x = 0f, y = yOffset),
|
||||||
|
end = Offset(x = size.width, y = yOffset),
|
||||||
|
strokeWidth = size.height,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = color.copy(alpha = 0.50f),
|
||||||
|
start = Offset(x = 0f, y = yOffset),
|
||||||
|
end =
|
||||||
|
Offset(
|
||||||
|
x = size.width.times(bufferedProgress),
|
||||||
|
y = yOffset,
|
||||||
|
),
|
||||||
|
strokeWidth = size.height,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(x = 0f, y = yOffset),
|
||||||
|
end =
|
||||||
|
Offset(
|
||||||
|
// x = size.width.times(if (isSelected) seekProgress else progress),
|
||||||
|
x = size.width.times(progressToUse),
|
||||||
|
y = yOffset,
|
||||||
|
),
|
||||||
|
strokeWidth = size.height,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = Color.White,
|
||||||
|
radius = size.height + 2,
|
||||||
|
center = Offset(x = size.width.times(progressToUse), y = yOffset),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package com.github.damontecres.stashapp.ui.components.playback
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.listen
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
@Composable
|
||||||
|
fun rememberSeekBarState(
|
||||||
|
player: Player,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
): SeekBarState {
|
||||||
|
val seekBarState = remember(player) { SeekBarState(player, scope) }
|
||||||
|
LaunchedEffect(player) {
|
||||||
|
seekBarState.observe()
|
||||||
|
}
|
||||||
|
return seekBarState
|
||||||
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
class SeekBarState(
|
||||||
|
private val player: Player,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_FORWARD))
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun onValueChange(progress: Float) {
|
||||||
|
job?.cancel()
|
||||||
|
job =
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
delay(750L)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
player.seekTo((player.duration * progress).toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun observe(): Nothing =
|
||||||
|
player.listen { events ->
|
||||||
|
if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) {
|
||||||
|
isEnabled = isCommandAvailable(Player.COMMAND_SEEK_FORWARD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.github.damontecres.stashapp.ui.components.playback
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.R
|
||||||
|
import com.github.damontecres.dolphin.ifElse
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkipIndicator(
|
||||||
|
durationMs: Long,
|
||||||
|
onFinish: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val backward = durationMs < 0
|
||||||
|
var currentRotation by remember { mutableFloatStateOf(0f) }
|
||||||
|
val rotation = remember { Animatable(currentRotation) }
|
||||||
|
LaunchedEffect(durationMs, backward, onFinish) {
|
||||||
|
rotation.animateTo(
|
||||||
|
targetValue = currentRotation + if (backward) -270f else 270f,
|
||||||
|
animationSpec =
|
||||||
|
tween(
|
||||||
|
durationMillis = 800,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
currentRotation = value
|
||||||
|
}
|
||||||
|
onFinish()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.size(55.dp, 55.dp)) {
|
||||||
|
Image(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.rotate(currentRotation)
|
||||||
|
.ifElse(backward, Modifier.scale(scaleX = -1f, scaleY = 1f)),
|
||||||
|
painter = painterResource(id = R.drawable.circular_arrow_right),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
text = abs(durationMs / 1000).toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
data class TrackSupport(
|
||||||
|
val id: String?,
|
||||||
|
val type: TrackType,
|
||||||
|
val supported: TrackSupportReason,
|
||||||
|
val selected: Boolean,
|
||||||
|
val labels: List<String>,
|
||||||
|
val codecs: String?,
|
||||||
|
val format: Format,
|
||||||
|
) {
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun displayString(context: Context): String =
|
||||||
|
if (labels.isNotEmpty()) {
|
||||||
|
labels.joinToString(", ")
|
||||||
|
} else {
|
||||||
|
val type =
|
||||||
|
when (codecs) {
|
||||||
|
MimeTypes.TEXT_VTT -> "vtt"
|
||||||
|
MimeTypes.APPLICATION_VOBSUB -> "vobsub"
|
||||||
|
MimeTypes.APPLICATION_SUBRIP -> "srt"
|
||||||
|
MimeTypes.TEXT_SSA -> "ssa"
|
||||||
|
MimeTypes.APPLICATION_PGS -> "pgs"
|
||||||
|
MimeTypes.APPLICATION_DVBSUBS -> "dvd"
|
||||||
|
MimeTypes.APPLICATION_TTML -> "ttml"
|
||||||
|
MimeTypes.TEXT_UNKNOWN -> "unknown"
|
||||||
|
null -> "unknown"
|
||||||
|
else -> {
|
||||||
|
val split = codecs.split("/")
|
||||||
|
if (split.size > 1) split[1] else codecs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val language = languageName(context, format.language)
|
||||||
|
"$language ($type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TrackSupportReason {
|
||||||
|
HANDLED,
|
||||||
|
EXCEEDS_CAPABILITIES,
|
||||||
|
UNSUPPORTED_DRM,
|
||||||
|
UNSUPPORTED_SUBTYPE,
|
||||||
|
UNSUPPORTED_TYPE,
|
||||||
|
UNKNOWN,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun fromInt(
|
||||||
|
@C.FormatSupport value: Int,
|
||||||
|
): TrackSupportReason =
|
||||||
|
when (value) {
|
||||||
|
C.FORMAT_HANDLED -> HANDLED
|
||||||
|
C.FORMAT_EXCEEDS_CAPABILITIES -> EXCEEDS_CAPABILITIES
|
||||||
|
C.FORMAT_UNSUPPORTED_DRM -> UNSUPPORTED_DRM
|
||||||
|
C.FORMAT_UNSUPPORTED_SUBTYPE -> UNSUPPORTED_SUBTYPE
|
||||||
|
C.FORMAT_UNSUPPORTED_TYPE -> UNSUPPORTED_TYPE
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TrackType {
|
||||||
|
UNKNOWN,
|
||||||
|
DEFAULT,
|
||||||
|
AUDIO,
|
||||||
|
VIDEO,
|
||||||
|
TEXT,
|
||||||
|
IMAGE,
|
||||||
|
METADATA,
|
||||||
|
CAMERA_MOTION,
|
||||||
|
NONE,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun fromInt(value: Int): TrackType =
|
||||||
|
when (value) {
|
||||||
|
C.TRACK_TYPE_UNKNOWN -> UNKNOWN
|
||||||
|
C.TRACK_TYPE_DEFAULT -> DEFAULT
|
||||||
|
C.TRACK_TYPE_AUDIO -> AUDIO
|
||||||
|
C.TRACK_TYPE_VIDEO -> VIDEO
|
||||||
|
C.TRACK_TYPE_TEXT -> TEXT
|
||||||
|
C.TRACK_TYPE_IMAGE -> IMAGE
|
||||||
|
C.TRACK_TYPE_METADATA -> METADATA
|
||||||
|
C.TRACK_TYPE_CAMERA_MOTION -> CAMERA_MOTION
|
||||||
|
C.TRACK_TYPE_NONE -> NONE
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun checkForSupport(tracks: Tracks): List<TrackSupport> =
|
||||||
|
tracks.groups.flatMap {
|
||||||
|
buildList {
|
||||||
|
val type = TrackType.fromInt(it.type)
|
||||||
|
for (i in 0..<it.length) {
|
||||||
|
val format = it.getTrackFormat(i)
|
||||||
|
val labels =
|
||||||
|
format.labels
|
||||||
|
.map {
|
||||||
|
if (it.language != null) {
|
||||||
|
"${it.value} (${it.language})"
|
||||||
|
} else {
|
||||||
|
it.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
|
||||||
|
add(
|
||||||
|
TrackSupport(
|
||||||
|
format.id,
|
||||||
|
type,
|
||||||
|
reason,
|
||||||
|
it.isSelected,
|
||||||
|
labels,
|
||||||
|
format.codecs,
|
||||||
|
format,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun languageName(
|
||||||
|
context: Context,
|
||||||
|
code: String?,
|
||||||
|
) = if (code != null && code != "00") {
|
||||||
|
try {
|
||||||
|
Locale(code).displayLanguage
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error in locale for '$code'")
|
||||||
|
code.uppercase()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Unknown"
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/baseline_fast_forward_24.xml
Normal file
10
app/src/main/res/drawable/baseline_fast_forward_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M4,18l8.5,-6L4,6v12zM13,6v12l8.5,-6L13,6z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/baseline_fast_rewind_24.xml
Normal file
10
app/src/main/res/drawable/baseline_fast_rewind_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M11,18L11,6l-8.5,6 8.5,6zM11.5,12l8.5,6L20,6l-8.5,6z"/>
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/drawable/baseline_more_vert_96.xml
Normal file
5
app/src/main/res/drawable/baseline_more_vert_96.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="96dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="96dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/baseline_pause_24.xml
Normal file
10
app/src/main/res/drawable/baseline_pause_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/baseline_play_arrow_24.xml
Normal file
10
app/src/main/res/drawable/baseline_play_arrow_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M8,5v14l11,-7z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/baseline_skip_next_24.xml
Normal file
10
app/src/main/res/drawable/baseline_skip_next_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/baseline_skip_previous_24.xml
Normal file
10
app/src/main/res/drawable/baseline_skip_previous_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/baseline_stop_24.xml
Normal file
10
app/src/main/res/drawable/baseline_stop_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,6h12v12H6z"/>
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/drawable/baseline_undo_24.xml
Normal file
5
app/src/main/res/drawable/baseline_undo_24.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M12.5,8c-2.65,0 -5.05,0.99 -6.9,2.6L2,7v9h9l-3.62,-3.62c1.39,-1.16 3.16,-1.88 5.12,-1.88 3.54,0 6.55,2.31 7.6,5.5l2.37,-0.78C21.08,11.03 17.15,8 12.5,8z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
7
app/src/main/res/drawable/captions_svgrepo_com.xml
Normal file
7
app/src/main/res/drawable/captions_svgrepo_com.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="200dp" android:viewportHeight="24" android:viewportWidth="24" android:width="200dp">
|
||||||
|
|
||||||
|
<path android:fillColor="#000000" android:pathData="M6,10v4c0,1.103 0.897,2 2,2h3v-2L8,14v-4h3L11,8L8,8c-1.103,0 -2,0.897 -2,2zM13,10v4c0,1.103 0.897,2 2,2h3v-2h-3v-4h3L18,8h-3c-1.103,0 -2,0.897 -2,2z"/>
|
||||||
|
|
||||||
|
<path android:fillColor="#000000" android:pathData="M20,4H4c-1.103,0 -2,0.897 -2,2v12c0,1.103 0.897,2 2,2h16c1.103,0 2,-0.897 2,-2V6c0,-1.103 -0.897,-2 -2,-2zM4,18V6h16l0.002,12H4z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/circular_arrow_right.xml
Normal file
10
app/src/main/res/drawable/circular_arrow_right.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="800dp"
|
||||||
|
android:height="800dp"
|
||||||
|
android:viewportWidth="75.695"
|
||||||
|
android:viewportHeight="75.695">
|
||||||
|
<!-- Modified from https://www.svgrepo.com/svg/168006/circular-arrow-clock under CC0 License -->
|
||||||
|
<path
|
||||||
|
android:pathData="M75.695,37.846c0,20.869 -16.98,37.85 -37.848,37.85C16.981,75.695 0,58.715 0,37.846C0,16.977 16.981,0 37.848,0c7.628,0 15.055,2.331 21.31,6.592l5.816,-5.817l4.679,17.946l-17.949,-4.678l4.069,-4.072c-5.319,-3.422 -11.538,-5.3 -17.929,-5.3c-18.294,0 -33.176,14.882 -33.176,33.174c0,18.294 14.882,33.178 33.176,33.178c18.293,0 33.175,-14.884 33.175,-33.178H75.695z"
|
||||||
|
android:fillColor="#ffffff" />
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/drawable/vector_settings.xml
Normal file
5
app/src/main/res/drawable/vector_settings.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<vector android:height="32dp" android:tint="#FFFFFF"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
|
||||||
|
</vector>
|
||||||
BIN
app/src/main/res/font/fa_solid_900.ttf
Normal file
BIN
app/src/main/res/font/fa_solid_900.ttf
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue