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 android.os.Build
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -31,7 +31,7 @@ fun VideoPlayerTheme(
val chosenScheme: ColorScheme = val chosenScheme: ColorScheme =
if (themeColor == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (themeColor == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(context) dynamicDarkColorScheme(context)
} else { } else {
generatedScheme 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) { enum class SegmentType(val raw: Int) {
COMMERCIAL(0), COMMERCIAL(0),
PREVIEW(1), PREVIEW(1),
@ -97,7 +109,8 @@ data class PlayerSettings (
val skipTypes: Map<SegmentType, SegmentSkip>, val skipTypes: Map<SegmentType, SegmentSkip>,
val themeColor: Long? = null, val themeColor: Long? = null,
val skipForward: Long, val skipForward: Long,
val skipBackward: Long val skipBackward: Long,
val autoNextType: AutoNextType
) )
{ {
companion object { companion object {
@ -107,7 +120,8 @@ data class PlayerSettings (
val themeColor = pigeonVar_list[2] as Long? val themeColor = pigeonVar_list[2] as Long?
val skipForward = pigeonVar_list[3] as Long val skipForward = pigeonVar_list[3] as Long
val skipBackward = pigeonVar_list[4] 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?> { fun toList(): List<Any?> {
@ -117,6 +131,7 @@ data class PlayerSettings (
themeColor, themeColor,
skipForward, skipForward,
skipBackward, skipBackward,
autoNextType,
) )
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -135,15 +150,20 @@ private open class PlayerSettingsHelperPigeonCodec : StandardMessageCodec() {
return when (type) { return when (type) {
129.toByte() -> { 129.toByte() -> {
return (readValue(buffer) as Long?)?.let { return (readValue(buffer) as Long?)?.let {
SegmentType.ofRaw(it.toInt()) AutoNextType.ofRaw(it.toInt())
} }
} }
130.toByte() -> { 130.toByte() -> {
return (readValue(buffer) as Long?)?.let { return (readValue(buffer) as Long?)?.let {
SegmentSkip.ofRaw(it.toInt()) SegmentType.ofRaw(it.toInt())
} }
} }
131.toByte() -> { 131.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SegmentSkip.ofRaw(it.toInt())
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
PlayerSettings.fromList(it) PlayerSettings.fromList(it)
} }
@ -153,16 +173,20 @@ private open class PlayerSettingsHelperPigeonCodec : StandardMessageCodec() {
} }
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) { when (value) {
is SegmentType -> { is AutoNextType -> {
stream.write(129) stream.write(129)
writeValue(stream, value.raw) writeValue(stream, value.raw)
} }
is SegmentSkip -> { is SegmentType -> {
stream.write(130) stream.write(130)
writeValue(stream, value.raw) writeValue(stream, value.raw)
} }
is PlayerSettings -> { is SegmentSkip -> {
stream.write(131) stream.write(131)
writeValue(stream, value.raw)
}
is PlayerSettings -> {
stream.write(132)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
else -> super.writeValue(stream, value) 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. */ /** Generated class from Pigeon that represents data sent in messages. */
data class PlayableData ( data class SimpleItemModel (
val id: String, val id: String,
val title: String, val title: String,
val subTitle: String? = null, val subTitle: String? = null,
val overview: String? = null,
val logoUrl: 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 description: String,
val startPosition: Long, val startPosition: Long,
val defaultAudioTrack: Long, val defaultAudioTrack: Long,
@ -109,38 +149,32 @@ data class PlayableData (
val trickPlayModel: TrickPlayModel? = null, val trickPlayModel: TrickPlayModel? = null,
val chapters: List<Chapter>, val chapters: List<Chapter>,
val segments: List<MediaSegment>, val segments: List<MediaSegment>,
val previousVideo: String? = null, val previousVideo: SimpleItemModel? = null,
val nextVideo: String? = null, val nextVideo: SimpleItemModel? = null,
val url: String val url: String
) )
{ {
companion object { companion object {
fun fromList(pigeonVar_list: List<Any?>): PlayableData { fun fromList(pigeonVar_list: List<Any?>): PlayableData {
val id = pigeonVar_list[0] as String val currentItem = pigeonVar_list[0] as SimpleItemModel
val title = pigeonVar_list[1] as String val description = pigeonVar_list[1] as String
val subTitle = pigeonVar_list[2] as String? val startPosition = pigeonVar_list[2] as Long
val logoUrl = pigeonVar_list[3] as String? val defaultAudioTrack = pigeonVar_list[3] as Long
val description = pigeonVar_list[4] as String val audioTracks = pigeonVar_list[4] as List<AudioTrack>
val startPosition = pigeonVar_list[5] as Long val defaultSubtrack = pigeonVar_list[5] as Long
val defaultAudioTrack = pigeonVar_list[6] as Long val subtitleTracks = pigeonVar_list[6] as List<SubtitleTrack>
val audioTracks = pigeonVar_list[7] as List<AudioTrack> val trickPlayModel = pigeonVar_list[7] as TrickPlayModel?
val defaultSubtrack = pigeonVar_list[8] as Long val chapters = pigeonVar_list[8] as List<Chapter>
val subtitleTracks = pigeonVar_list[9] as List<SubtitleTrack> val segments = pigeonVar_list[9] as List<MediaSegment>
val trickPlayModel = pigeonVar_list[10] as TrickPlayModel? val previousVideo = pigeonVar_list[10] as SimpleItemModel?
val chapters = pigeonVar_list[11] as List<Chapter> val nextVideo = pigeonVar_list[11] as SimpleItemModel?
val segments = pigeonVar_list[12] as List<MediaSegment> val url = pigeonVar_list[12] as String
val previousVideo = pigeonVar_list[13] as String? return PlayableData(currentItem, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, url)
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)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
return listOf( return listOf(
id, currentItem,
title,
subTitle,
logoUrl,
description, description,
startPosition, startPosition,
defaultAudioTrack, defaultAudioTrack,
@ -453,40 +487,45 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() {
} }
130.toByte() -> { 130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
PlayableData.fromList(it) SimpleItemModel.fromList(it)
} }
} }
131.toByte() -> { 131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
MediaSegment.fromList(it) PlayableData.fromList(it)
} }
} }
132.toByte() -> { 132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
AudioTrack.fromList(it) MediaSegment.fromList(it)
} }
} }
133.toByte() -> { 133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
SubtitleTrack.fromList(it) AudioTrack.fromList(it)
} }
} }
134.toByte() -> { 134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
Chapter.fromList(it) SubtitleTrack.fromList(it)
} }
} }
135.toByte() -> { 135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
TrickPlayModel.fromList(it) Chapter.fromList(it)
} }
} }
136.toByte() -> { 136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
StartResult.fromList(it) TrickPlayModel.fromList(it)
} }
} }
137.toByte() -> { 137.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
StartResult.fromList(it)
}
}
138.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
PlaybackState.fromList(it) PlaybackState.fromList(it)
} }
@ -500,38 +539,42 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() {
stream.write(129) stream.write(129)
writeValue(stream, value.raw) writeValue(stream, value.raw)
} }
is PlayableData -> { is SimpleItemModel -> {
stream.write(130) stream.write(130)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is MediaSegment -> { is PlayableData -> {
stream.write(131) stream.write(131)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is AudioTrack -> { is MediaSegment -> {
stream.write(132) stream.write(132)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is SubtitleTrack -> { is AudioTrack -> {
stream.write(133) stream.write(133)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is Chapter -> { is SubtitleTrack -> {
stream.write(134) stream.write(134)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is TrickPlayModel -> { is Chapter -> {
stream.write(135) stream.write(135)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is StartResult -> { is TrickPlayModel -> {
stream.write(136) stream.write(136)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is PlaybackState -> { is StartResult -> {
stream.write(137) stream.write(137)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is PlaybackState -> {
stream.write(138)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
} }
} }

View file

@ -1,9 +1,12 @@
package nl.jknaapen.fladder.composables.controls package nl.jknaapen.fladder.composables.controls
import androidx.compose.animation.animateColorAsState 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape 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.draw.scale
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.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 androidx.compose.ui.unit.dp
import nl.jknaapen.fladder.utility.conditional
@Composable @Composable
internal fun CustomIconButton( internal fun CustomButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
@ -38,6 +45,23 @@ internal fun CustomIconButton(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var isFocused by remember { mutableStateOf(false) } 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( val currentContentColor by animateColorAsState(
if (isFocused) { if (isFocused) {
foreGroundFocusedColor foreGroundFocusedColor
@ -56,9 +80,29 @@ internal fun CustomIconButton(
Box( Box(
modifier = modifier modifier = modifier
.conditional(enableScaledFocus) { .onKeyEvent { event ->
scale(if (isFocused) 1.05f else 1f) 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) .background(currentBackgroundColor, shape = CircleShape)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.clickable( .clickable(

View file

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

View file

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

View file

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

View file

@ -370,16 +370,16 @@ fun PlaybackButtons(
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CustomIconButton( CustomButton(
onClick = { VideoPlayerObject.videoPlayerControls?.loadPreviousVideo {} }, onClick = { VideoPlayerObject.videoPlayerControls?.loadPreviousVideo {} },
enabled = previousVideo != null, enabled = previousVideo != null,
) { ) {
Icon( Icon(
Iconsax.Filled.Backward, Iconsax.Filled.Backward,
contentDescription = previousVideo, contentDescription = previousVideo?.title,
) )
} }
CustomIconButton( CustomButton(
onClick = { onClick = {
player.seekTo( player.seekTo(
player.currentPosition - backwardSpeed.inWholeMilliseconds player.currentPosition - backwardSpeed.inWholeMilliseconds
@ -402,7 +402,7 @@ fun PlaybackButtons(
) )
} }
} }
CustomIconButton( CustomButton(
modifier = Modifier modifier = Modifier
.focusRequester(bottomControlFocusRequester) .focusRequester(bottomControlFocusRequester)
.defaultSelected(true), .defaultSelected(true),
@ -417,7 +417,7 @@ fun PlaybackButtons(
contentDescription = if (isPlaying) "Pause" else "Play", contentDescription = if (isPlaying) "Pause" else "Play",
) )
} }
CustomIconButton( CustomButton(
onClick = { onClick = {
player.seekTo( player.seekTo(
player.currentPosition + forwardSpeed.inWholeMilliseconds player.currentPosition + forwardSpeed.inWholeMilliseconds
@ -443,13 +443,13 @@ fun PlaybackButtons(
} }
} }
CustomIconButton( CustomButton(
onClick = { VideoPlayerObject.videoPlayerControls?.loadNextVideo {} }, onClick = { VideoPlayerObject.videoPlayerControls?.loadNextVideo {} },
enabled = nextVideo != null, enabled = nextVideo != null,
) { ) {
Icon( Icon(
Iconsax.Filled.Forward, Iconsax.Filled.Forward,
contentDescription = nextVideo, contentDescription = nextVideo?.title,
) )
} }
} }
@ -465,7 +465,7 @@ internal fun RowScope.LeftButtons(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start) horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start)
) { ) {
CustomIconButton( CustomButton(
onClick = openChapterSelection, onClick = openChapterSelection,
enabled = chapters?.isNotEmpty() == true enabled = chapters?.isNotEmpty() == true
) { ) {
@ -486,7 +486,7 @@ internal fun RowScope.RightButtons(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.End) horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.End)
) { ) {
CustomIconButton( CustomButton(
onClick = { onClick = {
showAudioDialog.value = true showAudioDialog.value = true
}, },
@ -496,7 +496,7 @@ internal fun RowScope.RightButtons(
contentDescription = "Audio Track", contentDescription = "Audio Track",
) )
} }
CustomIconButton( CustomButton(
onClick = { onClick = {
showSubDialog.value = true 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -100,6 +101,7 @@ internal fun ChapterSelectionSheet(
), ),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) )
.aspectRatio(1.67f)
.border( .border(
width = 2.dp, width = 2.dp,
color = Color.White.copy(alpha = if (selectedChapter) 0.45f else 0f), 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 androidx.compose.ui.unit.dp
import io.github.rabehx.iconsax.Iconsax import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.filled.TickSquare 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 import nl.jknaapen.fladder.utility.defaultSelected
@Composable @Composable
@ -29,7 +29,7 @@ internal fun TrackButton(
val textStyle = val textStyle =
MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)
CustomIconButton( CustomButton(
backgroundColor = Color.White.copy(alpha = 0.25f), backgroundColor = Color.White.copy(alpha = 0.25f),
modifier = modifier modifier = modifier
.padding(vertical = 6.dp, horizontal = 12.dp) .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 subTitles = playbackData.value?.subtitleTracks ?: listOf()
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()
.setUri(url) .setUri(url)
.setTag(playbackData.value?.title) .setTag(playbackData.value?.currentItem?.title)
.setMediaId(playbackData.value?.id ?: "") .setMediaId(playbackData.value?.currentItem?.id ?: "")
.setSubtitleConfigurations( .setSubtitleConfigurations(
subTitles.filter { it.external && !it.url.isNullOrEmpty() }.map { sub -> subTitles.filter { it.external && !it.url.isNullOrEmpty() }.map { sub ->
MediaItem.SubtitleConfiguration.Builder(sub.url!!.toUri()) MediaItem.SubtitleConfiguration.Builder(sub.url!!.toUri())

View file

@ -1,5 +1,6 @@
package nl.jknaapen.fladder.objects package nl.jknaapen.fladder.objects
import AutoNextType
import PlayerSettings import PlayerSettings
import PlayerSettingsPigeon import PlayerSettingsPigeon
import androidx.compose.ui.graphics.Color 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) { override fun sendPlayerSettings(playerSettings: PlayerSettings) {
settings.value = playerSettings settings.value = playerSettings
} }

View file

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

View file

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

View file

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

View file

@ -3,14 +3,18 @@ package nl.jknaapen.fladder.utility
import AudioTrack import AudioTrack
import Chapter import Chapter
import PlayableData import PlayableData
import SimpleItemModel
import SubtitleTrack import SubtitleTrack
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
val testPlaybackData = PlayableData( val testPlaybackData = PlayableData(
id = "8lsf8234l99sdf923lsd8f23j98j", currentItem = SimpleItemModel(
title = "Big buck bunny", id = "8lsf8234l99sdf923lsd8f23j98j",
subTitle = "Episode 1x2", 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, startPosition = 0,
description = "Short description of the movie that is being watched", description = "Short description of the movie that is being watched",
defaultSubtrack = 1, defaultSubtrack = 1,
@ -88,7 +92,12 @@ val testPlaybackData = PlayableData(
) )
), ),
nextVideo = null, 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(), segments = listOf(),
url = "https://github.com/ietf-wg-cellar/matroska-test-files/raw/refs/heads/master/test_files/test5.mkv", url = "https://github.com/ietf-wg-cellar/matroska-test-files/raw/refs/heads/master/test_files/test5.mkv",
) )

View file

@ -30,6 +30,7 @@ import 'package:fladder/screens/details_screens/episode_detail_screen.dart';
import 'package:fladder/screens/details_screens/season_detail_screen.dart'; import 'package:fladder/screens/details_screens/season_detail_screen.dart';
import 'package:fladder/screens/library_search/library_search_screen.dart'; import 'package:fladder/screens/library_search/library_search_screen.dart';
import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart'; import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart';
import 'package:fladder/src/video_player_helper.g.dart' show SimpleItemModel;
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
@ -233,6 +234,17 @@ class ItemBaseModel with ItemBaseModelMappable {
); );
} }
SimpleItemModel toSimpleItem(BuildContext? context) {
return SimpleItemModel(
id: id,
title: title,
subTitle: context != null ? label(context) : null,
overview: overview.summary,
logoUrl: images?.logo?.path,
primaryPoster: images?.primary?.path ?? getPosters?.primary?.path ?? "",
);
}
FladderItemType get type => switch (this) { FladderItemType get type => switch (this) {
MovieModel _ => FladderItemType.movie, MovieModel _ => FladderItemType.movie,
SeriesModel _ => FladderItemType.series, SeriesModel _ => FladderItemType.series,

View file

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
@ -42,6 +43,11 @@ final pigeonPlayerSettingsSyncProvider = Provider<void>((ref) {
), ),
), ),
themeColor: color, themeColor: color,
autoNextType: switch (value.nextVideoType) {
AutoNextType.off => pigeon.AutoNextType.off,
AutoNextType.static => pigeon.AutoNextType.static,
AutoNextType.smart => pigeon.AutoNextType.smart,
},
skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds, skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds,
skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds, skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds,
), ),

View file

@ -377,39 +377,42 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
"${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}"), "${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}"),
}, },
), ),
if (videoSettings.wantedPlayer != PlayerOptions.nativePlayer) ...[ Column(
Column( children: [
children: [ SettingsListTile(
SettingsListTile( label: Text(context.localized.settingsAutoNextTitle),
label: Text(context.localized.settingsAutoNextTitle), subLabel: Text(context.localized.settingsAutoNextDesc),
subLabel: Text(context.localized.settingsAutoNextDesc), trailing: EnumBox(
trailing: EnumBox( current: ref.watch(
current: ref.watch( videoPlayerSettingsProvider.select(
videoPlayerSettingsProvider.select( (value) => value.nextVideoType.label(context),
(value) => value.nextVideoType.label(context),
),
), ),
itemBuilder: (context) => AutoNextType.values
.map(
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(nextVideoType: entry),
),
)
.toList(),
), ),
itemBuilder: (context) => AutoNextType.values
.map(
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(nextVideoType: entry),
),
)
.toList(),
), ),
AnimatedFadeSize( ),
child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) { AnimatedFadeSize(
AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)), child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) {
AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)), AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)),
_ => const SizedBox.shrink(), AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)),
}, _ => const SizedBox.shrink(),
), },
], ),
), ],
if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb && !ref.read(argumentsStateProvider).htpcMode) ),
if (videoSettings.wantedPlayer != PlayerOptions.nativePlayer) ...[
if (!AdaptiveLayout.of(context).isDesktop &&
!kIsWeb &&
!ref.read(argumentsStateProvider).htpcMode &&
videoSettings.wantedPlayer != PlayerOptions.nativePlayer)
SettingsListTile( SettingsListTile(
label: Text(context.localized.playerSettingsOrientationTitle), label: Text(context.localized.playerSettingsOrientationTitle),
subLabel: Text(context.localized.playerSettingsOrientationDesc), subLabel: Text(context.localized.playerSettingsOrientationDesc),

View file

@ -29,6 +29,12 @@ bool _deepEquals(Object? a, Object? b) {
} }
enum AutoNextType {
off,
static,
smart,
}
enum SegmentType { enum SegmentType {
commercial, commercial,
preview, preview,
@ -50,6 +56,7 @@ class PlayerSettings {
this.themeColor, this.themeColor,
required this.skipForward, required this.skipForward,
required this.skipBackward, required this.skipBackward,
required this.autoNextType,
}); });
bool enableTunneling; bool enableTunneling;
@ -62,6 +69,8 @@ class PlayerSettings {
int skipBackward; int skipBackward;
AutoNextType autoNextType;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[
enableTunneling, enableTunneling,
@ -69,6 +78,7 @@ class PlayerSettings {
themeColor, themeColor,
skipForward, skipForward,
skipBackward, skipBackward,
autoNextType,
]; ];
} }
@ -83,6 +93,7 @@ class PlayerSettings {
themeColor: result[2] as int?, themeColor: result[2] as int?,
skipForward: result[3]! as int, skipForward: result[3]! as int,
skipBackward: result[4]! as int, skipBackward: result[4]! as int,
autoNextType: result[5]! as AutoNextType,
); );
} }
@ -112,14 +123,17 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is SegmentType) { } else if (value is AutoNextType) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.index); writeValue(buffer, value.index);
} else if (value is SegmentSkip) { } else if (value is SegmentType) {
buffer.putUint8(130); buffer.putUint8(130);
writeValue(buffer, value.index); writeValue(buffer, value.index);
} else if (value is PlayerSettings) { } else if (value is SegmentSkip) {
buffer.putUint8(131); buffer.putUint8(131);
writeValue(buffer, value.index);
} else if (value is PlayerSettings) {
buffer.putUint8(132);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else { } else {
super.writeValue(buffer, value); super.writeValue(buffer, value);
@ -131,11 +145,14 @@ class _PigeonCodec extends StandardMessageCodec {
switch (type) { switch (type) {
case 129: case 129:
final int? value = readValue(buffer) as int?; final int? value = readValue(buffer) as int?;
return value == null ? null : SegmentType.values[value]; return value == null ? null : AutoNextType.values[value];
case 130: case 130:
final int? value = readValue(buffer) as int?; final int? value = readValue(buffer) as int?;
return value == null ? null : SegmentSkip.values[value]; return value == null ? null : SegmentType.values[value];
case 131: case 131:
final int? value = readValue(buffer) as int?;
return value == null ? null : SegmentSkip.values[value];
case 132:
return PlayerSettings.decode(readValue(buffer)!); return PlayerSettings.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);

View file

@ -47,12 +47,75 @@ enum MediaSegmentType {
outro, outro,
} }
class PlayableData { class SimpleItemModel {
PlayableData({ SimpleItemModel({
required this.id, required this.id,
required this.title, required this.title,
this.subTitle, this.subTitle,
this.overview,
this.logoUrl, this.logoUrl,
required this.primaryPoster,
});
String id;
String title;
String? subTitle;
String? overview;
String? logoUrl;
String primaryPoster;
List<Object?> _toList() {
return <Object?>[
id,
title,
subTitle,
overview,
logoUrl,
primaryPoster,
];
}
Object encode() {
return _toList(); }
static SimpleItemModel decode(Object result) {
result as List<Object?>;
return SimpleItemModel(
id: result[0]! as String,
title: result[1]! as String,
subTitle: result[2] as String?,
overview: result[3] as String?,
logoUrl: result[4] as String?,
primaryPoster: result[5]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SimpleItemModel || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
}
class PlayableData {
PlayableData({
required this.currentItem,
required this.description, required this.description,
required this.startPosition, required this.startPosition,
required this.defaultAudioTrack, required this.defaultAudioTrack,
@ -67,13 +130,7 @@ class PlayableData {
required this.url, required this.url,
}); });
String id; SimpleItemModel currentItem;
String title;
String? subTitle;
String? logoUrl;
String description; String description;
@ -93,18 +150,15 @@ class PlayableData {
List<MediaSegment> segments; List<MediaSegment> segments;
String? previousVideo; SimpleItemModel? previousVideo;
String? nextVideo; SimpleItemModel? nextVideo;
String url; String url;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[
id, currentItem,
title,
subTitle,
logoUrl,
description, description,
startPosition, startPosition,
defaultAudioTrack, defaultAudioTrack,
@ -126,22 +180,19 @@ class PlayableData {
static PlayableData decode(Object result) { static PlayableData decode(Object result) {
result as List<Object?>; result as List<Object?>;
return PlayableData( return PlayableData(
id: result[0]! as String, currentItem: result[0]! as SimpleItemModel,
title: result[1]! as String, description: result[1]! as String,
subTitle: result[2] as String?, startPosition: result[2]! as int,
logoUrl: result[3] as String?, defaultAudioTrack: result[3]! as int,
description: result[4]! as String, audioTracks: (result[4] as List<Object?>?)!.cast<AudioTrack>(),
startPosition: result[5]! as int, defaultSubtrack: result[5]! as int,
defaultAudioTrack: result[6]! as int, subtitleTracks: (result[6] as List<Object?>?)!.cast<SubtitleTrack>(),
audioTracks: (result[7] as List<Object?>?)!.cast<AudioTrack>(), trickPlayModel: result[7] as TrickPlayModel?,
defaultSubtrack: result[8]! as int, chapters: (result[8] as List<Object?>?)!.cast<Chapter>(),
subtitleTracks: (result[9] as List<Object?>?)!.cast<SubtitleTrack>(), segments: (result[9] as List<Object?>?)!.cast<MediaSegment>(),
trickPlayModel: result[10] as TrickPlayModel?, previousVideo: result[10] as SimpleItemModel?,
chapters: (result[11] as List<Object?>?)!.cast<Chapter>(), nextVideo: result[11] as SimpleItemModel?,
segments: (result[12] as List<Object?>?)!.cast<MediaSegment>(), url: result[12]! as String,
previousVideo: result[13] as String?,
nextVideo: result[14] as String?,
url: result[15]! as String,
); );
} }
@ -596,30 +647,33 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is MediaSegmentType) { } else if (value is MediaSegmentType) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.index); writeValue(buffer, value.index);
} else if (value is PlayableData) { } else if (value is SimpleItemModel) {
buffer.putUint8(130); buffer.putUint8(130);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is MediaSegment) { } else if (value is PlayableData) {
buffer.putUint8(131); buffer.putUint8(131);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is AudioTrack) { } else if (value is MediaSegment) {
buffer.putUint8(132); buffer.putUint8(132);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is SubtitleTrack) { } else if (value is AudioTrack) {
buffer.putUint8(133); buffer.putUint8(133);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is Chapter) { } else if (value is SubtitleTrack) {
buffer.putUint8(134); buffer.putUint8(134);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is TrickPlayModel) { } else if (value is Chapter) {
buffer.putUint8(135); buffer.putUint8(135);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is StartResult) { } else if (value is TrickPlayModel) {
buffer.putUint8(136); buffer.putUint8(136);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is PlaybackState) { } else if (value is StartResult) {
buffer.putUint8(137); buffer.putUint8(137);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is PlaybackState) {
buffer.putUint8(138);
writeValue(buffer, value.encode());
} else { } else {
super.writeValue(buffer, value); super.writeValue(buffer, value);
} }
@ -632,20 +686,22 @@ class _PigeonCodec extends StandardMessageCodec {
final int? value = readValue(buffer) as int?; final int? value = readValue(buffer) as int?;
return value == null ? null : MediaSegmentType.values[value]; return value == null ? null : MediaSegmentType.values[value];
case 130: case 130:
return PlayableData.decode(readValue(buffer)!); return SimpleItemModel.decode(readValue(buffer)!);
case 131: case 131:
return MediaSegment.decode(readValue(buffer)!); return PlayableData.decode(readValue(buffer)!);
case 132: case 132:
return AudioTrack.decode(readValue(buffer)!); return MediaSegment.decode(readValue(buffer)!);
case 133: case 133:
return SubtitleTrack.decode(readValue(buffer)!); return AudioTrack.decode(readValue(buffer)!);
case 134: case 134:
return Chapter.decode(readValue(buffer)!); return SubtitleTrack.decode(readValue(buffer)!);
case 135: case 135:
return TrickPlayModel.decode(readValue(buffer)!); return Chapter.decode(readValue(buffer)!);
case 136: case 136:
return StartResult.decode(readValue(buffer)!); return TrickPlayModel.decode(readValue(buffer)!);
case 137: case 137:
return StartResult.decode(readValue(buffer)!);
case 138:
return PlaybackState.decode(readValue(buffer)!); return PlaybackState.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);

View file

@ -100,15 +100,12 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback {
Duration startPosition, Duration startPosition,
) async { ) async {
final playableData = PlayableData( final playableData = PlayableData(
id: model.item.id, currentItem: model.item.toSimpleItem(context),
title: model.item.title,
subTitle: context != null ? model.item.label(context) : "",
logoUrl: model.item.getPosters?.logo?.path,
startPosition: startPosition.inMilliseconds, startPosition: startPosition.inMilliseconds,
description: model.item.overview.summary, description: model.item.overview.summary,
defaultAudioTrack: model.mediaStreams?.defaultAudioStreamIndex ?? 1, defaultAudioTrack: model.mediaStreams?.defaultAudioStreamIndex ?? 1,
nextVideo: model.nextVideo?.name, nextVideo: model.nextVideo?.toSimpleItem(context),
previousVideo: model.previousVideo?.name, previousVideo: model.previousVideo?.toSimpleItem(context),
audioTracks: model.audioStreams audioTracks: model.audioStreams
?.map( ?.map(
(audio) => AudioTrack( (audio) => AudioTrack(

View file

@ -18,6 +18,7 @@ class PlayerSettings {
final int? themeColor; final int? themeColor;
final int skipForward; final int skipForward;
final int skipBackward; final int skipBackward;
final AutoNextType autoNextType;
const PlayerSettings({ const PlayerSettings({
required this.enableTunneling, required this.enableTunneling,
@ -25,9 +26,16 @@ class PlayerSettings {
required this.themeColor, required this.themeColor,
required this.skipForward, required this.skipForward,
required this.skipBackward, required this.skipBackward,
required this.autoNextType,
}); });
} }
enum AutoNextType {
off,
static,
smart,
}
enum SegmentType { enum SegmentType {
commercial, commercial,
preview, preview,

View file

@ -9,11 +9,26 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'nl_jknaapen_fladder.video', dartPackageName: 'nl_jknaapen_fladder.video',
), ),
) )
class PlayableData { class SimpleItemModel {
final String id; final String id;
final String title; final String title;
final String? subTitle; final String? subTitle;
final String? overview;
final String? logoUrl; final String? logoUrl;
final String primaryPoster;
const SimpleItemModel({
required this.id,
required this.title,
this.subTitle,
this.overview,
this.logoUrl,
required this.primaryPoster,
});
}
class PlayableData {
final SimpleItemModel currentItem;
final String description; final String description;
final int startPosition; final int startPosition;
final int defaultAudioTrack; final int defaultAudioTrack;
@ -23,15 +38,12 @@ class PlayableData {
final TrickPlayModel? trickPlayModel; final TrickPlayModel? trickPlayModel;
final List<Chapter> chapters; final List<Chapter> chapters;
final List<MediaSegment> segments; final List<MediaSegment> segments;
final String? previousVideo; final SimpleItemModel? previousVideo;
final String? nextVideo; final SimpleItemModel? nextVideo;
final String url; final String url;
PlayableData({ PlayableData({
required this.id, required this.currentItem,
required this.title,
this.subTitle,
this.logoUrl,
required this.description, required this.description,
required this.startPosition, required this.startPosition,
required this.defaultAudioTrack, required this.defaultAudioTrack,