diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt index 1c7c97e2..575bd939 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt @@ -6,6 +6,7 @@ import androidx.annotation.StringRes import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.preferences.PreferenceGroup +import com.github.damontecres.dolphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.dolphin.ui.preferences.PreferenceValidation import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes @@ -360,6 +361,77 @@ sealed interface AppPreference { title = R.string.license_info, destination = Destination.License, ) + + val AdvancedSettings = + AppDestinationPreference( + title = R.string.advanced_settings, + destination = Destination.Settings(PreferenceScreenOption.ADVANCED), + ) + + val SkipIntros = + AppChoicePreference( + title = R.string.skip_intro_behavior, + defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, + getter = { it.playbackPreferences.skipIntros }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { skipIntros = value } + }, + displayValues = R.array.skip_behaviors, + indexToValue = { SkipSegmentBehavior.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val SkipOutros = + AppChoicePreference( + title = R.string.skip_outro_behavior, + defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, + getter = { it.playbackPreferences.skipOutros }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { skipOutros = value } + }, + displayValues = R.array.skip_behaviors, + indexToValue = { SkipSegmentBehavior.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val SkipCommercials = + AppChoicePreference( + title = R.string.skip_comercials_behavior, + defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, + getter = { it.playbackPreferences.skipCommercials }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { skipCommercials = value } + }, + displayValues = R.array.skip_behaviors, + indexToValue = { SkipSegmentBehavior.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val SkipPreviews = + AppChoicePreference( + title = R.string.skip_previews_behavior, + defaultValue = SkipSegmentBehavior.IGNORE, + getter = { it.playbackPreferences.skipPreviews }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { skipPreviews = value } + }, + displayValues = R.array.skip_behaviors, + indexToValue = { SkipSegmentBehavior.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val SkipRecaps = + AppChoicePreference( + title = R.string.skip_recap_behavior, + defaultValue = SkipSegmentBehavior.IGNORE, + getter = { it.playbackPreferences.skipRecaps }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { skipRecaps = value } + }, + displayValues = R.array.skip_behaviors, + indexToValue = { SkipSegmentBehavior.forNumber(it) }, + valueToIndex = { it.number }, + ) } } @@ -386,8 +458,6 @@ val basicPreferences = AppPreference.AutoPlayNextUp, AppPreference.AutoPlayNextDelay, AppPreference.SkipBackOnResume, - AppPreference.MaxBitrate, - AppPreference.PlaybackDebugInfo, ), ), PreferenceGroup( @@ -398,11 +468,33 @@ val basicPreferences = AppPreference.OssLicenseInfo, ), ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( + AppPreference.AdvancedSettings, + ), + ), ) val uiPreferences = listOf() -val advancedPreferences = listOf() +val advancedPreferences = + listOf( + PreferenceGroup( + title = R.string.playback, + preferences = + listOf( + AppPreference.SkipIntros, + AppPreference.SkipOutros, + AppPreference.SkipCommercials, + AppPreference.SkipPreviews, + AppPreference.SkipRecaps, + AppPreference.MaxBitrate, + AppPreference.PlaybackDebugInfo, + ), + ), + ) data class AppSwitchPreference( @get:StringRes override val title: Int, diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt index 193b527a..23b50f75 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt @@ -34,6 +34,11 @@ class AppPreferencesSerializer skipBackOnResumeSeconds = AppPreference.SkipBackOnResume.defaultValue.seconds.inWholeMilliseconds maxBitrate = AppPreference.DEFAULT_BITRATE + skipIntros = AppPreference.SkipIntros.defaultValue + skipOutros = AppPreference.SkipOutros.defaultValue + skipCommercials = AppPreference.SkipCommercials.defaultValue + skipPreviews = AppPreference.SkipPreviews.defaultValue + skipRecaps = AppPreference.SkipRecaps.defaultValue }.build() homePagePreferences = HomePagePreferences diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index 904063d5..4b1e50a4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -5,6 +5,7 @@ package com.github.damontecres.dolphin.ui.nav import androidx.navigation3.runtime.NavKey import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode +import com.github.damontecres.dolphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.dolphin.util.UuidSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -28,7 +29,9 @@ sealed class Destination( ) : Destination() @Serializable - data object Settings : Destination(true) + data class Settings( + val screen: PreferenceScreenOption, + ) : Destination(true) @Serializable data object Search : Destination() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index b3e0c50e..43c998d2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -16,7 +16,6 @@ import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.main.HomePage import com.github.damontecres.dolphin.ui.main.SearchPage import com.github.damontecres.dolphin.ui.playback.PlaybackContent -import com.github.damontecres.dolphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.dolphin.ui.preferences.PreferencesPage import com.github.damontecres.dolphin.ui.setup.SwitchServerContent import com.github.damontecres.dolphin.ui.setup.SwitchUserContent @@ -49,10 +48,10 @@ fun DestinationContent( Destination.ServerList -> SwitchServerContent(modifier) Destination.UserList -> SwitchUserContent(modifier) - Destination.Settings -> + is Destination.Settings -> PreferencesPage( preferences.appPreferences, - PreferenceScreenOption.BASIC, + destination.screen, modifier, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index 67b6410f..622772b6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -60,6 +60,7 @@ import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.ifElse +import com.github.damontecres.dolphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.dolphin.ui.spacedByWithFooter import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.util.ExceptionHandler @@ -218,7 +219,11 @@ fun NavDrawer( icon = Icons.Default.Settings, selected = false, onClick = { - viewModel.navigationManager.navigateTo(Destination.Settings) + viewModel.navigationManager.navigateTo( + Destination.Settings( + PreferenceScreenOption.BASIC, + ), + ) }, modifier = Modifier, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt index bd842951..aca7ba0b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -54,7 +54,9 @@ import androidx.media3.ui.compose.state.rememberNextButtonState import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState import androidx.media3.ui.compose.state.rememberPreviousButtonState +import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.skipBackOnResume @@ -65,6 +67,7 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.util.seasonEpisode import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -94,6 +97,7 @@ fun PlaybackContent( val trickplay by viewModel.trickplay.observeAsState(null) val chapters by viewModel.chapters.observeAsState(listOf()) val currentPlayback by viewModel.currentPlayback.observeAsState(null) + val currentSegment by viewModel.currentSegment.observeAsState(null) var cues by remember { mutableStateOf>(listOf()) } var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @@ -315,6 +319,29 @@ fun PlaybackContent( .background(Color.Transparent), ) } + + AnimatedVisibility( + currentSegment != null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L, + modifier = + Modifier + .padding(40.dp) + .align(Alignment.BottomEnd), + ) { + currentSegment?.let { segment -> + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Button( + onClick = { + player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + }, + modifier = Modifier.focusRequester(focusRequester), + ) { + Text( + text = "Skip ${segment.type.serialName}", + ) + } + } + } } BackHandler(nextUp != null) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index e7d0dce5..6346814b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -17,11 +17,13 @@ import androidx.media3.exoplayer.ExoPlayer import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Chapter import com.github.damontecres.dolphin.preferences.AppPreference +import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.indexOfFirstOrNull import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.util.ApiRequestPager +import com.github.damontecres.dolphin.util.EqualityMutableLiveData import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener @@ -33,28 +35,36 @@ import com.github.damontecres.dolphin.util.subtitleMimeTypes import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi import org.jellyfin.sdk.api.client.extensions.trickplayApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.request.GetEpisodesRequest +import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID import javax.inject.Inject import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds enum class TranscodeType { DIRECT_PLAY, @@ -93,17 +103,20 @@ class PlaybackViewModel val currentPlayback = MutableLiveData(null) val trickplay = MutableLiveData(null) val chapters = MutableLiveData>(listOf()) + val currentSegment = EqualityMutableLiveData(null) private lateinit var preferences: UserPreferences private lateinit var deviceProfile: DeviceProfile private lateinit var itemId: UUID private lateinit var dto: BaseItemDto + private var activityListener: TrackActivityPlaybackListener? = null private val episodes = MutableLiveData>() private var currentEpisodeIndex = Int.MAX_VALUE val nextUpEpisode = MutableLiveData() init { + addCloseable { this@PlaybackViewModel.activityListener?.release() } addCloseable { player.release() } } @@ -231,10 +244,15 @@ class PlaybackViewModel withContext(Dispatchers.Main) { this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.subtitleStreams.value = subtitleStreams + this@PlaybackViewModel.activityListener?.let { + it.release() + player.removeListener(it) + } val activityListener = TrackActivityPlaybackListener(api, itemId, player) - addCloseable { activityListener.release() } player.addListener(activityListener) + this@PlaybackViewModel.activityListener = activityListener + changeStreams( itemId, audioIndex, @@ -249,6 +267,7 @@ class PlaybackViewModel if (base.type == BaseItemKind.EPISODE) { base.seriesId?.let(::getEpisodes) } + listenForSegments() } } @@ -485,6 +504,59 @@ class PlaybackViewModel } } + private var segmentJob: Job? = null + + private fun listenForSegments() { + segmentJob?.cancel() + segmentJob = + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + val prefs = preferences.appPreferences.playbackPreferences + val segments by api.mediaSegmentsApi.getItemSegments(itemId) + if (segments.items.isNotEmpty()) { + while (isActive) { + delay(500L) + val currentTicks = player.currentPosition.milliseconds.inWholeTicks + val currentSegment = + segments.items + .firstOrNull { + it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks + } + if (currentSegment != null) { + val behavior = + when (currentSegment.type) { + MediaSegmentType.COMMERCIAL -> prefs.skipCommercials + MediaSegmentType.PREVIEW -> prefs.skipPreviews + MediaSegmentType.RECAP -> prefs.skipRecaps + MediaSegmentType.OUTRO -> prefs.skipIntros + MediaSegmentType.INTRO -> prefs.skipOutros + MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE + } + withContext(Dispatchers.Main) { + when (behavior) { + SkipSegmentBehavior.AUTO_SKIP -> { + this@PlaybackViewModel.currentSegment.value = null + player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) + } + + SkipSegmentBehavior.ASK_TO_SKIP -> { + this@PlaybackViewModel.currentSegment.value = currentSegment + } + + else -> { + this@PlaybackViewModel.currentSegment.value = null + } + } + } + } else { + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentSegment.value = null + } + } + } + } + } + } + fun playUpNextEpisode() { nextUpEpisode.value?.let { init(Destination.Playback(it.id, 0, it), deviceProfile, preferences) diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/EqualityMutableLiveData.kt b/app/src/main/java/com/github/damontecres/dolphin/util/EqualityMutableLiveData.kt new file mode 100644 index 00000000..e3c4302c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/util/EqualityMutableLiveData.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.dolphin.util + +import androidx.lifecycle.MutableLiveData + +/** + * A [MutableLiveData] that only notifies observers if the value does not equal the previous value + */ +class EqualityMutableLiveData : MutableLiveData { + constructor() : super() + constructor(value: T) : super(value) + + override fun setValue(value: T?) { + if (value != getValue()) { + super.setValue(value) + } + } + + fun setValueNoCheck(value: T?) { + super.setValue(value) + } + + override fun postValue(value: T?) { + if (value != getValue()) { + super.postValue(value) + } + } +} diff --git a/app/src/main/proto/DolphinDataStore.proto b/app/src/main/proto/DolphinDataStore.proto index 4a352e0f..bbb2c1a3 100644 --- a/app/src/main/proto/DolphinDataStore.proto +++ b/app/src/main/proto/DolphinDataStore.proto @@ -3,6 +3,12 @@ syntax = "proto3"; option java_package = "com.github.damontecres.dolphin.preferences"; option java_multiple_files = true; +enum SkipSegmentBehavior{ + IGNORE = 0; + AUTO_SKIP = 1; + ASK_TO_SKIP = 2; +} + message PlaybackPreferences { int64 skip_forward_ms = 1; int64 skip_back_ms = 2; @@ -13,6 +19,12 @@ message PlaybackPreferences { int64 auto_play_next_delay_seconds = 7; int64 skip_back_on_resume_seconds = 8; int64 max_bitrate = 9; + + SkipSegmentBehavior skip_intros = 10; + SkipSegmentBehavior skip_outros = 11; + SkipSegmentBehavior skip_commercials = 12; + SkipSegmentBehavior skip_recaps = 13; + SkipSegmentBehavior skip_previews = 14; } message HomePagePreferences{ diff --git a/app/src/main/res/values/preferences.xml b/app/src/main/res/values/preferences.xml index 208084f6..2d207bfe 100644 --- a/app/src/main/res/values/preferences.xml +++ b/app/src/main/res/values/preferences.xml @@ -16,4 +16,10 @@ Orange + + Ignore + Skip automatically + Ask to skip + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1684d675..ca64344f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,5 +63,10 @@ Director Max bitrate Application Theme + Skip recap behavior + Skip previews behavior + Skip commercials behavior + Skip outro behavior + Skip intro behavior