fix: Lots of small adjustments and changes to native player

This commit is contained in:
PartyDonut 2025-10-16 15:11:52 +02:00
parent 360b87dacb
commit b4e68c9e15
14 changed files with 197 additions and 156 deletions

View file

@ -62,6 +62,7 @@
android:hardwareAccelerated="true"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTop"
android:exported="true" />
<service

View file

@ -61,7 +61,11 @@ class MainActivity : AudioServiceFragmentActivity(), NativeVideoActivity {
override fun launchActivity(callback: (Result<StartResult>) -> Unit) {
try {
videoPlayerCallback = callback
val intent = Intent(this, VideoPlayerActivity::class.java)
val intent = Intent(this, VideoPlayerActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
videoPlayerLauncher.launch(intent)
} catch (e: Exception) {
e.printStackTrace()

View file

@ -38,6 +38,11 @@ class VideoPlayerActivity : ComponentActivity() {
}
}
}
override fun onPause() {
super.onPause()
VideoPlayerObject.implementation.pause()
}
}
@RequiresApi(Build.VERSION_CODES.O)

View file

@ -79,7 +79,6 @@ 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
@ -143,7 +142,8 @@ fun CustomVideoControls(
// Restart the multiplier
LaunchedEffect(lastSeekInteraction.longValue) {
delay(2.seconds)
delay(1.seconds)
if (currentSkipTime == 0L) return@LaunchedEffect
player?.seekTo(position + currentSkipTime)
currentSkipTime = 0L
}
@ -167,18 +167,12 @@ fun CustomVideoControls(
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

View file

@ -19,7 +19,6 @@ 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.conditional
import nl.jknaapen.fladder.utility.setInternalAudioTrack
@OptIn(UnstableApi::class)
@ -32,15 +31,31 @@ fun AudioPicker(
val audioTracks by VideoPlayerObject.audioTracks.collectAsState(listOf())
val internalAudioTracks by VideoPlayerObject.exoAudioTracks
val focusRequester = remember { FocusRequester() }
val focusOffTrack = remember { FocusRequester() }
val focusRequesters = remember(internalAudioTracks) {
internalAudioTracks.associateWith { FocusRequester() }
}
val listState = rememberLazyListState()
LaunchedEffect(selectedIndex) {
if (selectedIndex == -1) return@LaunchedEffect
listState.scrollToItem(
audioTracks.indexOfFirst { it.index == selectedIndex.toLong() }
)
LaunchedEffect(selectedIndex, audioTracks, internalAudioTracks) {
if (selectedIndex == -1) {
focusOffTrack.requestFocus()
return@LaunchedEffect
}
val serverTrackIndex = audioTracks.indexOfFirst { it.index == selectedIndex.toLong() }
if (serverTrackIndex <= 0) {
focusOffTrack.requestFocus()
return@LaunchedEffect
}
val internalIndex = serverTrackIndex - 1
val lazyColumnIndex = internalIndex + 1
listState.scrollToItem(lazyColumnIndex)
focusRequesters[internalAudioTracks[internalIndex]]?.requestFocus()
}
CustomModalBottomSheet(
@ -54,45 +69,37 @@ fun AudioPicker(
.padding(horizontal = 8.dp, vertical = 16.dp),
) {
item {
val selectedOff = -1 == selectedIndex
val selectedOff = selectedIndex == -1
TrackButton(
modifier = Modifier
.fillMaxWidth()
.conditional(selectedOff) {
focusRequester(focusRequester)
},
.focusRequester(focusOffTrack),
onClick = {
VideoPlayerObject.setAudioTrackIndex(-1)
player.clearAudioTrack()
},
selected = selectedOff
) {
Text(
text = "Off",
)
Text("Off")
}
}
internalAudioTracks.forEachIndexed { index, track ->
val serverTrack = audioTracks.elementAtOrNull(index + 1)
val selected = serverTrack?.index == selectedIndex.toLong()
val selected = serverTrack?.index?.toInt() == selectedIndex
item {
TrackButton(
modifier = Modifier
.fillMaxWidth()
.conditional(selected) {
focusRequester(focusRequester)
},
.focusRequester(focusRequesters[track]!!),
onClick = {
serverTrack?.index?.let {
VideoPlayerObject.setAudioTrackIndex(it.toInt())
}
serverTrack?.index?.let { VideoPlayerObject.setAudioTrackIndex(it.toInt()) }
player.setInternalAudioTrack(track)
},
selected = selected
) {
Text(
text = serverTrack?.name ?: "",
)
Text(serverTrack?.name ?: "")
}
}
}

View file

@ -2,18 +2,20 @@ package nl.jknaapen.fladder.composables.dialogs
import Chapter
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@ -22,20 +24,18 @@ 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.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.conditional
import nl.jknaapen.fladder.utility.highlightOnFocus
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -47,27 +47,23 @@ internal fun ChapterSelectionSheet(
val chapters = playbackData?.chapters ?: listOf()
val currentPosition by VideoPlayerObject.position.collectAsState(0L)
val focusRequester = remember { FocusRequester() }
val focusRequesters = remember(chapters) {
chapters.associateWith { FocusRequester() }
}
if (chapters.isEmpty()) return
var currentChapter: Chapter? by remember {
mutableStateOf(
chapters[chapters.indexOfCurrent(
currentPosition
)]
)
}
val lazyListState = rememberLazyListState()
LaunchedEffect(chapters, currentPosition) {
val chapter = chapters.indexOfCurrent(currentPosition)
lazyListState.animateScrollToItem(
chapter
)
currentChapter = chapters[chapter]
focusRequester.requestFocus()
val currentChapterIndex = remember(currentPosition) {
chapters.indexOfCurrent(currentPosition)
}
val currentChapter = chapters.getOrNull(currentChapterIndex)
LaunchedEffect(currentChapter) {
lazyListState.animateScrollToItem(chapters.indexOf(currentChapter))
focusRequesters[currentChapter]?.requestFocus()
}
CustomModalBottomSheet(
@ -76,6 +72,8 @@ internal fun ChapterSelectionSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.55f)
.wrapContentHeight()
.padding(horizontal = 16.dp, vertical = 16.dp)
.wrapContentHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp)
@ -90,36 +88,11 @@ internal fun ChapterSelectionSheet(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
chapters.forEachIndexed { index, chapter ->
val selectedChapter = currentChapter == chapter
val isCurrentChapter = chapters.indexOfCurrent(currentPosition) == index
item {
Column(
modifier = Modifier
.background(
color = if (selectedChapter) Color.White.copy(alpha = 0.25f) else Color.Black.copy(
alpha = 0.75f
),
shape = RoundedCornerShape(8.dp)
)
.aspectRatio(1.67f)
.border(
width = 2.dp,
color = Color.White.copy(alpha = if (selectedChapter) 0.45f else 0f),
shape = RoundedCornerShape(8.dp)
)
.conditional(selectedChapter) {
focusRequester(focusRequester)
}
.onFocusChanged {
if (it.isFocused) {
currentChapter = chapter
}
}
.clickable(
onClick = {
onSelected(chapter)
}
)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
@ -127,33 +100,48 @@ internal fun ChapterSelectionSheet(
alignment = Alignment.CenterVertically
),
) {
AsyncImage(
model = chapter.url,
modifier = Modifier
.focusRequester(focusRequesters[chapter]!!)
.highlightOnFocus(
color = MaterialTheme.colorScheme.primary,
width = 3.dp,
shape = RoundedCornerShape(24.dp)
)
.clickable(
onClick = {
onSelected(chapter)
}
)
.aspectRatio(1.67f)
.clip(shape = RoundedCornerShape(24.dp))
.weight(1f),
contentDescription = "",
contentScale = ContentScale.FillBounds
)
Row(
horizontalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterHorizontally
alignment = Alignment.Start
),
verticalAlignment = Alignment.CenterVertically,
) {
if (isCurrentChapter)
Box(
modifier = Modifier
.size(16.dp)
.background(
color = MaterialTheme.colorScheme.primary,
shape = CircleShape
)
)
Text(
chapter.name,
style = MaterialTheme.typography.bodyLarge,
color = Color.White
)
}
AsyncImage(
model = chapter.url,
modifier = Modifier
.clip(
shape = RoundedCornerShape(24.dp)
)
.heightIn(min = 125.dp, max = 150.dp)
.border(
width = 2.dp,
color = Color.White.copy(alpha = if (isCurrentChapter) 1f else 0f),
shape = RoundedCornerShape(24.dp)
),
contentDescription = ""
)
}
}
}
@ -163,9 +151,13 @@ internal fun ChapterSelectionSheet(
}
private fun List<Chapter>.indexOfCurrent(currentPosition: Long): Int {
return this.indexOfFirst { chapter ->
val nextChapterTime =
this.getOrNull(this.indexOf(chapter) + 1)?.time ?: Long.MAX_VALUE
currentPosition >= chapter.time && currentPosition < nextChapterTime
if (isEmpty()) return 0
for (i in indices) {
val chapter = this[i]
val nextTime = getOrNull(i + 1)?.time ?: Long.MAX_VALUE
if (currentPosition in chapter.time until nextTime) return i
}
return if (currentPosition < first().time) 0 else lastIndex
}

View file

@ -6,10 +6,10 @@ 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.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -29,7 +29,11 @@ internal fun CustomModalBottomSheet(
content: @Composable () -> Unit,
) {
val modalBottomSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
skipPartiallyExpanded = true,
confirmValueChange = { newValue ->
newValue == SheetValue.Expanded ||
newValue == SheetValue.Hidden
}
)
LaunchedEffect(Unit) {
@ -47,7 +51,6 @@ internal fun CustomModalBottomSheet(
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.displayCutoutPadding()
.background(
shape = RoundedCornerShape(16.dp),

View file

@ -20,7 +20,6 @@ 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.conditional
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
@OptIn(UnstableApi::class)
@ -33,16 +32,27 @@ fun SubtitlePicker(
val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(listOf())
val internalSubTracks by VideoPlayerObject.exoSubTracks
val focusRequester = remember { FocusRequester() }
val focusOffTrack = remember { FocusRequester() }
val focusRequesters = remember(internalSubTracks) {
internalSubTracks.associateWith { FocusRequester() }
}
val listState = rememberLazyListState()
LaunchedEffect(selectedIndex, subTitles) {
if (selectedIndex == -1) return@LaunchedEffect
listState.scrollToItem(
subTitles.indexOfFirst { it.index == selectedIndex.toLong() }
)
focusRequester.requestFocus()
LaunchedEffect(selectedIndex, subTitles, internalSubTracks) {
val serverSubIndex = subTitles.indexOfFirst { it.index == selectedIndex.toLong() }
if (serverSubIndex <= 0) {
focusOffTrack.requestFocus()
return@LaunchedEffect
}
val internalIndex = serverSubIndex - 1
val lazyColumnIndex = internalIndex + 1
listState.scrollToItem(lazyColumnIndex)
focusRequesters[internalSubTracks[internalIndex]]?.requestFocus()
}
CustomModalBottomSheet(
@ -60,9 +70,7 @@ fun SubtitlePicker(
TrackButton(
modifier = Modifier
.fillMaxWidth()
.conditional(selectedOff) {
focusRequester(focusRequester)
},
.focusRequester(focusOffTrack),
onClick = {
VideoPlayerObject.setSubtitleTrackIndex(-1)
player.clearSubtitleTrack()
@ -81,9 +89,7 @@ fun SubtitlePicker(
TrackButton(
modifier = Modifier
.fillMaxWidth()
.conditional(selected) {
focusRequester(focusRequester)
},
.focusRequester(focusRequesters[subtitle]!!),
onClick = {
serverSub?.index?.let {
VideoPlayerObject.setSubtitleTrackIndex(it.toInt())

View file

@ -17,7 +17,6 @@ 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.CustomButton
import nl.jknaapen.fladder.utility.defaultSelected
@Composable
internal fun TrackButton(
@ -33,8 +32,7 @@ internal fun TrackButton(
backgroundColor = Color.White.copy(alpha = 0.25f),
modifier = modifier
.padding(vertical = 6.dp, horizontal = 12.dp)
.defaultMinSize(minHeight = 40.dp)
.defaultSelected(selected),
.defaultMinSize(minHeight = 40.dp),
onClick = onClick,
) {
Row(

View file

@ -164,10 +164,16 @@ internal fun NextUpOverlay(
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Next-up in $timeUntilNextVideo seconds",
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Box(
@ -181,6 +187,7 @@ internal fun NextUpOverlay(
)
)
MediaInfo()
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,

View file

@ -61,7 +61,6 @@ class VideoPlayerImplementation(
)
.build()
player?.stop()
player?.clearMediaItems()
player?.setMediaItem(mediaItem)

View file

@ -58,8 +58,6 @@ internal fun ExoPlayer(
val videoHost = VideoPlayerObject
val context = LocalContext.current
var initialized = false
val extractorsFactory = DefaultExtractorsFactory().apply {
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
setTsExtractorTimestampSearchBytes(
@ -167,13 +165,17 @@ internal fun ExoPlayer(
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
if (!initialized) {
initialized = true
val subTracks = exoPlayer.getSubtitleTracks()
val audioTracks = exoPlayer.getAudioTracks()
if (subTracks.isEmpty() && audioTracks.isEmpty()) return
if (subTracks != VideoPlayerObject.exoSubTracks.value || audioTracks != VideoPlayerObject.exoAudioTracks.value) {
VideoPlayerObject.implementation.playbackData.value?.let {
exoPlayer.properlySetSubAndAudioTracks(it)
}
VideoPlayerObject.exoSubTracks.value = exoPlayer.getSubtitleTracks()
VideoPlayerObject.exoAudioTracks.value = exoPlayer.getAudioTracks()
VideoPlayerObject.exoSubTracks.value = subTracks
VideoPlayerObject.exoAudioTracks.value = audioTracks
}
}
}

View file

@ -3,6 +3,7 @@ package nl.jknaapen.fladder.utility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -13,13 +14,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
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.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -42,7 +43,7 @@ fun Modifier.highlightOnFocus(
)
.border(
width = width,
color = color.copy(alpha = 0.5f),
color = color.copy(alpha = 0.8f),
)
} else
if (width != 0.dp) {
@ -54,7 +55,7 @@ fun Modifier.highlightOnFocus(
)
.border(
width = width,
color = color.copy(alpha = 0.5f),
color = color.copy(alpha = 0.8f),
shape = shape
)
} else {
@ -104,12 +105,23 @@ fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier):
fun Modifier.visible(
visible: Boolean,
): Modifier {
val alphaAnimated by animateFloatAsState(if (visible) 1f else 0f)
val alphaAnimated by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
label = "AlphaAnimation"
)
return this
.graphicsLayer {
alpha = alphaAnimated
}
.then(
if (!visible) Modifier.pointerInput(Unit) {} else Modifier
if (!visible) {
//Collapse composable to disable input blocking
Modifier
.size(0.dp)
.clipToBounds()
} else {
Modifier
}
)
}

View file

@ -56,6 +56,7 @@ fun ExoPlayer.setInternalAudioTrack(audioTrack: InternalTrack) {
selector.setParameters(
selector.buildUponParameters()
.setRendererDisabled(audioTrack.rendererIndex, false)
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.build()
)
@ -74,8 +75,12 @@ fun ExoPlayer.clearAudioTrack(disable: Boolean = true) {
selector.setParameters(
selector.buildUponParameters()
.setRendererDisabled(C.TRACK_TYPE_AUDIO, disable)
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, disable)
.build()
)
this.trackSelectionParameters = selector.parameters.buildUpon()
.build()
}
@OptIn(UnstableApi::class)
@ -110,21 +115,27 @@ fun ExoPlayer.getSubtitleTracks(): List<InternalTrack> {
fun ExoPlayer.clearSubtitleTrack() {
val selector = trackSelector as? DefaultTrackSelector ?: return
val newParams = selector.buildUponParameters()
.setRendererDisabled(C.TRACK_TYPE_TEXT, false) // keep text renderer active
.setPreferredTextLanguage(null) // don't auto-pick a language
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) // < disables selection of *any* text track
.setRendererDisabled(C.TRACK_TYPE_TEXT, false)
.setPreferredTextLanguage(null)
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
selector.setParameters(newParams)
this.trackSelectionParameters = selector.parameters.buildUpon()
.build()
}
@OptIn(UnstableApi::class)
fun ExoPlayer.enableSubtitles(language: String? = null) {
val selector = trackSelector as? DefaultTrackSelector ?: return
val newParams = selector.buildUponParameters()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) // allow text again
.setPreferredTextLanguage(language) // optional: auto-pick by language
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setPreferredTextLanguage(language)
.build()
selector.setParameters(newParams)
this.trackSelectionParameters = selector.parameters.buildUpon()
.build()
}