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,6 +209,7 @@ internal fun ExoPlayer(
}
},
)
if (showControls)
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
controls(exoPlayer)
}

View file

@ -30,13 +30,24 @@ 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 (!useClip) {
Modifier
.background(
color = color.copy(alpha = 0.25f),
)
.border(
width = width,
color = color.copy(alpha = 0.5f),
)
} else
if (width != 0.dp) {
Modifier
.clip(RoundedCornerShape(8.dp))
.clip(shape)
.background(
color = color.copy(alpha = 0.25f),
shape = shape,
@ -48,7 +59,7 @@ fun Modifier.highlightOnFocus(
)
} else {
Modifier
.clip(RoundedCornerShape(8.dp))
.clip(shape)
.background(
color = color.copy(alpha = 0.25f),
shape = shape,

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(
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",
)

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/library_search/library_search_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/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) {
MovieModel _ => FladderItemType.movie,
SeriesModel _ => FladderItemType.series,

View file

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
@ -42,6 +43,11 @@ final pigeonPlayerSettingsSyncProvider = Provider<void>((ref) {
),
),
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,
skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds,
),

View file

@ -377,7 +377,6 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
"${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}"),
},
),
if (videoSettings.wantedPlayer != PlayerOptions.nativePlayer) ...[
Column(
children: [
SettingsListTile(
@ -409,7 +408,11 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
),
],
),
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(
label: Text(context.localized.playerSettingsOrientationTitle),
subLabel: Text(context.localized.playerSettingsOrientationDesc),

View file

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

View file

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

View file

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

View file

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

View file

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