fix: More native player UI fixes

This commit is contained in:
PartyDonut 2025-10-14 18:25:54 +02:00
parent edbd8d467c
commit cfd4b4a5cc
12 changed files with 217 additions and 55 deletions

View file

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

View file

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

View file

@ -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()) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
subTitle: value.label(context),
image: value.getPosters,
logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
? Alignment.center ? Alignment.center
: Alignment.centerLeft, : Alignment.centerLeft,
summary: selectedPoster.overview.summary, summary: value.overview.summary,
productionYear: selectedPoster.overview.productionYear, productionYear: value.overview.productionYear,
runTime: selectedPoster.overview.runTime, runTime: value.overview.runTime,
genres: selectedPoster.overview.genreItems, genres: value.overview.genreItems,
studios: selectedPoster.overview.studios, studios: value.overview.studios,
officialRating: selectedPoster.overview.parentalRating, officialRating: value.overview.parentalRating,
communityRating: selectedPoster.overview.communityRating, 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);
}, },
), ),