diff --git a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt index b64f8fa6..3e2ae086 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt @@ -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 +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt new file mode 100644 index 00000000..0c30c8a4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt @@ -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) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/ControllerViewState.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/ControllerViewState.kt new file mode 100644 index 00000000..301abd80 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/ControllerViewState.kt @@ -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(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 + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt new file mode 100644 index 00000000..1f911e4d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackConstants.kt new file mode 100644 index 00000000..b1779ee6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackConstants.kt @@ -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", + ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt index 4af5631c..439d02e4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -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(), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt new file mode 100644 index 00000000..825c6dfb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -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, + 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, + 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, + onControllerInteraction: () -> Unit, + onControllerInteractionForDialog: () -> Unit, + onPlaybackActionClick: (PlaybackAction) -> Unit, + subtitleIndex: Int?, + audioOptions: List, + 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, + 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, +) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackKeyHandler.kt new file mode 100644 index 00000000..2d4b7962 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackKeyHandler.kt @@ -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 + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt new file mode 100644 index 00000000..aed56cf3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -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, + 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, + 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, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index bbd217f1..3cff2a34 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -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(null) + val title = MutableLiveData(null) + val subtitle = MutableLiveData(null) + val duration = MutableLiveData(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() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt new file mode 100644 index 00000000..7be6f874 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt @@ -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), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt new file mode 100644 index 00000000..cf84a01c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SkipIndicator.kt new file mode 100644 index 00000000..15f110f9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SkipIndicator.kt @@ -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(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackSupport.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackSupport.kt new file mode 100644 index 00000000..282b0d06 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackSupport.kt @@ -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, + 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 = + tracks.groups.flatMap { + buildList { + val type = TrackType.fromInt(it.type) + for (i in 0.. + + diff --git a/app/src/main/res/drawable/baseline_fast_rewind_24.xml b/app/src/main/res/drawable/baseline_fast_rewind_24.xml new file mode 100644 index 00000000..81f79b32 --- /dev/null +++ b/app/src/main/res/drawable/baseline_fast_rewind_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_more_vert_96.xml b/app/src/main/res/drawable/baseline_more_vert_96.xml new file mode 100644 index 00000000..35a0b1d2 --- /dev/null +++ b/app/src/main/res/drawable/baseline_more_vert_96.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_pause_24.xml b/app/src/main/res/drawable/baseline_pause_24.xml new file mode 100644 index 00000000..13d6d2ec --- /dev/null +++ b/app/src/main/res/drawable/baseline_pause_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_play_arrow_24.xml b/app/src/main/res/drawable/baseline_play_arrow_24.xml new file mode 100644 index 00000000..13c137a9 --- /dev/null +++ b/app/src/main/res/drawable/baseline_play_arrow_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_skip_next_24.xml b/app/src/main/res/drawable/baseline_skip_next_24.xml new file mode 100644 index 00000000..4fff2475 --- /dev/null +++ b/app/src/main/res/drawable/baseline_skip_next_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_skip_previous_24.xml b/app/src/main/res/drawable/baseline_skip_previous_24.xml new file mode 100644 index 00000000..1805b7d1 --- /dev/null +++ b/app/src/main/res/drawable/baseline_skip_previous_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml new file mode 100644 index 00000000..d099f1aa --- /dev/null +++ b/app/src/main/res/drawable/baseline_stop_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_undo_24.xml b/app/src/main/res/drawable/baseline_undo_24.xml new file mode 100644 index 00000000..073aa3b7 --- /dev/null +++ b/app/src/main/res/drawable/baseline_undo_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/captions_svgrepo_com.xml b/app/src/main/res/drawable/captions_svgrepo_com.xml new file mode 100644 index 00000000..59ed3842 --- /dev/null +++ b/app/src/main/res/drawable/captions_svgrepo_com.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/circular_arrow_right.xml b/app/src/main/res/drawable/circular_arrow_right.xml new file mode 100644 index 00000000..0b178d84 --- /dev/null +++ b/app/src/main/res/drawable/circular_arrow_right.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/vector_settings.xml b/app/src/main/res/drawable/vector_settings.xml new file mode 100644 index 00000000..584de10c --- /dev/null +++ b/app/src/main/res/drawable/vector_settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/font/fa_solid_900.ttf b/app/src/main/res/font/fa_solid_900.ttf new file mode 100644 index 00000000..ec24749d Binary files /dev/null and b/app/src/main/res/font/fa_solid_900.ttf differ