Add playback controls

This commit is contained in:
Damontecres 2025-09-22 20:09:12 -04:00
parent 3e7f11c588
commit b69a48407b
No known key found for this signature in database
27 changed files with 1618 additions and 6 deletions

View file

@ -1,6 +1,9 @@
package com.github.damontecres.dolphin
import android.view.KeyEvent
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent
import timber.log.Timber
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@ -24,3 +27,61 @@ fun FocusRequester.tryRequestFocus(): Boolean =
Timber.w(ex, "Failed to request focus")
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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,33 +1,55 @@
package com.github.damontecres.dolphin.ui.playback
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.focusable
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
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.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
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.rememberPreviousButtonState
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.tryRequestFocus
import com.github.damontecres.dolphin.ui.nav.Destination
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)
@Composable
@ -38,11 +60,17 @@ fun PlaybackContent(
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
LaunchedEffect(destination.itemId) {
viewModel.init(destination.itemId)
viewModel.init(destination.itemId, destination.item)
}
val player = viewModel.player
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) {
// TODO loading
} else {
@ -55,16 +83,52 @@ fun PlaybackContent(
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player)
val previousState = rememberPreviousButtonState(player)
val nextState = rememberNextButtonState(player)
val seekBarState = rememberSeekBarState(player, scope)
LaunchedEffect(Unit) {
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(
modifier
.background(Color.Black)
.onKeyEvent {
// TODO handle key events for playback controls
false
}.focusRequester(focusRequester)
.onKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) {
PlayerSurface(
@ -79,6 +143,70 @@ fun PlaybackContent(
.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(),
)
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -15,9 +15,12 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.nanoseconds
enum class TranscodeType {
DIRECT_PLAY,
@ -56,11 +59,22 @@ class PlaybackViewModel
val stream = MutableLiveData<StreamDecision?>(null)
val title = MutableLiveData<String?>(null)
val subtitle = MutableLiveData<String?>(null)
val duration = MutableLiveData<Duration?>(null)
init {
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) {
val response =
api.mediaInfoApi
@ -105,6 +119,7 @@ class PlaybackViewModel
}
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
withContext(Dispatchers.Main) {
duration.value = source.runTimeTicks?.nanoseconds
stream.value = decision
player.setMediaItem(decision.mediaItem)
player.prepare()

View file

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

View file

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

View file

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

View file

@ -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"
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Binary file not shown.