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)
ExoPlayer { player ->
ScaledContent(if (leanBackEnabled) 0.75f else 1f) {
ScaledContent(if (leanBackEnabled) 0.7f else 1f) {
CustomVideoControls(player)
}
}

View file

@ -71,7 +71,7 @@ internal fun CustomIconButton(
contentAlignment = Alignment.Center
) {
CompositionLocalProvider(LocalContentColor provides currentContentColor) {
Box(modifier = Modifier.padding(8.dp)) {
Box(modifier = Modifier.padding(12.dp)) {
icon()
}
}

View file

@ -4,7 +4,6 @@ import PlayableData
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
@ -13,19 +12,20 @@ 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 coil3.compose.AsyncImage
@Composable
fun ItemHeader(state: PlayableData?) {
fun ItemHeader(
modifier: Modifier = Modifier,
state: PlayableData?
) {
val title = state?.title
val logoUrl = state?.logoUrl
Box(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
.statusBarsPadding(),
contentAlignment = Alignment.CenterStart
) {
if (!logoUrl.isNullOrBlank()) {

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
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.derivedStateOf
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.Escape
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.key
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.util.fastCoerceIn
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.formatTime
import kotlin.math.max
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@ -259,7 +261,7 @@ internal fun RowScope.SimpleProgressBar(
modifier = Modifier
.focusable(enabled = false)
.fillMaxWidth()
.height(9.dp)
.height(8.dp)
.background(
color = Color.White.copy(
alpha = 0.15f
@ -313,10 +315,10 @@ internal fun RowScope.SimpleProgressBar(
.focusable(enabled = false)
.graphicsLayer {
translationX = startPx
translationY = 13.dp.toPx()
translationY = 14.dp.toPx()
}
.width(segDp)
.height(7.dp)
.height(6.dp)
.background(
color = segment.color.copy(alpha = 0.75f),
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
Box(
modifier = Modifier
@ -379,7 +405,7 @@ internal fun RowScope.SimpleProgressBar(
}
}
.focusable(enabled = true)
.onKeyEvent { keyEvent: KeyEvent ->
.onKeyEvent { keyEvent ->
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
onUserInteraction()
@ -392,25 +418,42 @@ internal fun RowScope.SimpleProgressBar(
}
DirectionLeft -> {
if (direction != -1) {
direction = -1
speed = 1L
} else {
speed++
}
if (!scrubbingTimeLine) {
onTempPosChanged(position)
onScrubbingChanged(true)
player.pause()
}
val newPos = max(0L, tempPosition - 3000L)
val newPos = max(
0L,
tempPosition - scrubSpeedResult()
)
onTempPosChanged(newPos)
updateLastInteraction()
true
}
DirectionRight -> {
if (direction != 1) {
direction = 1
speed = 1L
} else {
speed++
}
if (!scrubbingTimeLine) {
onTempPosChanged(position)
onScrubbingChanged(true)
player.pause()
}
val newPos = min(player.duration.takeIf { it > 0 } ?: 1L,
tempPosition + 3000L)
tempPosition + scrubSpeedResult())
onTempPosChanged(newPos)
updateLastInteraction()
true
}
@ -448,6 +491,7 @@ internal fun RowScope.SimpleProgressBar(
}
}
val MediaSegment.color: Color
get() = when (this.type) {
MediaSegmentType.COMMERCIAL -> Color.Magenta

View file

@ -88,15 +88,16 @@ internal fun BoxScope.SegmentSkipOverlay(
AnimatedVisibility(
activeSegment != null && skip == SegmentSkip.ASK,
modifier = Modifier
.fillMaxSize()
.align(alignment = Alignment.CenterEnd)
.padding(16.dp)
.safeContentPadding()
) {
CustomIconButton(
modifier = modifier
.align(alignment = Alignment.CenterEnd)
.focusRequester(focusRequester)
.defaultSelected(true),
backgroundColor = Color.Black.copy(alpha = 0.5f),
enableScaledFocus = true,
onClick = {
activeSegment?.let {
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.Color
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.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
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.leanBackEnabled
import nl.jknaapen.fladder.utility.visible
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.seconds
@ -98,7 +102,7 @@ fun CustomVideoControls(
val buffering by VideoPlayerObject.buffering.collectAsState(true)
val playing by VideoPlayerObject.playing.collectAsState(false)
val controlsPadding = 16.dp
val controlsPadding = 32.dp
ImmersiveSystemBars(isImmersive = !showControls)
@ -128,6 +132,26 @@ fun CustomVideoControls(
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(
modifier = Modifier
.fillMaxSize()
@ -139,7 +163,27 @@ fun CustomVideoControls(
}
.onKeyEvent { keyEvent: KeyEvent ->
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
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()
updateLastInteraction()
return@onKeyEvent true
@ -193,7 +237,10 @@ fun CustomVideoControls(
null
)
state?.let {
ItemHeader(it)
ItemHeader(
modifier = Modifier.padding(controlsPadding),
it
)
}
}
if (!leanBackEnabled(LocalContext.current)) {
@ -217,7 +264,6 @@ fun CustomVideoControls(
// Progress Bar
Column(
modifier = Modifier
.padding(horizontal = controlsPadding)
.background(
brush = Brush.linearGradient(
colors = listOf(
@ -228,6 +274,7 @@ fun CustomVideoControls(
end = Offset(0f, Float.POSITIVE_INFINITY)
),
)
.padding(horizontal = controlsPadding)
.displayCutoutPadding()
.padding(top = 8.dp, bottom = controlsPadding)
) {
@ -250,10 +297,10 @@ fun CustomVideoControls(
RightButtons(showAudioDialog, showSubDialog)
}
}
}
}
SegmentSkipOverlay()
SeekOverlay(value = currentSkipTime)
if (buffering && !playing) {
CircularProgressIndicator(
modifier = Modifier

View file

@ -10,13 +10,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.clearAudioTrack
import nl.jknaapen.fladder.utility.defaultSelected
import nl.jknaapen.fladder.utility.conditional
import nl.jknaapen.fladder.utility.setInternalAudioTrack
@OptIn(UnstableApi::class)
@ -29,6 +32,8 @@ fun AudioPicker(
val audioTracks by VideoPlayerObject.audioTracks.collectAsState(listOf())
val internalAudioTracks by VideoPlayerObject.exoAudioTracks
val focusRequester = remember { FocusRequester() }
val listState = rememberLazyListState()
LaunchedEffect(selectedIndex) {
@ -53,7 +58,9 @@ fun AudioPicker(
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(selectedOff),
.conditional(selectedOff) {
focusRequester(focusRequester)
},
onClick = {
VideoPlayerObject.setAudioTrackIndex(-1)
player.clearAudioTrack()
@ -72,7 +79,9 @@ fun AudioPicker(
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(selected),
.conditional(selected) {
focusRequester(focusRequester)
},
onClick = {
serverTrack?.index?.let {
VideoPlayerObject.setAudioTrackIndex(it.toInt())

View file

@ -27,12 +27,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.defaultSelected
import nl.jknaapen.fladder.utility.conditional
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -44,6 +46,8 @@ internal fun ChapterSelectionSheet(
val chapters = playbackData?.chapters ?: listOf()
val currentPosition by VideoPlayerObject.position.collectAsState(0L)
val focusRequester = remember { FocusRequester() }
if (chapters.isEmpty()) return
var currentChapter: Chapter? by remember {
@ -56,10 +60,13 @@ internal fun ChapterSelectionSheet(
val lazyListState = rememberLazyListState()
LaunchedEffect(chapters) {
LaunchedEffect(chapters, currentPosition) {
val chapter = chapters.indexOfCurrent(currentPosition)
lazyListState.animateScrollToItem(
chapters.indexOfCurrent(currentPosition)
chapter
)
currentChapter = chapters[chapter]
focusRequester.requestFocus()
}
CustomModalBottomSheet(
@ -68,7 +75,7 @@ internal fun ChapterSelectionSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(horizontal = 16.dp, vertical = 16.dp)
.wrapContentHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@ -88,15 +95,19 @@ internal fun ChapterSelectionSheet(
Column(
modifier = Modifier
.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)
)
.border(
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)
)
.defaultSelected(index == 0)
.conditional(selectedChapter) {
focusRequester(focusRequester)
}
.onFocusChanged {
if (it.isFocused) {
currentChapter = chapter

View file

@ -11,13 +11,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.clearSubtitleTrack
import nl.jknaapen.fladder.utility.defaultSelected
import nl.jknaapen.fladder.utility.conditional
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
@OptIn(UnstableApi::class)
@ -30,13 +33,16 @@ fun SubtitlePicker(
val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(listOf())
val internalSubTracks by VideoPlayerObject.exoSubTracks
val focusRequester = remember { FocusRequester() }
val listState = rememberLazyListState()
LaunchedEffect(selectedIndex) {
LaunchedEffect(selectedIndex, subTitles) {
if (selectedIndex == -1) return@LaunchedEffect
listState.scrollToItem(
subTitles.indexOfFirst { it.index == selectedIndex.toLong() }
)
focusRequester.requestFocus()
}
CustomModalBottomSheet(
@ -54,7 +60,9 @@ fun SubtitlePicker(
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(selectedOff),
.conditional(selectedOff) {
focusRequester(focusRequester)
},
onClick = {
VideoPlayerObject.setSubtitleTrackIndex(-1)
player.clearSubtitleTrack()
@ -73,7 +81,9 @@ fun SubtitlePicker(
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(selected),
.conditional(selected) {
focusRequester(focusRequester)
},
onClick = {
serverSub?.index?.let {
VideoPlayerObject.setSubtitleTrackIndex(it.toInt())

View file

@ -11,9 +11,11 @@ fun ScaledContent(
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val fontScale = 1f / scale
CompositionLocalProvider(
LocalDensity provides Density(
density = density.density * scale,
fontScale = fontScale
)
) {
content()

View file

@ -1,11 +1,17 @@
import 'dart:developer';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
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/update_provider.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/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
@ -87,6 +93,8 @@ class UpdateInformation extends StatelessWidget {
@override
Widget build(BuildContext context) {
final apkDownload =
releaseInfo.preferredDownloads.entries.where((entry) => entry.value.toLowerCase().endsWith('.apk')).firstOrNull;
return ExpansionTile(
backgroundColor:
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(
(entry) {
return FilledButton(

View file

@ -26,7 +26,7 @@ class DetailedBanner extends ConsumerStatefulWidget {
}
class _DetailedBannerState extends ConsumerState<DetailedBanner> {
late ItemBaseModel selectedPoster = widget.posters.first;
late ValueNotifier<ItemBaseModel> selectedPoster = ValueNotifier(widget.posters.first);
@override
Widget build(BuildContext context) {
@ -45,8 +45,11 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
child: AspectRatio(
aspectRatio: 1.8,
child: CustomShaderMask(
child: FladderImage(
image: selectedPoster.images?.primary,
child: ValueListenableBuilder(
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),
child: FractionallySizedBox(
widthFactor: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? 1.0 : 0.55,
child: OverviewHeader(
name: selectedPoster.parentBaseModel.name,
subTitle: selectedPoster.label(context),
image: selectedPoster.getPosters,
logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
? Alignment.center
: Alignment.centerLeft,
summary: selectedPoster.overview.summary,
productionYear: selectedPoster.overview.productionYear,
runTime: selectedPoster.overview.runTime,
genres: selectedPoster.overview.genreItems,
studios: selectedPoster.overview.studios,
officialRating: selectedPoster.overview.parentalRating,
communityRating: selectedPoster.overview.communityRating,
child: ValueListenableBuilder(
valueListenable: selectedPoster,
builder: (context, value, child) => OverviewHeader(
name: value.parentBaseModel.name,
subTitle: value.label(context),
image: value.getPosters,
logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
? Alignment.center
: Alignment.centerLeft,
summary: value.overview.summary,
productionYear: value.overview.productionYear,
runTime: value.overview.runTime,
genres: value.overview.genreItems,
studios: value.overview.studios,
officialRating: value.overview.parentalRating,
communityRating: value.overview.communityRating,
),
),
),
),
@ -97,9 +103,7 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
context.ensureVisible(
alignment: 1.0,
);
setState(() {
selectedPoster = poster;
});
selectedPoster.value = poster;
widget.onSelect(poster);
},
),