feat: Implement next-up screen for native player (#533)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-10-15 18:05:51 +02:00 committed by GitHub
parent 311b647286
commit 29b1c2e633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 782 additions and 203 deletions

View file

@ -3,7 +3,7 @@ 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.material3.dynamicDarkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -31,7 +31,7 @@ fun VideoPlayerTheme(
val chosenScheme: ColorScheme =
if (themeColor == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(context)
dynamicDarkColorScheme(context)
} else {
generatedScheme
}

View file

@ -65,6 +65,18 @@ private object PlayerSettingsHelperPigeonUtils {
}
enum class AutoNextType(val raw: Int) {
OFF(0),
STATIC(1),
SMART(2);
companion object {
fun ofRaw(raw: Int): AutoNextType? {
return values().firstOrNull { it.raw == raw }
}
}
}
enum class SegmentType(val raw: Int) {
COMMERCIAL(0),
PREVIEW(1),
@ -97,7 +109,8 @@ data class PlayerSettings (
val skipTypes: Map<SegmentType, SegmentSkip>,
val themeColor: Long? = null,
val skipForward: Long,
val skipBackward: Long
val skipBackward: Long,
val autoNextType: AutoNextType
)
{
companion object {
@ -107,7 +120,8 @@ data class PlayerSettings (
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)
val autoNextType = pigeonVar_list[5] as AutoNextType
return PlayerSettings(enableTunneling, skipTypes, themeColor, skipForward, skipBackward, autoNextType)
}
}
fun toList(): List<Any?> {
@ -117,6 +131,7 @@ data class PlayerSettings (
themeColor,
skipForward,
skipBackward,
autoNextType,
)
}
override fun equals(other: Any?): Boolean {
@ -135,15 +150,20 @@ private open class PlayerSettingsHelperPigeonCodec : StandardMessageCodec() {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SegmentType.ofRaw(it.toInt())
AutoNextType.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SegmentSkip.ofRaw(it.toInt())
SegmentType.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SegmentSkip.ofRaw(it.toInt())
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlayerSettings.fromList(it)
}
@ -153,16 +173,20 @@ private open class PlayerSettingsHelperPigeonCodec : StandardMessageCodec() {
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is SegmentType -> {
is AutoNextType -> {
stream.write(129)
writeValue(stream, value.raw)
}
is SegmentSkip -> {
is SegmentType -> {
stream.write(130)
writeValue(stream, value.raw)
}
is PlayerSettings -> {
is SegmentSkip -> {
stream.write(131)
writeValue(stream, value.raw)
}
is PlayerSettings -> {
stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)

View file

@ -95,11 +95,51 @@ enum class MediaSegmentType(val raw: Int) {
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlayableData (
data class SimpleItemModel (
val id: String,
val title: String,
val subTitle: String? = null,
val overview: String? = null,
val logoUrl: String? = null,
val primaryPoster: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SimpleItemModel {
val id = pigeonVar_list[0] as String
val title = pigeonVar_list[1] as String
val subTitle = pigeonVar_list[2] as String?
val overview = pigeonVar_list[3] as String?
val logoUrl = pigeonVar_list[4] as String?
val primaryPoster = pigeonVar_list[5] as String
return SimpleItemModel(id, title, subTitle, overview, logoUrl, primaryPoster)
}
}
fun toList(): List<Any?> {
return listOf(
id,
title,
subTitle,
overview,
logoUrl,
primaryPoster,
)
}
override fun equals(other: Any?): Boolean {
if (other !is SimpleItemModel) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlayableData (
val currentItem: SimpleItemModel,
val description: String,
val startPosition: Long,
val defaultAudioTrack: Long,
@ -109,38 +149,32 @@ data class PlayableData (
val trickPlayModel: TrickPlayModel? = null,
val chapters: List<Chapter>,
val segments: List<MediaSegment>,
val previousVideo: String? = null,
val nextVideo: String? = null,
val previousVideo: SimpleItemModel? = null,
val nextVideo: SimpleItemModel? = null,
val url: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): PlayableData {
val id = pigeonVar_list[0] as String
val title = pigeonVar_list[1] as String
val subTitle = pigeonVar_list[2] as String?
val logoUrl = pigeonVar_list[3] as String?
val description = pigeonVar_list[4] as String
val startPosition = pigeonVar_list[5] as Long
val defaultAudioTrack = pigeonVar_list[6] as Long
val audioTracks = pigeonVar_list[7] as List<AudioTrack>
val defaultSubtrack = pigeonVar_list[8] as Long
val subtitleTracks = pigeonVar_list[9] as List<SubtitleTrack>
val trickPlayModel = pigeonVar_list[10] as TrickPlayModel?
val chapters = pigeonVar_list[11] as List<Chapter>
val segments = pigeonVar_list[12] as List<MediaSegment>
val previousVideo = pigeonVar_list[13] as String?
val nextVideo = pigeonVar_list[14] as String?
val url = pigeonVar_list[15] as String
return PlayableData(id, title, subTitle, logoUrl, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, url)
val currentItem = pigeonVar_list[0] as SimpleItemModel
val description = pigeonVar_list[1] as String
val startPosition = pigeonVar_list[2] as Long
val defaultAudioTrack = pigeonVar_list[3] as Long
val audioTracks = pigeonVar_list[4] as List<AudioTrack>
val defaultSubtrack = pigeonVar_list[5] as Long
val subtitleTracks = pigeonVar_list[6] as List<SubtitleTrack>
val trickPlayModel = pigeonVar_list[7] as TrickPlayModel?
val chapters = pigeonVar_list[8] as List<Chapter>
val segments = pigeonVar_list[9] as List<MediaSegment>
val previousVideo = pigeonVar_list[10] as SimpleItemModel?
val nextVideo = pigeonVar_list[11] as SimpleItemModel?
val url = pigeonVar_list[12] as String
return PlayableData(currentItem, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, url)
}
}
fun toList(): List<Any?> {
return listOf(
id,
title,
subTitle,
logoUrl,
currentItem,
description,
startPosition,
defaultAudioTrack,
@ -453,40 +487,45 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() {
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlayableData.fromList(it)
SimpleItemModel.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
MediaSegment.fromList(it)
PlayableData.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
AudioTrack.fromList(it)
MediaSegment.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SubtitleTrack.fromList(it)
AudioTrack.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
Chapter.fromList(it)
SubtitleTrack.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TrickPlayModel.fromList(it)
Chapter.fromList(it)
}
}
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
StartResult.fromList(it)
TrickPlayModel.fromList(it)
}
}
137.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
StartResult.fromList(it)
}
}
138.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlaybackState.fromList(it)
}
@ -500,38 +539,42 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() {
stream.write(129)
writeValue(stream, value.raw)
}
is PlayableData -> {
is SimpleItemModel -> {
stream.write(130)
writeValue(stream, value.toList())
}
is MediaSegment -> {
is PlayableData -> {
stream.write(131)
writeValue(stream, value.toList())
}
is AudioTrack -> {
is MediaSegment -> {
stream.write(132)
writeValue(stream, value.toList())
}
is SubtitleTrack -> {
is AudioTrack -> {
stream.write(133)
writeValue(stream, value.toList())
}
is Chapter -> {
is SubtitleTrack -> {
stream.write(134)
writeValue(stream, value.toList())
}
is TrickPlayModel -> {
is Chapter -> {
stream.write(135)
writeValue(stream, value.toList())
}
is StartResult -> {
is TrickPlayModel -> {
stream.write(136)
writeValue(stream, value.toList())
}
is PlaybackState -> {
is StartResult -> {
stream.write(137)
writeValue(stream, value.toList())
}
is PlaybackState -> {
stream.write(138)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}

View file

@ -1,9 +1,12 @@
package nl.jknaapen.fladder.composables.controls
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
@ -20,11 +23,15 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
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.unit.dp
import nl.jknaapen.fladder.utility.conditional
@Composable
internal fun CustomIconButton(
internal fun CustomButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
enabled: Boolean = true,
@ -38,6 +45,23 @@ internal fun CustomIconButton(
val interactionSource = remember { MutableInteractionSource() }
var isFocused by remember { mutableStateOf(false) }
var isClickAnimationActive by remember { mutableStateOf(false) }
val isPressed by interactionSource.collectIsPressedAsState()
isClickAnimationActive = isPressed
val targetScale = when {
isClickAnimationActive -> 0.9f
isFocused && enableScaledFocus -> 1.05f
else -> 1.0f
}
val animatedScale by animateFloatAsState(
targetValue = targetScale,
animationSpec = spring(dampingRatio = 0.5f, stiffness = 400f),
label = "buttonScaleAnimation"
)
val currentContentColor by animateColorAsState(
if (isFocused) {
foreGroundFocusedColor
@ -56,9 +80,29 @@ internal fun CustomIconButton(
Box(
modifier = modifier
.conditional(enableScaledFocus) {
scale(if (isFocused) 1.05f else 1f)
.onKeyEvent { event ->
if (!enabled || !isFocused) return@onKeyEvent false
val isDpadClick = event.key == Key.Enter || event.key == Key.DirectionCenter
if (isDpadClick) {
when (event.type) {
KeyEventType.KeyDown -> {
isClickAnimationActive = true
return@onKeyEvent false
}
KeyEventType.KeyUp -> {
isClickAnimationActive = false
return@onKeyEvent false
}
else -> return@onKeyEvent false
}
}
return@onKeyEvent false
}
.scale(animatedScale)
.background(currentBackgroundColor, shape = CircleShape)
.onFocusChanged { isFocused = it.isFocused }
.clickable(

View file

@ -19,8 +19,8 @@ fun ItemHeader(
modifier: Modifier = Modifier,
state: PlayableData?
) {
val title = state?.title
val logoUrl = state?.logoUrl
val title = state?.currentItem?.title
val logoUrl = state?.currentItem?.logoUrl
Box(
modifier = modifier

View file

@ -120,7 +120,7 @@ internal fun ProgressBar(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
val progressBarTopLabel = listOf(
playableData?.subTitle,
playableData?.currentItem?.subTitle,
endTimeString,
)

View file

@ -92,7 +92,7 @@ internal fun BoxScope.SegmentSkipOverlay(
.padding(16.dp)
.safeContentPadding()
) {
CustomIconButton(
CustomButton(
modifier = modifier
.focusRequester(focusRequester)
.defaultSelected(true),

View file

@ -370,16 +370,16 @@ fun PlaybackButtons(
),
verticalAlignment = Alignment.CenterVertically,
) {
CustomIconButton(
CustomButton(
onClick = { VideoPlayerObject.videoPlayerControls?.loadPreviousVideo {} },
enabled = previousVideo != null,
) {
Icon(
Iconsax.Filled.Backward,
contentDescription = previousVideo,
contentDescription = previousVideo?.title,
)
}
CustomIconButton(
CustomButton(
onClick = {
player.seekTo(
player.currentPosition - backwardSpeed.inWholeMilliseconds
@ -402,7 +402,7 @@ fun PlaybackButtons(
)
}
}
CustomIconButton(
CustomButton(
modifier = Modifier
.focusRequester(bottomControlFocusRequester)
.defaultSelected(true),
@ -417,7 +417,7 @@ fun PlaybackButtons(
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
CustomIconButton(
CustomButton(
onClick = {
player.seekTo(
player.currentPosition + forwardSpeed.inWholeMilliseconds
@ -443,13 +443,13 @@ fun PlaybackButtons(
}
}
CustomIconButton(
CustomButton(
onClick = { VideoPlayerObject.videoPlayerControls?.loadNextVideo {} },
enabled = nextVideo != null,
) {
Icon(
Iconsax.Filled.Forward,
contentDescription = nextVideo,
contentDescription = nextVideo?.title,
)
}
}
@ -465,7 +465,7 @@ internal fun RowScope.LeftButtons(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start)
) {
CustomIconButton(
CustomButton(
onClick = openChapterSelection,
enabled = chapters?.isNotEmpty() == true
) {
@ -486,7 +486,7 @@ internal fun RowScope.RightButtons(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.End)
) {
CustomIconButton(
CustomButton(
onClick = {
showAudioDialog.value = true
},
@ -496,7 +496,7 @@ internal fun RowScope.RightButtons(
contentDescription = "Audio Track",
)
}
CustomIconButton(
CustomButton(
onClick = {
showSubDialog.value = true
},

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@ -100,6 +101,7 @@ internal fun ChapterSelectionSheet(
),
shape = RoundedCornerShape(8.dp)
)
.aspectRatio(1.67f)
.border(
width = 2.dp,
color = Color.White.copy(alpha = if (selectedChapter) 0.45f else 0f),

View file

@ -16,7 +16,7 @@ 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.composables.controls.CustomButton
import nl.jknaapen.fladder.utility.defaultSelected
@Composable
@ -29,7 +29,7 @@ internal fun TrackButton(
val textStyle =
MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)
CustomIconButton(
CustomButton(
backgroundColor = Color.White.copy(alpha = 0.25f),
modifier = modifier
.padding(vertical = 6.dp, horizontal = 12.dp)

View file

@ -0,0 +1,316 @@
package nl.jknaapen.fladder.composables.overlays
import AutoNextType
import MediaSegmentType
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.mutableIntStateOf
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.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.filled.CloseCircle
import io.github.rabehx.iconsax.filled.Next
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import nl.jknaapen.fladder.composables.controls.CustomButton
import nl.jknaapen.fladder.objects.PlayerSettingsObject
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.conditional
import nl.jknaapen.fladder.utility.highlightOnFocus
import nl.jknaapen.fladder.utility.visible
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@Composable
internal fun NextUpOverlay(
modifier: Modifier = Modifier,
content: @Composable BoxScope.(Boolean) -> Unit,
) {
val nextVideo by VideoPlayerObject.nextUpVideo.collectAsState(null)
val nextType by PlayerSettingsObject.autoNextType.collectAsState(AutoNextType.OFF)
var disableUntilNextVideo by remember { mutableStateOf(false) }
if (nextType == AutoNextType.OFF || nextVideo == null) {
return Box(
modifier = Modifier.fillMaxSize()
) {
content(true)
}
}
val isConditionMet = showNextUp()
var nextUpVisible by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
var timeUntilNextVideo by remember { mutableIntStateOf(30) }
fun reInitNextUp() {
nextUpVisible = false
disableUntilNextVideo = false
}
LaunchedEffect(isConditionMet) {
if (isConditionMet && !nextUpVisible && !disableUntilNextVideo) {
nextUpVisible = true
timeUntilNextVideo = 30
focusRequester.requestFocus()
} else if (!isConditionMet) {
nextUpVisible = false
}
}
val isBuffering by VideoPlayerObject.buffering.collectAsState(true)
LaunchedEffect(nextVideo) {
reInitNextUp()
}
fun loadNextVideo() {
if (isBuffering) return
VideoPlayerObject.videoPlayerControls?.loadNextVideo {}
reInitNextUp()
}
val showNextUp = nextUpVisible && !disableUntilNextVideo
LaunchedEffect(showNextUp) {
if (showNextUp) {
while (timeUntilNextVideo > 0 && isActive) {
delay(1000)
timeUntilNextVideo -= 1
}
loadNextVideo()
}
}
val activity = LocalActivity.current
val animatedDp by animateDpAsState(if (showNextUp) 16.dp else 0.dp, label = "paddingDp")
val animatedSizeFraction by animateFloatAsState(
if (showNextUp) 0.6f else 1f,
label = "sizeFraction"
)
Box(
modifier = modifier.background(
color = Color(0xFF0E0E0E),
),
) {
Box(
modifier = Modifier
.padding(animatedDp)
.align(Alignment.CenterStart)
.fillMaxSize(fraction = animatedSizeFraction)
.conditional(showNextUp) {
highlightOnFocus(
width = if (showNextUp) 2.dp else 0.dp,
useClip = false,
)
}
.clickable(
enabled = showNextUp,
onClick = {
disableUntilNextVideo = true
}
)
) {
content(!showNextUp)
}
Column(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth(fraction = 0.4f)
.align(Alignment.CenterEnd)
.visible(showNextUp)
.padding(16.dp)
.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(8.dp)
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"Next-up in $timeUntilNextVideo seconds",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Box(
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.fillMaxWidth(fraction = 0.1f)
.heightIn(2.dp)
.background(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
shape = RoundedCornerShape(16.dp),
)
)
MediaInfo()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
CustomButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = ::loadNextVideo,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Next")
Icon(Iconsax.Filled.Next, contentDescription = "Play next video")
}
}
activity?.let {
CustomButton(
onClick = {
reInitNextUp()
activity.finish()
},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Close")
Icon(Iconsax.Filled.CloseCircle, contentDescription = "Close Icon")
}
}
}
}
}
}
}
@Composable
private fun MediaInfo() {
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
val nextUpVideo by VideoPlayerObject.nextUpVideo.collectAsState(null)
nextUpVideo?.let { video ->
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
video.title,
style = MaterialTheme.typography.titleMedium,
color = onSurfaceColor
)
if (video.title != video.subTitle && video.subTitle != null)
Text(
video.subTitle,
style = MaterialTheme.typography.bodyMedium,
color = onSurfaceColor.copy(alpha = 0.65f)
)
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1.67f)
.clip(RoundedCornerShape(16.dp)),
model = video.primaryPoster,
contentDescription = "ItemPoster"
)
video.overview?.let { overview ->
Text(
overview,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 4,
color = onSurfaceColor
)
}
}
}
}
@Composable
private fun showNextUp(): Boolean {
val nextType by PlayerSettingsObject.autoNextType.collectAsState(AutoNextType.OFF)
val durationMs by VideoPlayerObject.duration.collectAsState(0L)
val positionMs by VideoPlayerObject.position.collectAsState(0L)
val buffering by VideoPlayerObject.buffering.collectAsState(true)
val videoDuration = durationMs.toDuration(DurationUnit.MILLISECONDS)
val videoPosition = positionMs.toDuration(DurationUnit.MILLISECONDS)
if (nextType == AutoNextType.OFF || videoDuration < 40.seconds || buffering) {
return false
}
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState()
val credits =
(playbackData?.segments ?: listOf()).firstOrNull { it.type == MediaSegmentType.OUTRO }
val timeToVideoEnd = (videoDuration - videoPosition).absoluteValue
val nearEndOfVideo = timeToVideoEnd < 32.seconds
when (nextType) {
AutoNextType.STATIC -> {
if (nearEndOfVideo) return true
}
AutoNextType.SMART -> {
if (credits != null) {
val maxTimePct = 90.0
val resumeDuration = videoDuration * (maxTimePct / 100)
val creditsEnd = credits.end.toDuration(DurationUnit.MILLISECONDS)
val creditsStart = credits.start.toDuration(DurationUnit.MILLISECONDS)
val timeLeftAfterCredits = videoDuration - creditsEnd
if (creditsEnd > resumeDuration && timeLeftAfterCredits < 30.seconds) {
if (videoPosition >= creditsStart) {
return true
}
} else if (nearEndOfVideo) {
return true
}
} else {
if (nearEndOfVideo) {
return true
}
}
}
AutoNextType.OFF -> return false
}
return false
}

View file

@ -48,8 +48,8 @@ class VideoPlayerImplementation(
val subTitles = playbackData.value?.subtitleTracks ?: listOf()
val mediaItem = MediaItem.Builder()
.setUri(url)
.setTag(playbackData.value?.title)
.setMediaId(playbackData.value?.id ?: "")
.setTag(playbackData.value?.currentItem?.title)
.setMediaId(playbackData.value?.currentItem?.id ?: "")
.setSubtitleConfigurations(
subTitles.filter { it.external && !it.url.isNullOrEmpty() }.map { sub ->
MediaItem.SubtitleConfiguration.Builder(sub.url!!.toUri())

View file

@ -1,5 +1,6 @@
package nl.jknaapen.fladder.objects
import AutoNextType
import PlayerSettings
import PlayerSettingsPigeon
import androidx.compose.ui.graphics.Color
@ -28,6 +29,10 @@ object PlayerSettingsObject : PlayerSettingsPigeon {
}
}
val autoNextType = settings.map { settings ->
settings?.autoNextType ?: AutoNextType.OFF
}
override fun sendPlayerSettings(playerSettings: PlayerSettings) {
settings.value = playerSettings
}

View file

@ -31,6 +31,8 @@ object VideoPlayerObject {
val chapters = implementation.playbackData.map { it?.chapters }
val nextUpVideo = implementation.playbackData.map { it?.nextVideo }
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalTime::class)
val endTime = combine(position, duration) { pos, dur ->

View file

@ -4,16 +4,14 @@ import PlaybackState
import android.app.ActivityManager
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.getSystemService
@ -35,11 +33,14 @@ import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import io.github.peerless2012.ass.media.kt.buildWithAssSupport
import io.github.peerless2012.ass.media.type.AssRenderType
import kotlinx.coroutines.delay
import nl.jknaapen.fladder.composables.overlays.NextUpOverlay
import nl.jknaapen.fladder.messengers.properlySetSubAndAudioTracks
import nl.jknaapen.fladder.objects.PlayerSettingsObject
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.getAudioTracks
import nl.jknaapen.fladder.utility.getSubtitleTracks
import kotlin.time.Duration.Companion.seconds
val LocalPlayer = compositionLocalOf<ExoPlayer?> { null }
@ -108,9 +109,29 @@ internal fun ExoPlayer(
)
}
fun updatePlaybackState() {
videoHost.setPlaybackState(
PlaybackState(
position = exoPlayer.currentPosition,
buffered = exoPlayer.bufferedPosition,
duration = exoPlayer.duration,
playing = exoPlayer.isPlaying,
buffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
completed = exoPlayer.playbackState == Player.STATE_ENDED,
failed = exoPlayer.playbackState == Player.STATE_IDLE
)
)
}
LaunchedEffect(exoPlayer) {
while (true) {
updatePlaybackState()
delay(1.seconds)
}
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
videoHost.setPlaybackState(
PlaybackState(
@ -127,17 +148,7 @@ internal fun ExoPlayer(
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
videoHost.setPlaybackState(
PlaybackState(
position = exoPlayer.currentPosition,
buffered = exoPlayer.bufferedPosition,
duration = exoPlayer.duration,
playing = exoPlayer.isPlaying,
buffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
completed = exoPlayer.playbackState == Player.STATE_ENDED,
failed = exoPlayer.playbackState == Player.STATE_IDLE
)
)
updatePlaybackState()
}
override fun onTracksChanged(tracks: Tracks) {
@ -168,13 +179,13 @@ internal fun ExoPlayer(
}
Box(
NextUpOverlay(
modifier = Modifier
.background(Color.Black)
.fillMaxSize()
) {
) { showControls ->
AndroidView(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize(),
factory = {
PlayerView(it).apply {
player = exoPlayer
@ -198,8 +209,9 @@ internal fun ExoPlayer(
}
},
)
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
controls(exoPlayer)
}
if (showControls)
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
controls(exoPlayer)
}
}
}

View file

@ -30,30 +30,41 @@ import androidx.compose.ui.unit.dp
fun Modifier.highlightOnFocus(
color: Color = Color.White.copy(alpha = 0.85f),
width: Dp = 1.5.dp,
shape: Shape = RoundedCornerShape(16.dp)
shape: Shape = RoundedCornerShape(16.dp),
useClip: Boolean = true,
): Modifier = composed {
var hasFocus by remember { mutableStateOf(false) }
val highlightModifier = remember {
if (width != 0.dp) {
if (!useClip) {
Modifier
.clip(RoundedCornerShape(8.dp))
.background(
color = color.copy(alpha = 0.25f),
shape = shape,
)
.border(
width = width,
color = color.copy(alpha = 0.5f),
shape = shape
)
} else {
Modifier
.clip(RoundedCornerShape(8.dp))
.background(
color = color.copy(alpha = 0.25f),
shape = shape,
)
}
} else
if (width != 0.dp) {
Modifier
.clip(shape)
.background(
color = color.copy(alpha = 0.25f),
shape = shape,
)
.border(
width = width,
color = color.copy(alpha = 0.5f),
shape = shape
)
} else {
Modifier
.clip(shape)
.background(
color = color.copy(alpha = 0.25f),
shape = shape,
)
}
}
this

View file

@ -3,14 +3,18 @@ package nl.jknaapen.fladder.utility
import AudioTrack
import Chapter
import PlayableData
import SimpleItemModel
import SubtitleTrack
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
val testPlaybackData = PlayableData(
id = "8lsf8234l99sdf923lsd8f23j98j",
title = "Big buck bunny",
subTitle = "Episode 1x2",
currentItem = SimpleItemModel(
id = "8lsf8234l99sdf923lsd8f23j98j",
title = "Big buck bunny",
subTitle = "Episode 1x2",
primaryPoster = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.ytimg.com%2Fvi%2Faqz-KE-bpKQ%2Fmaxresdefault.jpg&f=1&nofb=1&ipt=4e375598bf8cc78e681ee62de9111dea32b85972ae756e40a1eddac01aa79f80"
),
startPosition = 0,
description = "Short description of the movie that is being watched",
defaultSubtrack = 1,
@ -88,7 +92,12 @@ val testPlaybackData = PlayableData(
)
),
nextVideo = null,
previousVideo = "Previous episode name",
previousVideo = SimpleItemModel(
id = "8lsf8234l99sdf923lsd8f23j98j",
title = "Big buck bunny",
subTitle = "Episode 1x26",
primaryPoster = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.ytimg.com%2Fvi%2Faqz-KE-bpKQ%2Fmaxresdefault.jpg&f=1&nofb=1&ipt=4e375598bf8cc78e681ee62de9111dea32b85972ae756e40a1eddac01aa79f80"
),
segments = listOf(),
url = "https://github.com/ietf-wg-cellar/matroska-test-files/raw/refs/heads/master/test_files/test5.mkv",
)