mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 13:38:13 -08:00
feat: Implement next-up screen for native player (#533)
Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
parent
311b647286
commit
29b1c2e633
25 changed files with 782 additions and 203 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ internal fun ProgressBar(
|
|||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val progressBarTopLabel = listOf(
|
||||
playableData?.subTitle,
|
||||
playableData?.currentItem?.subTitle,
|
||||
endTimeString,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ internal fun BoxScope.SegmentSkipOverlay(
|
|||
.padding(16.dp)
|
||||
.safeContentPadding()
|
||||
) {
|
||||
CustomIconButton(
|
||||
CustomButton(
|
||||
modifier = modifier
|
||||
.focusRequester(focusRequester)
|
||||
.defaultSelected(true),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -4,16 +4,14 @@ import PlaybackState
|
|||
import android.app.ActivityManager
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.getSystemService
|
||||
|
|
@ -35,11 +33,14 @@ import androidx.media3.ui.CaptionStyleCompat
|
|||
import androidx.media3.ui.PlayerView
|
||||
import io.github.peerless2012.ass.media.kt.buildWithAssSupport
|
||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||
import kotlinx.coroutines.delay
|
||||
import nl.jknaapen.fladder.composables.overlays.NextUpOverlay
|
||||
import nl.jknaapen.fladder.messengers.properlySetSubAndAudioTracks
|
||||
import nl.jknaapen.fladder.objects.PlayerSettingsObject
|
||||
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||
import nl.jknaapen.fladder.utility.getAudioTracks
|
||||
import nl.jknaapen.fladder.utility.getSubtitleTracks
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
val LocalPlayer = compositionLocalOf<ExoPlayer?> { null }
|
||||
|
||||
|
|
@ -108,9 +109,29 @@ internal fun ExoPlayer(
|
|||
)
|
||||
}
|
||||
|
||||
fun updatePlaybackState() {
|
||||
videoHost.setPlaybackState(
|
||||
PlaybackState(
|
||||
position = exoPlayer.currentPosition,
|
||||
buffered = exoPlayer.bufferedPosition,
|
||||
duration = exoPlayer.duration,
|
||||
playing = exoPlayer.isPlaying,
|
||||
buffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
|
||||
completed = exoPlayer.playbackState == Player.STATE_ENDED,
|
||||
failed = exoPlayer.playbackState == Player.STATE_IDLE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer) {
|
||||
while (true) {
|
||||
updatePlaybackState()
|
||||
delay(1.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
val listener = object : Player.Listener {
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
videoHost.setPlaybackState(
|
||||
PlaybackState(
|
||||
|
|
@ -127,17 +148,7 @@ internal fun ExoPlayer(
|
|||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
videoHost.setPlaybackState(
|
||||
PlaybackState(
|
||||
position = exoPlayer.currentPosition,
|
||||
buffered = exoPlayer.bufferedPosition,
|
||||
duration = exoPlayer.duration,
|
||||
playing = exoPlayer.isPlaying,
|
||||
buffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
|
||||
completed = exoPlayer.playbackState == Player.STATE_ENDED,
|
||||
failed = exoPlayer.playbackState == Player.STATE_IDLE
|
||||
)
|
||||
)
|
||||
updatePlaybackState()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
|
|
@ -168,13 +179,13 @@ internal fun ExoPlayer(
|
|||
}
|
||||
|
||||
|
||||
Box(
|
||||
NextUpOverlay(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
) { showControls ->
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
factory = {
|
||||
PlayerView(it).apply {
|
||||
player = exoPlayer
|
||||
|
|
@ -198,8 +209,9 @@ internal fun ExoPlayer(
|
|||
}
|
||||
},
|
||||
)
|
||||
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
|
||||
controls(exoPlayer)
|
||||
}
|
||||
if (showControls)
|
||||
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
|
||||
controls(exoPlayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,30 +30,41 @@ import androidx.compose.ui.unit.dp
|
|||
fun Modifier.highlightOnFocus(
|
||||
color: Color = Color.White.copy(alpha = 0.85f),
|
||||
width: Dp = 1.5.dp,
|
||||
shape: Shape = RoundedCornerShape(16.dp)
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
useClip: Boolean = true,
|
||||
): Modifier = composed {
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
val highlightModifier = remember {
|
||||
if (width != 0.dp) {
|
||||
if (!useClip) {
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
color = color.copy(alpha = 0.25f),
|
||||
shape = shape,
|
||||
)
|
||||
.border(
|
||||
width = width,
|
||||
color = color.copy(alpha = 0.5f),
|
||||
shape = shape
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
color = color.copy(alpha = 0.25f),
|
||||
shape = shape,
|
||||
)
|
||||
}
|
||||
} else
|
||||
if (width != 0.dp) {
|
||||
Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
color = color.copy(alpha = 0.25f),
|
||||
shape = shape,
|
||||
)
|
||||
.border(
|
||||
width = width,
|
||||
color = color.copy(alpha = 0.5f),
|
||||
shape = shape
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
color = color.copy(alpha = 0.25f),
|
||||
shape = shape,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ package nl.jknaapen.fladder.utility
|
|||
import AudioTrack
|
||||
import Chapter
|
||||
import PlayableData
|
||||
import SimpleItemModel
|
||||
import SubtitleTrack
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.DurationUnit
|
||||
|
||||
val testPlaybackData = PlayableData(
|
||||
id = "8lsf8234l99sdf923lsd8f23j98j",
|
||||
title = "Big buck bunny",
|
||||
subTitle = "Episode 1x2",
|
||||
currentItem = SimpleItemModel(
|
||||
id = "8lsf8234l99sdf923lsd8f23j98j",
|
||||
title = "Big buck bunny",
|
||||
subTitle = "Episode 1x2",
|
||||
primaryPoster = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.ytimg.com%2Fvi%2Faqz-KE-bpKQ%2Fmaxresdefault.jpg&f=1&nofb=1&ipt=4e375598bf8cc78e681ee62de9111dea32b85972ae756e40a1eddac01aa79f80"
|
||||
),
|
||||
startPosition = 0,
|
||||
description = "Short description of the movie that is being watched",
|
||||
defaultSubtrack = 1,
|
||||
|
|
@ -88,7 +92,12 @@ val testPlaybackData = PlayableData(
|
|||
)
|
||||
),
|
||||
nextVideo = null,
|
||||
previousVideo = "Previous episode name",
|
||||
previousVideo = SimpleItemModel(
|
||||
id = "8lsf8234l99sdf923lsd8f23j98j",
|
||||
title = "Big buck bunny",
|
||||
subTitle = "Episode 1x26",
|
||||
primaryPoster = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.ytimg.com%2Fvi%2Faqz-KE-bpKQ%2Fmaxresdefault.jpg&f=1&nofb=1&ipt=4e375598bf8cc78e681ee62de9111dea32b85972ae756e40a1eddac01aa79f80"
|
||||
),
|
||||
segments = listOf(),
|
||||
url = "https://github.com/ietf-wg-cellar/matroska-test-files/raw/refs/heads/master/test_files/test5.mkv",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue