mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +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
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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()
|
||||
|
|
|
|||
|
|
@ -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