feat: Bunch of small UI improvements for native player

This commit is contained in:
PartyDonut 2025-10-13 20:03:13 +02:00
parent 66ffc8c112
commit edbd8d467c
23 changed files with 329 additions and 327 deletions

View file

@ -1,30 +1,45 @@
package nl.jknaapen.fladder
import android.os.Build
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.materialkolor.PaletteStyle
import com.materialkolor.dynamiccolor.ColorSpec
import com.materialkolor.rememberDynamicColorScheme
import nl.jknaapen.fladder.objects.PlayerSettingsObject
@Composable
fun VideoPlayerTheme(
content: @Composable () -> Unit
) {
val colorScheme = rememberDynamicColorScheme(
seedColor = Color(0xFFFF9800),
val context = LocalContext.current
val themeColor by PlayerSettingsObject.themeColor.collectAsState(null)
val generatedScheme = rememberDynamicColorScheme(
seedColor = themeColor ?: Color(0xFFFF9800),
isDark = true,
specVersion = ColorSpec.SpecVersion.SPEC_2025,
style = PaletteStyle.Expressive,
)
MaterialTheme(
colorScheme = colorScheme,
) {
CompositionLocalProvider {
content()
val chosenScheme: ColorScheme =
if (themeColor == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(context)
} else {
generatedScheme
}
MaterialTheme(
colorScheme = chosenScheme,
) {
content()
}
}

View file

@ -47,7 +47,7 @@ fun VideoPlayerScreen(
) {
val leanBackEnabled = leanBackEnabled(LocalContext.current)
ExoPlayer { player ->
ScaledContent(if (leanBackEnabled) 0.50f else 1f) {
ScaledContent(if (leanBackEnabled) 0.75f else 1f) {
CustomVideoControls(player)
}
}

View file

@ -95,6 +95,7 @@ enum class SegmentSkip(val raw: Int) {
data class PlayerSettings (
val enableTunneling: Boolean,
val skipTypes: Map<SegmentType, SegmentSkip>,
val themeColor: Long? = null,
val skipForward: Long,
val skipBackward: Long
)
@ -103,15 +104,17 @@ data class PlayerSettings (
fun fromList(pigeonVar_list: List<Any?>): PlayerSettings {
val enableTunneling = pigeonVar_list[0] as Boolean
val skipTypes = pigeonVar_list[1] as Map<SegmentType, SegmentSkip>
val skipForward = pigeonVar_list[2] as Long
val skipBackward = pigeonVar_list[3] as Long
return PlayerSettings(enableTunneling, skipTypes, skipForward, skipBackward)
val themeColor = pigeonVar_list[2] as Long?
val skipForward = pigeonVar_list[3] as Long
val skipBackward = pigeonVar_list[4] as Long
return PlayerSettings(enableTunneling, skipTypes, themeColor, skipForward, skipBackward)
}
}
fun toList(): List<Any?> {
return listOf(
enableTunneling,
skipTypes,
themeColor,
skipForward,
skipBackward,
)

View file

@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
@ -57,7 +56,6 @@ internal fun CustomIconButton(
Box(
modifier = modifier
.wrapContentSize() // parent expands to fit children
.conditional(enableScaledFocus) {
scale(if (isFocused) 1.05f else 1f)
}
@ -69,11 +67,11 @@ internal fun CustomIconButton(
indication = null,
onClick = onClick
)
.alpha(if (enabled) 1f else 0.5f),
.alpha(if (enabled) 1f else 0.15f),
contentAlignment = Alignment.Center
) {
CompositionLocalProvider(LocalContentColor provides currentContentColor) {
Box(modifier = Modifier.padding(16.dp)) {
Box(modifier = Modifier.padding(8.dp)) {
icon()
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -23,6 +24,7 @@ fun ItemHeader(state: PlayableData?) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
contentAlignment = Alignment.CenterStart
) {

View file

@ -83,6 +83,8 @@ internal fun ProgressBar(
val position by VideoPlayerObject.position.collectAsState(0L)
val duration by VideoPlayerObject.duration.collectAsState(0L)
val endTimeString by VideoPlayerObject.endTime.collectAsState(null)
var tempPosition by remember { mutableLongStateOf(position) }
var scrubbingTimeLine by remember { mutableStateOf(false) }
@ -99,7 +101,7 @@ internal fun ProgressBar(
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.CenterVertically)
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.CenterVertically)
) {
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
if (scrubbingTimeLine)
@ -115,21 +117,25 @@ internal fun ProgressBar(
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
val subTitle = playableData?.subTitle
subTitle?.let {
val progressBarTopLabel = listOf(
playableData?.subTitle,
endTimeString,
)
val label = progressBarTopLabel.joinToString(separator = " - ")
if (label.isNotBlank()) {
Text(
text = it,
style = MaterialTheme.typography.titleLarge.copy(
text = label,
style = MaterialTheme.typography.bodyLarge.copy(
color = Color.White,
fontWeight = FontWeight.Bold
),
)
}
VideoEndTime()
}
Row(
horizontalArrangement = Arrangement.spacedBy(
16.dp,
12.dp,
alignment = Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
@ -138,7 +144,7 @@ internal fun ProgressBar(
Text(
formatTime(currentPosition),
color = Color.White,
style = MaterialTheme.typography.titleLarge.copy(
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
)
)
@ -163,7 +169,7 @@ internal fun ProgressBar(
)
),
color = Color.White,
style = MaterialTheme.typography.titleLarge.copy(
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
)
)
@ -212,7 +218,7 @@ internal fun RowScope.SimpleProgressBar(
width = it.size.width
}
)
.heightIn(min = 42.dp)
.heightIn(min = 32.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
onUserInteraction()
@ -253,7 +259,7 @@ internal fun RowScope.SimpleProgressBar(
modifier = Modifier
.focusable(enabled = false)
.fillMaxWidth()
.height(10.dp)
.height(9.dp)
.background(
color = Color.White.copy(
alpha = 0.15f
@ -307,10 +313,10 @@ internal fun RowScope.SimpleProgressBar(
.focusable(enabled = false)
.graphicsLayer {
translationX = startPx
translationY = 20.dp.toPx()
translationY = 13.dp.toPx()
}
.width(segDp)
.height(8.dp)
.height(7.dp)
.background(
color = segment.color.copy(alpha = 0.75f),
shape = RoundedCornerShape(8.dp)

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -20,22 +19,17 @@ import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
@ -61,8 +55,6 @@ internal fun BoxScope.SegmentSkipOverlay(
val player = VideoPlayerObject.implementation.player
val skipMap by PlayerSettingsObject.skipMap.collectAsState(mapOf())
var isFocused by remember { mutableStateOf(false) }
LaunchedEffect(segments, skipMap) { }
if (segments.isEmpty() || player == null) return
@ -91,7 +83,7 @@ internal fun BoxScope.SegmentSkipOverlay(
}
}
val shape = RoundedCornerShape(8.dp)
RoundedCornerShape(8.dp)
AnimatedVisibility(
activeSegment != null && skip == SegmentSkip.ASK,
@ -100,69 +92,53 @@ internal fun BoxScope.SegmentSkipOverlay(
.padding(16.dp)
.safeContentPadding()
) {
Box {
FilledTonalButton(
modifier = modifier
.align(alignment = Alignment.CenterEnd)
.focusRequester(focusRequester)
.onFocusChanged { state ->
isFocused = state.isFocused
}
.border(
width = 2.dp,
color = if (isFocused) Color.White.copy(alpha = 0.4f) else Color.Transparent,
shape = shape,
)
.defaultSelected(true),
contentPadding = PaddingValues(horizontal = 12.dp),
shape = shape,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = Color.White.copy(alpha = 0.75f),
contentColor = Color.Black,
),
onClick = {
activeSegment?.let {
player.seekTo(it.end)
}
CustomIconButton(
modifier = modifier
.align(alignment = Alignment.CenterEnd)
.focusRequester(focusRequester)
.defaultSelected(true),
onClick = {
activeSegment?.let {
player.seekTo(it.end)
}
}
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isAndroidTV) {
if (isAndroidTV) {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = Color.Black.copy(alpha = 0.15f),
shape = CircleShape,
)
.border(
width = 1.5.dp,
color = Color.Black.copy(alpha = 0.15f),
shape = CircleShape,
)
) {
Box(
modifier = Modifier
.size(24.dp)
.padding(7.dp)
.fillMaxSize()
.background(
color = Color.Black.copy(alpha = 0.15f),
shape = CircleShape,
)
.border(
width = 1.5.dp,
color = Color.Black.copy(alpha = 0.15f),
color = Color.White,
shape = CircleShape,
)
) {
Box(
modifier = Modifier
.padding(7.dp)
.fillMaxSize()
.background(
color = Color.White,
shape = CircleShape,
)
) {
}
}
}
activeSegment?.let {
Text(
"Skip ${it.name.lowercase()}",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
)
}
}
activeSegment?.let {
Text(
"Skip ${it.name.lowercase()}",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
)
}
}
}

View file

@ -1,48 +0,0 @@
package nl.jknaapen.fladder.composables.controls
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import nl.jknaapen.fladder.objects.VideoPlayerObject
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalTime::class)
@Composable
fun VideoEndTime() {
val startInstant = remember { Clock.System.now() }
val durationMs by VideoPlayerObject.duration.collectAsState(initial = 0L)
val zone = ZoneId.systemDefault()
val javaInstant = remember(startInstant) { startInstant.toJavaInstant() }
val endJavaInstant = remember(javaInstant, durationMs) {
javaInstant.plusMillis(durationMs)
}
val endZoned = remember(endJavaInstant, zone) {
endJavaInstant.atZone(zone)
}
val formatter = DateTimeFormatter.ofPattern("hh:mm a")
val formattedEnd = remember(endZoned, formatter) {
endZoned.format(formatter)
}
Text(
text = "ends at $formattedEnd",
style = MaterialTheme.typography.titleLarge.copy(
color = Color.White,
fontWeight = FontWeight.Bold
),
)
}

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -97,24 +98,31 @@ fun CustomVideoControls(
val buffering by VideoPlayerObject.buffering.collectAsState(true)
val playing by VideoPlayerObject.playing.collectAsState(false)
val controlsPadding = 32.dp
val controlsPadding = 16.dp
ImmersiveSystemBars(isImmersive = !showControls)
val bottomControlFocusRequester = remember { FocusRequester() }
fun hideControls() {
showControls = false
bottomControlFocusRequester.requestFocus()
}
BackHandler(
enabled = showControls
) {
showControls = false
hideControls()
}
// Restart the hide timer whenever `lastInteraction` changes.
LaunchedEffect(lastInteraction.longValue) {
delay(5.seconds)
showControls = false
hideControls()
}
val bottomControlFocusRequester = remember { FocusRequester() }
fun updateLastInteraction() {
showControls = true
lastInteraction.longValue = System.currentTimeMillis()
@ -167,7 +175,7 @@ fun CustomVideoControls(
.background(
brush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0.85f),
Color.Black.copy(alpha = 0.90f),
Color.Black.copy(alpha = 0f),
),
start = Offset(0f, 0f),
@ -175,10 +183,11 @@ fun CustomVideoControls(
),
)
.safeContentPadding(),
horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Start)
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start)
) {
Column(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f),
) {
val state by VideoPlayerObject.implementation.playbackData.collectAsState(
null
@ -196,7 +205,7 @@ fun CustomVideoControls(
Icon(
Iconsax.Outline.CloseSquare,
modifier = Modifier
.size(48.dp)
.size(38.dp)
.focusable(false),
contentDescription = "Close icon",
tint = Color.White,
@ -209,40 +218,39 @@ fun CustomVideoControls(
Column(
modifier = Modifier
.padding(horizontal = controlsPadding)
.displayCutoutPadding(),
) {
ProgressBar(
modifier = Modifier,
exoPlayer, bottomControlFocusRequester, ::updateLastInteraction
)
}
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0f),
Color.Black.copy(alpha = 0.85f),
Color.Black.copy(alpha = 0.9f),
),
start = Offset(0f, 0f),
end = Offset(0f, Float.POSITIVE_INFINITY)
),
)
.displayCutoutPadding()
.padding(horizontal = controlsPadding)
.padding(top = 8.dp, bottom = controlsPadding)
) {
LeftButtons(
openChapterSelection = {
showChapterDialog = true
}
ProgressBar(
modifier = Modifier,
exoPlayer, bottomControlFocusRequester, ::updateLastInteraction
)
PlaybackButtons(exoPlayer, bottomControlFocusRequester)
RightButtons(showAudioDialog, showSubDialog)
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
LeftButtons(
openChapterSelection = {
showChapterDialog = true
}
)
PlaybackButtons(exoPlayer, bottomControlFocusRequester)
RightButtons(showAudioDialog, showSubDialog)
}
}
}
}
SegmentSkipOverlay()
@ -252,7 +260,7 @@ fun CustomVideoControls(
.align(alignment = Alignment.Center)
.size(64.dp),
strokeCap = StrokeCap.Round,
strokeWidth = 12.dp
strokeWidth = 10.dp
)
}
}
@ -310,7 +318,7 @@ fun PlaybackButtons(
.padding(horizontal = 4.dp, vertical = 6.dp)
.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(
16.dp,
12.dp,
alignment = Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
@ -321,7 +329,6 @@ fun PlaybackButtons(
) {
Icon(
Iconsax.Filled.Backward,
modifier = Modifier.size(32.dp),
contentDescription = previousVideo,
)
}
@ -339,11 +346,13 @@ fun PlaybackButtons(
) {
Icon(
Iconsax.Outline.Refresh,
modifier = Modifier.size(38.dp),
contentDescription = "Forward",
modifier = Modifier
.size(48.dp),
)
Text("-${backwardSpeed.inWholeSeconds}")
Text(
"-${backwardSpeed.inWholeSeconds}",
style = MaterialTheme.typography.bodySmall
)
}
}
CustomIconButton(
@ -357,7 +366,7 @@ fun PlaybackButtons(
) {
Icon(
if (isPlaying) Iconsax.Filled.Pause else Iconsax.Filled.Play,
modifier = Modifier.size(55.dp),
modifier = Modifier.size(42.dp),
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
@ -377,10 +386,13 @@ fun PlaybackButtons(
Iconsax.Outline.Refresh,
contentDescription = "Forward",
modifier = Modifier
.size(48.dp)
.size(38.dp)
.scale(scaleX = -1f, scaleY = 1f),
)
Text(forwardSpeed.inWholeSeconds.toString())
Text(
forwardSpeed.inWholeSeconds.toString(),
style = MaterialTheme.typography.bodySmall
)
}
}
@ -390,7 +402,6 @@ fun PlaybackButtons(
) {
Icon(
Iconsax.Filled.Forward,
modifier = Modifier.size(32.dp),
contentDescription = nextVideo,
)
}
@ -405,7 +416,7 @@ internal fun RowScope.LeftButtons(
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start)
) {
CustomIconButton(
onClick = openChapterSelection,
@ -413,7 +424,6 @@ internal fun RowScope.LeftButtons(
) {
Icon(
Iconsax.Filled.Check,
modifier = Modifier.size(32.dp),
contentDescription = "Show chapters",
)
}
@ -427,7 +437,7 @@ internal fun RowScope.RightButtons(
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.End)
) {
CustomIconButton(
onClick = {
@ -436,7 +446,6 @@ internal fun RowScope.RightButtons(
) {
Icon(
Iconsax.Filled.AudioSquare,
modifier = Modifier.size(32.dp),
contentDescription = "Audio Track",
)
}
@ -447,7 +456,6 @@ internal fun RowScope.RightButtons(
) {
Icon(
Iconsax.Filled.Subtitle,
modifier = Modifier.size(32.dp),
contentDescription = "Subtitles",
)
}

View file

@ -1,7 +1,6 @@
package nl.jknaapen.fladder.composables.dialogs
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -48,7 +47,6 @@ fun AudioPicker(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
item {
val selectedOff = -1 == selectedIndex

View file

@ -6,12 +6,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
@ -31,6 +32,10 @@ internal fun CustomModalBottomSheet(
skipPartiallyExpanded = true
)
LaunchedEffect(Unit) {
modalBottomSheetState.expand()
}
ModalBottomSheet(
onDismissRequest,
dragHandle = null,
@ -43,13 +48,13 @@ internal fun CustomModalBottomSheet(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.systemBarsPadding()
.displayCutoutPadding()
.background(
shape = RoundedCornerShape(16.dp),
brush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0f),
Color.Black.copy(alpha = 0.8f),
Color.Black.copy(alpha = 0.65f),
Color.Black.copy(alpha = 0.85f),
),
start = Offset(0f, 0f),
end = Offset(0f, Float.POSITIVE_INFINITY)

View file

@ -1,10 +1,9 @@
package nl.jknaapen.fladder.composables.dialogs
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
@ -12,7 +11,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
@ -48,9 +46,8 @@ fun SubtitlePicker(
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth()
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
item {
val selectedOff = -1 == selectedIndex
@ -64,17 +61,9 @@ fun SubtitlePicker(
},
selected = selectedOff
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterVertically
)
) {
Text(
text = "Off",
)
}
Text(
text = "Off",
)
}
}
internalSubTracks.forEachIndexed { index, subtitle ->
@ -93,17 +82,9 @@ fun SubtitlePicker(
},
selected = selected,
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterVertically
)
) {
Text(
text = serverSub?.name ?: "",
)
}
Text(
text = serverSub?.name ?: "",
)
}
}
}

View file

@ -1,20 +1,23 @@
package nl.jknaapen.fladder.composables.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.filled.TickSquare
import nl.jknaapen.fladder.composables.controls.CustomIconButton
import nl.jknaapen.fladder.utility.defaultSelected
@Composable
internal fun TrackButton(
@ -23,30 +26,30 @@ internal fun TrackButton(
selected: Boolean = false,
content: @Composable () -> Unit,
) {
val backgroundColor = if (selected) Color.White else Color.Black
val textColor = if (selected) Color.Black else Color.White
val textStyle =
MaterialTheme.typography.bodyLarge.copy(color = textColor, fontWeight = FontWeight.Bold)
MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)
val interactionSource = remember { MutableInteractionSource() }
TextButton(
CustomIconButton(
backgroundColor = Color.White.copy(alpha = 0.25f),
modifier = modifier
.background(
color = backgroundColor.copy(alpha = 0.65f),
shape = RoundedCornerShape(12.dp),
)
.padding(12.dp)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
indication = null,
),
.padding(vertical = 6.dp, horizontal = 12.dp)
.defaultMinSize(minHeight = 40.dp)
.defaultSelected(selected),
onClick = onClick,
interactionSource = interactionSource,
) {
CompositionLocalProvider(LocalTextStyle provides textStyle) {
content()
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selected) {
Icon(
imageVector = Iconsax.Filled.TickSquare,
contentDescription = "",
)
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
content()
}
}
}
}

View file

@ -2,6 +2,7 @@ package nl.jknaapen.fladder.objects
import PlayerSettings
import PlayerSettingsPigeon
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlin.time.DurationUnit
@ -21,6 +22,12 @@ object PlayerSettingsObject : PlayerSettingsPigeon {
)
}
val themeColor = settings.map { settings ->
settings?.themeColor.let {
if (it == null) null else Color(it)
}
}
override fun sendPlayerSettings(playerSettings: PlayerSettings) {
settings.value = playerSettings
}

View file

@ -3,12 +3,20 @@ package nl.jknaapen.fladder.objects
import PlaybackState
import VideoPlayerControlsCallback
import VideoPlayerListenerCallback
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import nl.jknaapen.fladder.VideoPlayerActivity
import nl.jknaapen.fladder.messengers.VideoPlayerImplementation
import nl.jknaapen.fladder.utility.InternalTrack
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant
object VideoPlayerObject {
val implementation: VideoPlayerImplementation = VideoPlayerImplementation()
@ -23,6 +31,20 @@ object VideoPlayerObject {
val chapters = implementation.playbackData.map { it?.chapters }
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalTime::class)
val endTime = combine(position, duration) { pos, dur ->
val startInstant = Clock.System.now()
val zone = ZoneId.systemDefault()
val remainingMs = (dur - pos).coerceAtLeast(0L)
val endInstant = startInstant.toJavaInstant().plusMillis(remainingMs)
val endZoned = endInstant.atZone(zone)
val formatter = DateTimeFormatter.ofPattern("hh:mm a")
"ends at ${endZoned.format(formatter)}"
}
val currentSubtitleTrackIndex =
MutableStateFlow((implementation.playbackData.value?.defaultSubtrack ?: -1).toInt())
val currentAudioTrackIndex =

View file

@ -98,7 +98,7 @@ void main(List<String> args) async {
applicationInfoProvider.overrideWith((ref) => applicationInfo),
crashLogProvider.overrideWith((ref) => crashProvider),
argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args, leanBackEnabled)),
syncProvider.overrideWith((ref) => SyncNotifier(ref, applicationDirectory))
syncProvider.overrideWith((ref) => SyncNotifier(ref, applicationDirectory)),
],
child: AdaptiveLayoutBuilder(
child: (context) => const Main(),

View file

@ -0,0 +1,57 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/src/player_settings_helper.g.dart' as pigeon;
final pigeonPlayerSettingsSyncProvider = Provider<void>((ref) {
void sendSettings() {
final userData = ref.read(userProvider);
final color = ref.read(
clientSettingsProvider.select(
(value) => value.themeColor?.color.toARGB32(),
),
);
final value = ref.read(videoPlayerSettingsProvider);
if (!kIsWeb && Platform.isAndroid) {
pigeon.PlayerSettingsPigeon().sendPlayerSettings(
pigeon.PlayerSettings(
enableTunneling: value.enableTunneling,
skipTypes: value.segmentSkipSettings.map(
(key, value) => MapEntry(
switch (key) {
MediaSegmentType.unknown => pigeon.SegmentType.intro,
MediaSegmentType.commercial => pigeon.SegmentType.commercial,
MediaSegmentType.preview => pigeon.SegmentType.preview,
MediaSegmentType.recap => pigeon.SegmentType.recap,
MediaSegmentType.outro => pigeon.SegmentType.outro,
MediaSegmentType.intro => pigeon.SegmentType.intro,
},
switch (value) {
SegmentSkip.none => pigeon.SegmentSkip.none,
SegmentSkip.askToSkip => pigeon.SegmentSkip.ask,
SegmentSkip.skip => pigeon.SegmentSkip.skip,
},
),
),
themeColor: color,
skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds,
skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds,
),
);
}
}
ref.listen(userProvider, (_, __) => sendSettings());
ref.listen(clientSettingsProvider, (_, __) => sendSettings());
ref.listen(videoPlayerSettingsProvider, (_, __) => sendSettings());
sendSettings();
});

View file

@ -1,6 +1,3 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -8,13 +5,10 @@ import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/settings/key_combinations.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/src/player_settings_helper.g.dart' as pigeon;
final videoPlayerSettingsProvider =
StateNotifierProvider<VideoPlayerSettingsProviderNotifier, VideoPlayerSettingsModel>((ref) {
@ -33,34 +27,6 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
final oldState = super.state;
super.state = value;
ref.read(sharedUtilityProvider).videoPlayerSettings = value;
if (!kIsWeb && Platform.isAndroid) {
final userData = ref.read(userProvider);
pigeon.PlayerSettingsPigeon().sendPlayerSettings(
pigeon.PlayerSettings(
enableTunneling: value.enableTunneling,
skipTypes: value.segmentSkipSettings.map(
(key, value) => MapEntry(
switch (key) {
MediaSegmentType.unknown => pigeon.SegmentType.intro,
MediaSegmentType.commercial => pigeon.SegmentType.commercial,
MediaSegmentType.preview => pigeon.SegmentType.preview,
MediaSegmentType.recap => pigeon.SegmentType.recap,
MediaSegmentType.outro => pigeon.SegmentType.outro,
MediaSegmentType.intro => pigeon.SegmentType.intro,
},
switch (value) {
SegmentSkip.none => pigeon.SegmentSkip.none,
SegmentSkip.askToSkip => pigeon.SegmentSkip.ask,
SegmentSkip.skip => pigeon.SegmentSkip.skip,
},
),
),
skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds,
skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds,
),
);
}
if (!oldState.playerSame(value)) {
ref.read(videoPlayerProvider.notifier).init();
}

View file

@ -15,6 +15,7 @@ import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart';
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
import 'package:fladder/providers/settings/pigeon_player_settings_provider.dart';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
@ -25,6 +26,9 @@ final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
final sharedUtilityProvider = Provider<SharedUtility>((ref) {
final sharedPrefs = ref.watch(sharedPreferencesProvider);
//Init pigeon settings sync for native
ref.read(pigeonPlayerSettingsSyncProvider);
return SharedUtility(ref: ref, sharedPreferences: sharedPrefs);
});

View file

@ -24,15 +24,12 @@ List<Widget> buildClientSettingsTheme(BuildContext context, WidgetRef ref) {
items: ThemeMode.values,
selected: [ref.read(clientSettingsProvider.select((value) => value.themeMode))],
onChanged: (values) => ref.read(clientSettingsProvider.notifier).setThemeMode(values.first),
itemBuilder: (type, selected, tap) => RadioGroup(
groupValue: ref.read(clientSettingsProvider.select((value) => value.themeMode)),
itemBuilder: (type, selected, tap) => CheckboxListTile(
value: selected,
onChanged: (value) => tap(),
child: RadioListTile(
value: type,
title: Text(type.label(context)),
contentPadding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
title: Text(type.label(context)),
contentPadding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
@ -41,43 +38,40 @@ List<Widget> buildClientSettingsTheme(BuildContext context, WidgetRef ref) {
subLabel: Text(clientSettings.themeColor?.name ?? context.localized.dynamicText),
onTap: () => openMultiSelectOptions<ColorThemes?>(
context,
label: context.localized.settingsLayoutModesTitle,
label: context.localized.color,
items: [null, ...ColorThemes.values],
selected: [(ref.read(clientSettingsProvider.select((value) => value.themeColor)))],
onChanged: (values) => ref.read(clientSettingsProvider.notifier).setThemeColor(values.first),
itemBuilder: (type, selected, tap) => RadioGroup(
itemBuilder: (type, selected, tap) => CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: selected,
onChanged: (value) => tap(),
groupValue: ref.read(clientSettingsProvider.select((value) => value.themeColor)),
child: RadioListTile<ColorThemes?>(
contentPadding: EdgeInsets.zero,
value: type,
title: Row(
children: [
Container(
height: 24,
width: 24,
decoration: BoxDecoration(
gradient: type == null
? const SweepGradient(
center: FractionalOffset.center,
colors: <Color>[
Color(0xFF4285F4), // blue
Color(0xFF34A853), // green
Color(0xFFFBBC05), // yellow
Color(0xFFEA4335), // red
Color(0xFF4285F4), // blue again to seamlessly transition to the start
],
stops: <double>[0.0, 0.25, 0.5, 0.75, 1.0],
)
: null,
color: type?.color,
borderRadius: BorderRadius.circular(4),
),
title: Row(
children: [
Container(
height: 24,
width: 24,
decoration: BoxDecoration(
gradient: type == null
? const SweepGradient(
center: FractionalOffset.center,
colors: <Color>[
Color(0xFF4285F4), // blue
Color(0xFF34A853), // green
Color(0xFFFBBC05), // yellow
Color(0xFFEA4335), // red
Color(0xFF4285F4), // blue again to seamlessly transition to the start
],
stops: <double>[0.0, 0.25, 0.5, 0.75, 1.0],
)
: null,
color: type?.color,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(width: 8),
Text(type?.name ?? context.localized.dynamicText),
],
),
),
const SizedBox(width: 8),
Text(type?.name ?? context.localized.dynamicText),
],
),
),
),
@ -88,18 +82,15 @@ List<Widget> buildClientSettingsTheme(BuildContext context, WidgetRef ref) {
onTap: () async {
await openMultiSelectOptions<DynamicSchemeVariant>(
context,
label: context.localized.settingsLayoutModesTitle,
label: context.localized.clientSettingsSchemeVariantTitle,
items: DynamicSchemeVariant.values,
selected: [(ref.read(clientSettingsProvider.select((value) => value.schemeVariant)))],
onChanged: (values) => ref.read(clientSettingsProvider.notifier).setSchemeVariant(values.first),
itemBuilder: (type, selected, tap) => RadioGroup(
itemBuilder: (type, selected, tap) => CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: selected,
onChanged: (value) => tap(),
groupValue: selected ? type : null,
child: RadioListTile<DynamicSchemeVariant>(
contentPadding: EdgeInsets.zero,
value: type,
title: Text(type.label(context)),
),
title: Text(type.label(context)),
),
);
},

View file

@ -47,6 +47,7 @@ class PlayerSettings {
PlayerSettings({
required this.enableTunneling,
required this.skipTypes,
this.themeColor,
required this.skipForward,
required this.skipBackward,
});
@ -55,6 +56,8 @@ class PlayerSettings {
Map<SegmentType, SegmentSkip> skipTypes;
int? themeColor;
int skipForward;
int skipBackward;
@ -63,6 +66,7 @@ class PlayerSettings {
return <Object?>[
enableTunneling,
skipTypes,
themeColor,
skipForward,
skipBackward,
];
@ -76,8 +80,9 @@ class PlayerSettings {
return PlayerSettings(
enableTunneling: result[0]! as bool,
skipTypes: (result[1] as Map<Object?, Object?>?)!.cast<SegmentType, SegmentSkip>(),
skipForward: result[2]! as int,
skipBackward: result[3]! as int,
themeColor: result[2] as int?,
skipForward: result[3]! as int,
skipBackward: result[4]! as int,
);
}

View file

@ -19,7 +19,7 @@ Future<List<T>> openMultiSelectOptions<T>(
builder: (context, setState) => AlertDialog(
title: Text(label),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
width: MediaQuery.of(context).size.width * 0.85,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
shrinkWrap: true,

View file

@ -14,12 +14,15 @@ import 'package:pigeon/pigeon.dart';
class PlayerSettings {
final bool enableTunneling;
final Map<SegmentType, SegmentSkip> skipTypes;
//Color in ARGB32 format
final int? themeColor;
final int skipForward;
final int skipBackward;
const PlayerSettings({
required this.enableTunneling,
required this.skipTypes,
required this.themeColor,
required this.skipForward,
required this.skipBackward,
});