From 29b1c2e633040479be8a3f91bebea8d5f76d1d1c Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Wed, 15 Oct 2025 18:05:51 +0200 Subject: [PATCH] feat: Implement next-up screen for native player (#533) Co-authored-by: PartyDonut --- .../main/kotlin/nl/jknaapen/fladder/Theme.kt | 4 +- .../fladder/api/PlayerSettingsHelper.g.kt | 38 ++- .../fladder/api/VideoPlayerHelper.g.kt | 121 ++++--- .../{CustomIconButton.kt => CustomButton.kt} | 52 ++- .../composables/controls/ItemHeader.kt | 4 +- .../composables/controls/ProgressBar.kt | 2 +- .../composables/controls/SkipOverlay.kt | 2 +- .../controls/VideoPlayerControls.kt | 20 +- .../dialogs/ChapterSelectionSheet.kt | 2 + .../composables/dialogs/TrackButton.kt | 4 +- .../composables/overlays/NextUpOverlay.kt | 316 ++++++++++++++++++ .../messengers/VideoPlayerImplementation.kt | 4 +- .../fladder/objects/PlayerSettingsObject.kt | 5 + .../fladder/objects/VideoPlayerObject.kt | 2 + .../nl/jknaapen/fladder/player/ExoPlayer.kt | 56 ++-- .../nl/jknaapen/fladder/utility/Modifiers.kt | 37 +- .../fladder/utility/TestPlaybackData.kt | 17 +- lib/models/item_base_model.dart | 12 + .../pigeon_player_settings_provider.dart | 6 + .../settings/player_settings_page.dart | 63 ++-- lib/src/player_settings_helper.g.dart | 27 +- lib/src/video_player_helper.g.dart | 148 +++++--- lib/wrappers/players/native_player.dart | 9 +- pigeons/player_settings_pigeon.dart | 8 + pigeons/video_player.dart | 26 +- 25 files changed, 782 insertions(+), 203 deletions(-) rename android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/{CustomIconButton.kt => CustomButton.kt} (59%) create mode 100644 android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/NextUpOverlay.kt diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt index 6daf03f..6b74d98 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt @@ -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 } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt index 06c3e89..e18e1ce 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt @@ -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, 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 { @@ -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)?.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) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt index d730a7d..0a96969 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt @@ -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): 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 { + 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, val segments: List, - 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): 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 - val defaultSubtrack = pigeonVar_list[8] as Long - val subtitleTracks = pigeonVar_list[9] as List - val trickPlayModel = pigeonVar_list[10] as TrickPlayModel? - val chapters = pigeonVar_list[11] as List - val segments = pigeonVar_list[12] as List - 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 + val defaultSubtrack = pigeonVar_list[5] as Long + val subtitleTracks = pigeonVar_list[6] as List + val trickPlayModel = pigeonVar_list[7] as TrickPlayModel? + val chapters = pigeonVar_list[8] as List + val segments = pigeonVar_list[9] as List + 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 { 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)?.let { - PlayableData.fromList(it) + SimpleItemModel.fromList(it) } } 131.toByte() -> { return (readValue(buffer) as? List)?.let { - MediaSegment.fromList(it) + PlayableData.fromList(it) } } 132.toByte() -> { return (readValue(buffer) as? List)?.let { - AudioTrack.fromList(it) + MediaSegment.fromList(it) } } 133.toByte() -> { return (readValue(buffer) as? List)?.let { - SubtitleTrack.fromList(it) + AudioTrack.fromList(it) } } 134.toByte() -> { return (readValue(buffer) as? List)?.let { - Chapter.fromList(it) + SubtitleTrack.fromList(it) } } 135.toByte() -> { return (readValue(buffer) as? List)?.let { - TrickPlayModel.fromList(it) + Chapter.fromList(it) } } 136.toByte() -> { return (readValue(buffer) as? List)?.let { - StartResult.fromList(it) + TrickPlayModel.fromList(it) } } 137.toByte() -> { + return (readValue(buffer) as? List)?.let { + StartResult.fromList(it) + } + } + 138.toByte() -> { return (readValue(buffer) as? List)?.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) } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomButton.kt similarity index 59% rename from android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt rename to android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomButton.kt index 1ef4d40..aaf9b4d 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomButton.kt @@ -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( diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt index d55053a..8bb67cb 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt @@ -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 diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt index 8289ee0..426a5c7 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt @@ -120,7 +120,7 @@ internal fun ProgressBar( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { val progressBarTopLabel = listOf( - playableData?.subTitle, + playableData?.currentItem?.subTitle, endTimeString, ) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt index 9631a78..bfb386c 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt @@ -92,7 +92,7 @@ internal fun BoxScope.SegmentSkipOverlay( .padding(16.dp) .safeContentPadding() ) { - CustomIconButton( + CustomButton( modifier = modifier .focusRequester(focusRequester) .defaultSelected(true), diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt index c9ec56c..23204cc 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt @@ -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 }, diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt index 268d51f..a36c971 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt @@ -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), diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt index 0bfb090..1eacd1e 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt @@ -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) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/NextUpOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/NextUpOverlay.kt new file mode 100644 index 0000000..5363173 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/NextUpOverlay.kt @@ -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 +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt index e24a6f6..167be48 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt @@ -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()) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt index 72fb3e3..0b7fa32 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt @@ -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 } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt index c5779f2..0ed35ee 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt @@ -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 -> diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt index 830d23a..f4d9903 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -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 { 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) + } } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt index 1871444..a196ae8 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt @@ -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 diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt index 44e2e30..4ad3ef2 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt @@ -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", ) \ No newline at end of file diff --git a/lib/models/item_base_model.dart b/lib/models/item_base_model.dart index b7c9a08..d4cd7fe 100644 --- a/lib/models/item_base_model.dart +++ b/lib/models/item_base_model.dart @@ -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, diff --git a/lib/providers/settings/pigeon_player_settings_provider.dart b/lib/providers/settings/pigeon_player_settings_provider.dart index 55a78b8..a427f2d 100644 --- a/lib/providers/settings/pigeon_player_settings_provider.dart +++ b/lib/providers/settings/pigeon_player_settings_provider.dart @@ -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((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, ), diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index c9effad..d61d6ab 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -377,39 +377,42 @@ class _PlayerSettingsPageState extends ConsumerState { "${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}"), }, ), - if (videoSettings.wantedPlayer != PlayerOptions.nativePlayer) ...[ - Column( - children: [ - SettingsListTile( - label: Text(context.localized.settingsAutoNextTitle), - subLabel: Text(context.localized.settingsAutoNextDesc), - trailing: EnumBox( - current: ref.watch( - videoPlayerSettingsProvider.select( - (value) => value.nextVideoType.label(context), - ), + Column( + children: [ + SettingsListTile( + label: Text(context.localized.settingsAutoNextTitle), + subLabel: Text(context.localized.settingsAutoNextDesc), + trailing: EnumBox( + current: ref.watch( + videoPlayerSettingsProvider.select( + (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))) { - AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)), - AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)), - _ => const SizedBox.shrink(), - }, - ), - ], - ), - if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb && !ref.read(argumentsStateProvider).htpcMode) + ), + AnimatedFadeSize( + child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) { + AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)), + AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)), + _ => const SizedBox.shrink(), + }, + ), + ], + ), + 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), diff --git a/lib/src/player_settings_helper.g.dart b/lib/src/player_settings_helper.g.dart index e71e63b..0697221 100644 --- a/lib/src/player_settings_helper.g.dart +++ b/lib/src/player_settings_helper.g.dart @@ -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 _toList() { return [ 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); diff --git a/lib/src/video_player_helper.g.dart b/lib/src/video_player_helper.g.dart index e6e6c22..b8f9716 100644 --- a/lib/src/video_player_helper.g.dart +++ b/lib/src/video_player_helper.g.dart @@ -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 _toList() { + return [ + id, + title, + subTitle, + overview, + logoUrl, + primaryPoster, + ]; + } + + Object encode() { + return _toList(); } + + static SimpleItemModel decode(Object result) { + result as List; + 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 segments; - String? previousVideo; + SimpleItemModel? previousVideo; - String? nextVideo; + SimpleItemModel? nextVideo; String url; List _toList() { return [ - id, - title, - subTitle, - logoUrl, + currentItem, description, startPosition, defaultAudioTrack, @@ -126,22 +180,19 @@ class PlayableData { static PlayableData decode(Object result) { result as List; 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?)!.cast(), - defaultSubtrack: result[8]! as int, - subtitleTracks: (result[9] as List?)!.cast(), - trickPlayModel: result[10] as TrickPlayModel?, - chapters: (result[11] as List?)!.cast(), - segments: (result[12] as List?)!.cast(), - 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?)!.cast(), + defaultSubtrack: result[5]! as int, + subtitleTracks: (result[6] as List?)!.cast(), + trickPlayModel: result[7] as TrickPlayModel?, + chapters: (result[8] as List?)!.cast(), + segments: (result[9] as List?)!.cast(), + 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); diff --git a/lib/wrappers/players/native_player.dart b/lib/wrappers/players/native_player.dart index 8016374..4607d8e 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -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( diff --git a/pigeons/player_settings_pigeon.dart b/pigeons/player_settings_pigeon.dart index 17f5767..d38fe90 100644 --- a/pigeons/player_settings_pigeon.dart +++ b/pigeons/player_settings_pigeon.dart @@ -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, diff --git a/pigeons/video_player.dart b/pigeons/video_player.dart index 042dfa2..835b108 100644 --- a/pigeons/video_player.dart +++ b/pigeons/video_player.dart @@ -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 chapters; final List 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,