Initial skip segments implementation

This commit is contained in:
Damontecres 2025-10-09 21:10:43 -04:00
parent 165fd80c0e
commit 405faa388e
No known key found for this signature in database
11 changed files with 262 additions and 9 deletions

View file

@ -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<T> {
title = R.string.license_info,
destination = Destination.License,
)
val AdvancedSettings =
AppDestinationPreference(
title = R.string.advanced_settings,
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
)
val SkipIntros =
AppChoicePreference<SkipSegmentBehavior>(
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<SkipSegmentBehavior>(
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<SkipSegmentBehavior>(
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<SkipSegmentBehavior>(
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<SkipSegmentBehavior>(
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<PreferenceGroup>()
val advancedPreferences = listOf<PreferenceGroup>()
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,

View file

@ -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

View file

@ -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()

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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<List<Cue>>(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) {

View file

@ -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<CurrentPlayback?>(null)
val trickplay = MutableLiveData<TrickplayInfo?>(null)
val chapters = MutableLiveData<List<Chapter>>(listOf())
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(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<ApiRequestPager<GetEpisodesRequest>>()
private var currentEpisodeIndex = Int.MAX_VALUE
val nextUpEpisode = MutableLiveData<BaseItem?>()
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)

View file

@ -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<T> : MutableLiveData<T> {
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)
}
}
}

View file

@ -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{

View file

@ -16,4 +16,10 @@
<item>Orange</item>
</string-array>
<string-array name="skip_behaviors">
<item>Ignore</item>
<item>Skip automatically</item>
<item>Ask to skip</item>
</string-array>
</resources>

View file

@ -63,5 +63,10 @@
<string name="director">Director</string>
<string name="max_bitrate">Max bitrate</string>
<string name="app_theme">Application Theme</string>
<string name="skip_recap_behavior">Skip recap behavior</string>
<string name="skip_previews_behavior">Skip previews behavior</string>
<string name="skip_comercials_behavior">Skip commercials behavior</string>
<string name="skip_outro_behavior">Skip outro behavior</string>
<string name="skip_intro_behavior">Skip intro behavior</string>
</resources>