mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-08 23:18:16 -07:00
fix: More native player UI fixes
This commit is contained in:
parent
edbd8d467c
commit
cfd4b4a5cc
12 changed files with 217 additions and 55 deletions
|
|
@ -47,7 +47,7 @@ fun VideoPlayerScreen(
|
||||||
) {
|
) {
|
||||||
val leanBackEnabled = leanBackEnabled(LocalContext.current)
|
val leanBackEnabled = leanBackEnabled(LocalContext.current)
|
||||||
ExoPlayer { player ->
|
ExoPlayer { player ->
|
||||||
ScaledContent(if (leanBackEnabled) 0.75f else 1f) {
|
ScaledContent(if (leanBackEnabled) 0.7f else 1f) {
|
||||||
CustomVideoControls(player)
|
CustomVideoControls(player)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ internal fun CustomIconButton(
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides currentContentColor) {
|
CompositionLocalProvider(LocalContentColor provides currentContentColor) {
|
||||||
Box(modifier = Modifier.padding(8.dp)) {
|
Box(modifier = Modifier.padding(12.dp)) {
|
||||||
icon()
|
icon()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import PlayableData
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -13,19 +12,20 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemHeader(state: PlayableData?) {
|
fun ItemHeader(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: PlayableData?
|
||||||
|
) {
|
||||||
val title = state?.title
|
val title = state?.title
|
||||||
val logoUrl = state?.logoUrl
|
val logoUrl = state?.logoUrl
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding(),
|
||||||
.padding(16.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
if (!logoUrl.isNullOrBlank()) {
|
if (!logoUrl.isNullOrBlank()) {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -51,7 +52,6 @@ import androidx.compose.ui.input.key.Key.Companion.DirectionRight
|
||||||
import androidx.compose.ui.input.key.Key.Companion.Enter
|
import androidx.compose.ui.input.key.Key.Companion.Enter
|
||||||
import androidx.compose.ui.input.key.Key.Companion.Escape
|
import androidx.compose.ui.input.key.Key.Companion.Escape
|
||||||
import androidx.compose.ui.input.key.Key.Companion.Spacebar
|
import androidx.compose.ui.input.key.Key.Companion.Spacebar
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
|
@ -63,12 +63,14 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastCoerceIn
|
import androidx.compose.ui.util.fastCoerceIn
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
import nl.jknaapen.fladder.utility.formatTime
|
import nl.jknaapen.fladder.utility.formatTime
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
import kotlin.time.toDuration
|
import kotlin.time.toDuration
|
||||||
|
|
||||||
|
|
@ -259,7 +261,7 @@ internal fun RowScope.SimpleProgressBar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusable(enabled = false)
|
.focusable(enabled = false)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(9.dp)
|
.height(8.dp)
|
||||||
.background(
|
.background(
|
||||||
color = Color.White.copy(
|
color = Color.White.copy(
|
||||||
alpha = 0.15f
|
alpha = 0.15f
|
||||||
|
|
@ -313,10 +315,10 @@ internal fun RowScope.SimpleProgressBar(
|
||||||
.focusable(enabled = false)
|
.focusable(enabled = false)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
translationX = startPx
|
translationX = startPx
|
||||||
translationY = 13.dp.toPx()
|
translationY = 14.dp.toPx()
|
||||||
}
|
}
|
||||||
.width(segDp)
|
.width(segDp)
|
||||||
.height(7.dp)
|
.height(6.dp)
|
||||||
.background(
|
.background(
|
||||||
color = segment.color.copy(alpha = 0.75f),
|
color = segment.color.copy(alpha = 0.75f),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
|
@ -367,6 +369,30 @@ internal fun RowScope.SimpleProgressBar(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
var direction by remember { mutableIntStateOf(0) }
|
||||||
|
var speed by remember { mutableLongStateOf(1L) }
|
||||||
|
val scrubSpeedDivider = 15L
|
||||||
|
|
||||||
|
val lastInteraction = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
|
|
||||||
|
// Restart the multiplier
|
||||||
|
LaunchedEffect(lastInteraction.longValue) {
|
||||||
|
delay(500.milliseconds)
|
||||||
|
speed = 1L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLastInteraction() {
|
||||||
|
lastInteraction.longValue = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrubSpeed = playbackData?.trickPlayModel?.interval ?: 5.seconds.inWholeMilliseconds
|
||||||
|
|
||||||
|
fun scrubSpeedResult(): Long {
|
||||||
|
return (scrubSpeed * (speed / scrubSpeedDivider).coerceIn(
|
||||||
|
1L..60.seconds.inWholeMilliseconds
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
//Thumb
|
//Thumb
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -379,7 +405,7 @@ internal fun RowScope.SimpleProgressBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.focusable(enabled = true)
|
.focusable(enabled = true)
|
||||||
.onKeyEvent { keyEvent: KeyEvent ->
|
.onKeyEvent { keyEvent ->
|
||||||
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
|
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
|
||||||
onUserInteraction()
|
onUserInteraction()
|
||||||
|
|
@ -392,25 +418,42 @@ internal fun RowScope.SimpleProgressBar(
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectionLeft -> {
|
DirectionLeft -> {
|
||||||
|
if (direction != -1) {
|
||||||
|
direction = -1
|
||||||
|
speed = 1L
|
||||||
|
} else {
|
||||||
|
speed++
|
||||||
|
}
|
||||||
if (!scrubbingTimeLine) {
|
if (!scrubbingTimeLine) {
|
||||||
onTempPosChanged(position)
|
onTempPosChanged(position)
|
||||||
onScrubbingChanged(true)
|
onScrubbingChanged(true)
|
||||||
player.pause()
|
player.pause()
|
||||||
}
|
}
|
||||||
val newPos = max(0L, tempPosition - 3000L)
|
val newPos = max(
|
||||||
|
0L,
|
||||||
|
tempPosition - scrubSpeedResult()
|
||||||
|
)
|
||||||
onTempPosChanged(newPos)
|
onTempPosChanged(newPos)
|
||||||
|
updateLastInteraction()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectionRight -> {
|
DirectionRight -> {
|
||||||
|
if (direction != 1) {
|
||||||
|
direction = 1
|
||||||
|
speed = 1L
|
||||||
|
} else {
|
||||||
|
speed++
|
||||||
|
}
|
||||||
if (!scrubbingTimeLine) {
|
if (!scrubbingTimeLine) {
|
||||||
onTempPosChanged(position)
|
onTempPosChanged(position)
|
||||||
onScrubbingChanged(true)
|
onScrubbingChanged(true)
|
||||||
player.pause()
|
player.pause()
|
||||||
}
|
}
|
||||||
val newPos = min(player.duration.takeIf { it > 0 } ?: 1L,
|
val newPos = min(player.duration.takeIf { it > 0 } ?: 1L,
|
||||||
tempPosition + 3000L)
|
tempPosition + scrubSpeedResult())
|
||||||
onTempPosChanged(newPos)
|
onTempPosChanged(newPos)
|
||||||
|
updateLastInteraction()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,6 +491,7 @@ internal fun RowScope.SimpleProgressBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val MediaSegment.color: Color
|
val MediaSegment.color: Color
|
||||||
get() = when (this.type) {
|
get() = when (this.type) {
|
||||||
MediaSegmentType.COMMERCIAL -> Color.Magenta
|
MediaSegmentType.COMMERCIAL -> Color.Magenta
|
||||||
|
|
|
||||||
|
|
@ -88,15 +88,16 @@ internal fun BoxScope.SegmentSkipOverlay(
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
activeSegment != null && skip == SegmentSkip.ASK,
|
activeSegment != null && skip == SegmentSkip.ASK,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.align(alignment = Alignment.CenterEnd)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.safeContentPadding()
|
.safeContentPadding()
|
||||||
) {
|
) {
|
||||||
CustomIconButton(
|
CustomIconButton(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.align(alignment = Alignment.CenterEnd)
|
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.defaultSelected(true),
|
.defaultSelected(true),
|
||||||
|
backgroundColor = Color.Black.copy(alpha = 0.5f),
|
||||||
|
enableScaledFocus = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
activeSegment?.let {
|
activeSegment?.let {
|
||||||
player.seekTo(it.end)
|
player.seekTo(it.end)
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,11 @@ import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.DirectionLeft
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.DirectionRight
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -76,6 +79,7 @@ import nl.jknaapen.fladder.utility.ImmersiveSystemBars
|
||||||
import nl.jknaapen.fladder.utility.defaultSelected
|
import nl.jknaapen.fladder.utility.defaultSelected
|
||||||
import nl.jknaapen.fladder.utility.leanBackEnabled
|
import nl.jknaapen.fladder.utility.leanBackEnabled
|
||||||
import nl.jknaapen.fladder.utility.visible
|
import nl.jknaapen.fladder.utility.visible
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,7 +102,7 @@ fun CustomVideoControls(
|
||||||
|
|
||||||
val buffering by VideoPlayerObject.buffering.collectAsState(true)
|
val buffering by VideoPlayerObject.buffering.collectAsState(true)
|
||||||
val playing by VideoPlayerObject.playing.collectAsState(false)
|
val playing by VideoPlayerObject.playing.collectAsState(false)
|
||||||
val controlsPadding = 16.dp
|
val controlsPadding = 32.dp
|
||||||
|
|
||||||
ImmersiveSystemBars(isImmersive = !showControls)
|
ImmersiveSystemBars(isImmersive = !showControls)
|
||||||
|
|
||||||
|
|
@ -128,6 +132,26 @@ fun CustomVideoControls(
|
||||||
lastInteraction.longValue = System.currentTimeMillis()
|
lastInteraction.longValue = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val forwardSpeed by PlayerSettingsObject.forwardSpeed.collectAsState(30.seconds)
|
||||||
|
val backwardSpeed by PlayerSettingsObject.backwardSpeed.collectAsState(15.seconds)
|
||||||
|
|
||||||
|
val position by VideoPlayerObject.position.collectAsState(0L)
|
||||||
|
val player = VideoPlayerObject.implementation.player
|
||||||
|
val lastSeekInteraction = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
|
|
||||||
|
var currentSkipTime by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
// Restart the multiplier
|
||||||
|
LaunchedEffect(lastSeekInteraction.longValue) {
|
||||||
|
delay(2.seconds)
|
||||||
|
player?.seekTo(position + currentSkipTime)
|
||||||
|
currentSkipTime = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSeekInteraction() {
|
||||||
|
lastSeekInteraction.longValue = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -139,7 +163,27 @@ fun CustomVideoControls(
|
||||||
}
|
}
|
||||||
.onKeyEvent { keyEvent: KeyEvent ->
|
.onKeyEvent { keyEvent: KeyEvent ->
|
||||||
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
|
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
|
||||||
if (!showControls) {
|
if (!showControls) {
|
||||||
|
when (keyEvent.key) {
|
||||||
|
DirectionLeft -> {
|
||||||
|
if (currentSkipTime == 0L) {
|
||||||
|
player?.seekTo(position - backwardSpeed.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
currentSkipTime -= backwardSpeed.inWholeMilliseconds
|
||||||
|
updateSeekInteraction()
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectionRight -> {
|
||||||
|
if (currentSkipTime.absoluteValue == 0L) {
|
||||||
|
player?.seekTo(position + forwardSpeed.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
currentSkipTime += forwardSpeed.inWholeMilliseconds
|
||||||
|
updateSeekInteraction()
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
}
|
||||||
bottomControlFocusRequester.requestFocus()
|
bottomControlFocusRequester.requestFocus()
|
||||||
updateLastInteraction()
|
updateLastInteraction()
|
||||||
return@onKeyEvent true
|
return@onKeyEvent true
|
||||||
|
|
@ -193,7 +237,10 @@ fun CustomVideoControls(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
state?.let {
|
state?.let {
|
||||||
ItemHeader(it)
|
ItemHeader(
|
||||||
|
modifier = Modifier.padding(controlsPadding),
|
||||||
|
it
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!leanBackEnabled(LocalContext.current)) {
|
if (!leanBackEnabled(LocalContext.current)) {
|
||||||
|
|
@ -217,7 +264,6 @@ fun CustomVideoControls(
|
||||||
// Progress Bar
|
// Progress Bar
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = controlsPadding)
|
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
|
|
@ -228,6 +274,7 @@ fun CustomVideoControls(
|
||||||
end = Offset(0f, Float.POSITIVE_INFINITY)
|
end = Offset(0f, Float.POSITIVE_INFINITY)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.padding(horizontal = controlsPadding)
|
||||||
.displayCutoutPadding()
|
.displayCutoutPadding()
|
||||||
.padding(top = 8.dp, bottom = controlsPadding)
|
.padding(top = 8.dp, bottom = controlsPadding)
|
||||||
) {
|
) {
|
||||||
|
|
@ -250,10 +297,10 @@ fun CustomVideoControls(
|
||||||
RightButtons(showAudioDialog, showSubDialog)
|
RightButtons(showAudioDialog, showSubDialog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SegmentSkipOverlay()
|
SegmentSkipOverlay()
|
||||||
|
SeekOverlay(value = currentSkipTime)
|
||||||
if (buffering && !playing) {
|
if (buffering && !playing) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,16 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
import nl.jknaapen.fladder.utility.clearAudioTrack
|
import nl.jknaapen.fladder.utility.clearAudioTrack
|
||||||
import nl.jknaapen.fladder.utility.defaultSelected
|
import nl.jknaapen.fladder.utility.conditional
|
||||||
import nl.jknaapen.fladder.utility.setInternalAudioTrack
|
import nl.jknaapen.fladder.utility.setInternalAudioTrack
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
@ -29,6 +32,8 @@ fun AudioPicker(
|
||||||
val audioTracks by VideoPlayerObject.audioTracks.collectAsState(listOf())
|
val audioTracks by VideoPlayerObject.audioTracks.collectAsState(listOf())
|
||||||
val internalAudioTracks by VideoPlayerObject.exoAudioTracks
|
val internalAudioTracks by VideoPlayerObject.exoAudioTracks
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(selectedIndex) {
|
LaunchedEffect(selectedIndex) {
|
||||||
|
|
@ -53,7 +58,9 @@ fun AudioPicker(
|
||||||
TrackButton(
|
TrackButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.defaultSelected(selectedOff),
|
.conditional(selectedOff) {
|
||||||
|
focusRequester(focusRequester)
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
VideoPlayerObject.setAudioTrackIndex(-1)
|
VideoPlayerObject.setAudioTrackIndex(-1)
|
||||||
player.clearAudioTrack()
|
player.clearAudioTrack()
|
||||||
|
|
@ -72,7 +79,9 @@ fun AudioPicker(
|
||||||
TrackButton(
|
TrackButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.defaultSelected(selected),
|
.conditional(selected) {
|
||||||
|
focusRequester(focusRequester)
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
serverTrack?.index?.let {
|
serverTrack?.index?.let {
|
||||||
VideoPlayerObject.setAudioTrackIndex(it.toInt())
|
VideoPlayerObject.setAudioTrackIndex(it.toInt())
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,14 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
import nl.jknaapen.fladder.utility.defaultSelected
|
import nl.jknaapen.fladder.utility.conditional
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -44,6 +46,8 @@ internal fun ChapterSelectionSheet(
|
||||||
val chapters = playbackData?.chapters ?: listOf()
|
val chapters = playbackData?.chapters ?: listOf()
|
||||||
val currentPosition by VideoPlayerObject.position.collectAsState(0L)
|
val currentPosition by VideoPlayerObject.position.collectAsState(0L)
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
if (chapters.isEmpty()) return
|
if (chapters.isEmpty()) return
|
||||||
|
|
||||||
var currentChapter: Chapter? by remember {
|
var currentChapter: Chapter? by remember {
|
||||||
|
|
@ -56,10 +60,13 @@ internal fun ChapterSelectionSheet(
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(chapters) {
|
LaunchedEffect(chapters, currentPosition) {
|
||||||
|
val chapter = chapters.indexOfCurrent(currentPosition)
|
||||||
lazyListState.animateScrollToItem(
|
lazyListState.animateScrollToItem(
|
||||||
chapters.indexOfCurrent(currentPosition)
|
chapter
|
||||||
)
|
)
|
||||||
|
currentChapter = chapters[chapter]
|
||||||
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomModalBottomSheet(
|
CustomModalBottomSheet(
|
||||||
|
|
@ -68,7 +75,7 @@ internal fun ChapterSelectionSheet(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
.wrapContentHeight(),
|
.wrapContentHeight(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -88,15 +95,19 @@ internal fun ChapterSelectionSheet(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(
|
.background(
|
||||||
color = Color.Black.copy(alpha = 0.75f),
|
color = if (selectedChapter) Color.White.copy(alpha = 0.25f) else Color.Black.copy(
|
||||||
|
alpha = 0.75f
|
||||||
|
),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
)
|
)
|
||||||
.border(
|
.border(
|
||||||
width = 2.dp,
|
width = 2.dp,
|
||||||
color = Color.White.copy(alpha = if (selectedChapter) 1f else 0f),
|
color = Color.White.copy(alpha = if (selectedChapter) 0.45f else 0f),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
)
|
)
|
||||||
.defaultSelected(index == 0)
|
.conditional(selectedChapter) {
|
||||||
|
focusRequester(focusRequester)
|
||||||
|
}
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
if (it.isFocused) {
|
if (it.isFocused) {
|
||||||
currentChapter = chapter
|
currentChapter = chapter
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,16 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
import nl.jknaapen.fladder.utility.clearSubtitleTrack
|
import nl.jknaapen.fladder.utility.clearSubtitleTrack
|
||||||
import nl.jknaapen.fladder.utility.defaultSelected
|
import nl.jknaapen.fladder.utility.conditional
|
||||||
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
|
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
@ -30,13 +33,16 @@ fun SubtitlePicker(
|
||||||
val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(listOf())
|
val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(listOf())
|
||||||
val internalSubTracks by VideoPlayerObject.exoSubTracks
|
val internalSubTracks by VideoPlayerObject.exoSubTracks
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(selectedIndex) {
|
LaunchedEffect(selectedIndex, subTitles) {
|
||||||
if (selectedIndex == -1) return@LaunchedEffect
|
if (selectedIndex == -1) return@LaunchedEffect
|
||||||
listState.scrollToItem(
|
listState.scrollToItem(
|
||||||
subTitles.indexOfFirst { it.index == selectedIndex.toLong() }
|
subTitles.indexOfFirst { it.index == selectedIndex.toLong() }
|
||||||
)
|
)
|
||||||
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomModalBottomSheet(
|
CustomModalBottomSheet(
|
||||||
|
|
@ -54,7 +60,9 @@ fun SubtitlePicker(
|
||||||
TrackButton(
|
TrackButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.defaultSelected(selectedOff),
|
.conditional(selectedOff) {
|
||||||
|
focusRequester(focusRequester)
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
VideoPlayerObject.setSubtitleTrackIndex(-1)
|
VideoPlayerObject.setSubtitleTrackIndex(-1)
|
||||||
player.clearSubtitleTrack()
|
player.clearSubtitleTrack()
|
||||||
|
|
@ -73,7 +81,9 @@ fun SubtitlePicker(
|
||||||
TrackButton(
|
TrackButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.defaultSelected(selected),
|
.conditional(selected) {
|
||||||
|
focusRequester(focusRequester)
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
serverSub?.index?.let {
|
serverSub?.index?.let {
|
||||||
VideoPlayerObject.setSubtitleTrackIndex(it.toInt())
|
VideoPlayerObject.setSubtitleTrackIndex(it.toInt())
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ fun ScaledContent(
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val fontScale = 1f / scale
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalDensity provides Density(
|
LocalDensity provides Density(
|
||||||
density = density.density * scale,
|
density = density.density * scale,
|
||||||
|
fontScale = fontScale
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:markdown_widget/widget/markdown.dart';
|
import 'package:markdown_widget/widget/markdown.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||||
import 'package:fladder/providers/update_provider.dart';
|
import 'package:fladder/providers/update_provider.dart';
|
||||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||||
|
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||||
import 'package:fladder/screens/shared/media/external_urls.dart';
|
import 'package:fladder/screens/shared/media/external_urls.dart';
|
||||||
import 'package:fladder/util/list_padding.dart';
|
import 'package:fladder/util/list_padding.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
|
|
@ -87,6 +93,8 @@ class UpdateInformation extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final apkDownload =
|
||||||
|
releaseInfo.preferredDownloads.entries.where((entry) => entry.value.toLowerCase().endsWith('.apk')).firstOrNull;
|
||||||
return ExpansionTile(
|
return ExpansionTile(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
releaseInfo.isNewerThanCurrent ? context.colors.primaryContainer : context.colors.surfaceContainer,
|
releaseInfo.isNewerThanCurrent ? context.colors.primaryContainer : context.colors.surfaceContainer,
|
||||||
|
|
@ -107,6 +115,32 @@ class UpdateInformation extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (apkDownload != null)
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(Uri.parse(apkDownload.value));
|
||||||
|
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final apkPath = '${tempDir.path}/update.apk';
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final file = File(apkPath);
|
||||||
|
await file.writeAsBytes(response.bodyBytes);
|
||||||
|
launchUrl(context, file.path);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to download APK: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
fladderSnackbar(context, title: 'Failed to download update: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
"Install",
|
||||||
|
),
|
||||||
|
),
|
||||||
...releaseInfo.preferredDownloads.entries.map(
|
...releaseInfo.preferredDownloads.entries.map(
|
||||||
(entry) {
|
(entry) {
|
||||||
return FilledButton(
|
return FilledButton(
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class DetailedBanner extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DetailedBannerState extends ConsumerState<DetailedBanner> {
|
class _DetailedBannerState extends ConsumerState<DetailedBanner> {
|
||||||
late ItemBaseModel selectedPoster = widget.posters.first;
|
late ValueNotifier<ItemBaseModel> selectedPoster = ValueNotifier(widget.posters.first);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -45,8 +45,11 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 1.8,
|
aspectRatio: 1.8,
|
||||||
child: CustomShaderMask(
|
child: CustomShaderMask(
|
||||||
child: FladderImage(
|
child: ValueListenableBuilder(
|
||||||
image: selectedPoster.images?.primary,
|
valueListenable: selectedPoster,
|
||||||
|
builder: (context, value, child) => FladderImage(
|
||||||
|
image: value.images?.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -68,20 +71,23 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 4),
|
||||||
child: FractionallySizedBox(
|
child: FractionallySizedBox(
|
||||||
widthFactor: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? 1.0 : 0.55,
|
widthFactor: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? 1.0 : 0.55,
|
||||||
child: OverviewHeader(
|
child: ValueListenableBuilder(
|
||||||
name: selectedPoster.parentBaseModel.name,
|
valueListenable: selectedPoster,
|
||||||
subTitle: selectedPoster.label(context),
|
builder: (context, value, child) => OverviewHeader(
|
||||||
image: selectedPoster.getPosters,
|
name: value.parentBaseModel.name,
|
||||||
logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
|
subTitle: value.label(context),
|
||||||
? Alignment.center
|
image: value.getPosters,
|
||||||
: Alignment.centerLeft,
|
logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
|
||||||
summary: selectedPoster.overview.summary,
|
? Alignment.center
|
||||||
productionYear: selectedPoster.overview.productionYear,
|
: Alignment.centerLeft,
|
||||||
runTime: selectedPoster.overview.runTime,
|
summary: value.overview.summary,
|
||||||
genres: selectedPoster.overview.genreItems,
|
productionYear: value.overview.productionYear,
|
||||||
studios: selectedPoster.overview.studios,
|
runTime: value.overview.runTime,
|
||||||
officialRating: selectedPoster.overview.parentalRating,
|
genres: value.overview.genreItems,
|
||||||
communityRating: selectedPoster.overview.communityRating,
|
studios: value.overview.studios,
|
||||||
|
officialRating: value.overview.parentalRating,
|
||||||
|
communityRating: value.overview.communityRating,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -97,9 +103,7 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
|
||||||
context.ensureVisible(
|
context.ensureVisible(
|
||||||
alignment: 1.0,
|
alignment: 1.0,
|
||||||
);
|
);
|
||||||
setState(() {
|
selectedPoster.value = poster;
|
||||||
selectedPoster = poster;
|
|
||||||
});
|
|
||||||
widget.onSelect(poster);
|
widget.onSelect(poster);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue