Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement media segments #4052

Merged
merged 2 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
Expand Down Expand Up @@ -120,6 +122,7 @@ val appModule = module {
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }

viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
* Delay when starting video playback after loading the video player.
*/
var videoStartDelay = longPreference("video_start_delay", 0)

/**
* The actions to take for each media segment type. Managed by the [MediaSegmentRepository].
*/
var mediaSegmentActions = stringPreference("media_segment_actions", "")
}

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -639,23 +639,27 @@ private void startItem(BaseItemDto item, long position, StreamInfo response) {
mVideoManager.setVideoPath(response.getMediaUrl());
}

// Set video start delay
long videoStartDelay = userPreferences.getValue().get(UserPreferences.Companion.getVideoStartDelay());
if (videoStartDelay > 0) {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
if (mVideoManager != null) {
mVideoManager.start();
PlaybackControllerHelperKt.applyMediaSegments(this, item, () -> {
// Set video start delay
long videoStartDelay = userPreferences.getValue().get(UserPreferences.Companion.getVideoStartDelay());
if (videoStartDelay > 0) {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
if (mVideoManager != null) {
mVideoManager.start();
}
}
}
}, videoStartDelay);
} else {
mVideoManager.start();
}
}, videoStartDelay);
} else {
mVideoManager.start();
}

dataRefreshService.getValue().setLastPlayedItem(item);
reportingHelper.getValue().reportStart(item, mbPos);

dataRefreshService.getValue().setLastPlayedItem(item);
reportingHelper.getValue().reportStart(item, mbPos);
return null;
});
}

public void startSpinner() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package org.jellyfin.androidtv.ui.playback

import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.extensions.ticks
import org.koin.android.ext.android.inject
import java.util.UUID

Expand All @@ -22,3 +29,45 @@ fun PlaybackController.getLiveTvChannel(
}
}
}

fun PlaybackController.applyMediaSegments(
item: BaseItemDto,
callback: () -> Unit,
) {
val mediaSegmentRepository by fragment.inject<MediaSegmentRepository>()

fragment.lifecycleScope.launch {
val mediaSegments = runCatching {
mediaSegmentRepository.getSegmentsForItem(item)
}.getOrNull().orEmpty()

for (mediaSegment in mediaSegments) {
val action = mediaSegmentRepository.getMediaSegmentAction(mediaSegment)

when (action) {
MediaSegmentAction.SKIP -> addSkipAction(mediaSegment)
MediaSegmentAction.NOTHING -> Unit
}
}

callback()
}
}

@OptIn(UnstableApi::class)
private fun PlaybackController.addSkipAction(mediaSegment: MediaSegmentDto) {
mVideoManager.mExoPlayer
.createMessage { messageType: Int, payload: Any? ->
// We can't seek directly on the ExoPlayer instance as not all media is seekable
// the seek function in the PlaybackController checks this and optionally starts a transcode
// at the requested position
fragment.lifecycleScope.launch(Dispatchers.Main) {
seek(mediaSegment.endTicks.ticks.inWholeMilliseconds)
}
}
// Segments at position 0 will never be hit by ExoPlayer so we need to add a minimum value
.setPosition(mediaSegment.startTicks.ticks.inWholeMilliseconds.coerceAtLeast(1))
.setPayload(mediaSegment)
.setDeleteAfterDelivery(false)
.send()
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
private Limiter mLimiter;
private PlaybackControllerNotifiable mPlaybackControllerNotifiable;
private PlaybackOverlayFragmentHelper _helper;
private ExoPlayer mExoPlayer;
public ExoPlayer mExoPlayer;

Check notice

Code scanning / Android Lint

Unknown nullness Note

Unknown nullability; explicitly declare as @Nullable or @NonNull to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations
private PlayerView mExoPlayerView;
private Handler mHandler = new Handler();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.jellyfin.androidtv.ui.playback.segment

import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum

enum class MediaSegmentAction(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Don't take any action for this segment.
*/
NOTHING(R.string.segment_action_nothing),

/**
* Seek to the end of this segment (endTicks). If the duration of this segment is shorter than 1 second it should do nothing to avoid

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
* lagg. The skip action will only execute when playing over the segment start, not when seeking into the segment block.

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
*/
SKIP(R.string.segment_action_skip),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package org.jellyfin.androidtv.ui.playback.segment

import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType

interface MediaSegmentRepository {
companion object {
/**
* All media segments currently supported by the app. The order of these is used for the preferences UI.
*/
val SupportedTypes = listOf(
MediaSegmentType.INTRO,
MediaSegmentType.OUTRO,
MediaSegmentType.PREVIEW,
MediaSegmentType.RECAP,
MediaSegmentType.COMMERCIAL,
)
}

fun getDefaultSegmentTypeAction(type: MediaSegmentType): MediaSegmentAction
fun setDefaultSegmentTypeAction(type: MediaSegmentType, action: MediaSegmentAction)

suspend fun getSegmentsForItem(item: BaseItemDto): List<MediaSegmentDto>
fun getMediaSegmentAction(segment: MediaSegmentDto): MediaSegmentAction
}

class MediaSegmentRepositoryImpl(
private val userPreferences: UserPreferences,
private val api: ApiClient,
) : MediaSegmentRepository {
private val mediaTypeActions = mutableMapOf<MediaSegmentType, MediaSegmentAction>()

init {
restoreMediaTypeActions()
}

private fun restoreMediaTypeActions() {
val restoredMediaTypeActions = userPreferences[UserPreferences.mediaSegmentActions]
.split(",")
.mapNotNull {
runCatching {
val (type, action) = it.split('=', limit = 2)
MediaSegmentType.fromName(type) to MediaSegmentAction.valueOf(action)
}.getOrNull()
}

mediaTypeActions.clear()
mediaTypeActions.putAll(restoredMediaTypeActions)
}

private fun saveMediaTypeActions() {
userPreferences[UserPreferences.mediaSegmentActions] = mediaTypeActions
.map { "${it.key.serialName}=${it.value.name}" }
.joinToString(",")
}

override fun getDefaultSegmentTypeAction(type: MediaSegmentType): MediaSegmentAction {
// Always return no action for unsupported types
if (!MediaSegmentRepository.SupportedTypes.contains(type)) return MediaSegmentAction.NOTHING

return mediaTypeActions.getOrDefault(type, MediaSegmentAction.NOTHING)
}

override fun setDefaultSegmentTypeAction(type: MediaSegmentType, action: MediaSegmentAction) {
// Don't allow modifying actions for unsupported types
if (!MediaSegmentRepository.SupportedTypes.contains(type)) return

mediaTypeActions[type] = action
saveMediaTypeActions()
}

override fun getMediaSegmentAction(segment: MediaSegmentDto): MediaSegmentAction {
return getDefaultSegmentTypeAction(segment.type)
}

override suspend fun getSegmentsForItem(item: BaseItemDto): List<MediaSegmentDto> = runCatching {
api.mediaSegmentsApi.getItemSegments(
itemId = item.id,
includeSegmentTypes = MediaSegmentRepository.SupportedTypes,
).content.items
}.getOrDefault(emptyList())
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.AudioBehavior
import org.jellyfin.androidtv.preference.constant.NEXTUP_TIMER_DISABLED
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.preference.custom.DurationSeekBarPreference
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
Expand All @@ -14,10 +16,12 @@ import org.jellyfin.androidtv.ui.preference.dsl.link
import org.jellyfin.androidtv.ui.preference.dsl.list
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
import org.jellyfin.androidtv.ui.preference.dsl.seekbar
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.koin.android.ext.android.inject

class PlaybackPreferencesScreen : OptionsFragment() {
private val userPreferences: UserPreferences by inject()
private val mediaSegmentRepository: MediaSegmentRepository by inject()

override val screen by optionsScreen {
setTitle(R.string.pref_playback)
Expand Down Expand Up @@ -140,6 +144,29 @@ class PlaybackPreferencesScreen : OptionsFragment() {
}
}

category {
setTitle(R.string.pref_mediasegment_actions)

for (segmentType in MediaSegmentRepository.SupportedTypes) {
enum<MediaSegmentAction> {
when (segmentType) {
MediaSegmentType.UNKNOWN -> R.string.segment_type_unknown
MediaSegmentType.COMMERCIAL -> R.string.segment_type_commercial
MediaSegmentType.PREVIEW -> R.string.segment_type_preview
MediaSegmentType.RECAP -> R.string.segment_type_recap
MediaSegmentType.OUTRO -> R.string.segment_type_outro
MediaSegmentType.INTRO -> R.string.segment_type_intro
}.let(::setTitle)

bind {
get { mediaSegmentRepository.getDefaultSegmentTypeAction(segmentType) }
set { value -> mediaSegmentRepository.setDefaultSegmentTypeAction(segmentType, value) }
default { MediaSegmentAction.NOTHING }
}
}
}
}

category {
setTitle(R.string.advanced_settings)

Expand Down
9 changes: 9 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,15 @@
<string name="prefer_exoplayer_ffmpeg">Prefer FFmpeg for audio playback</string>
<string name="prefer_exoplayer_ffmpeg_content">Use FFmpeg to decode audio, even if platform codecs are available.</string>
<string name="video_start_delay">Video start delay</string>
<string name="pref_mediasegment_actions">Media segment actions</string>
<string name="segment_action_nothing">Do nothing</string>
<string name="segment_action_skip">Skip</string>
<string name="segment_type_commercial">Commercials</string>
<string name="segment_type_intro">Intros</string>
<string name="segment_type_outro">Outros</string>
<string name="segment_type_preview">Previews</string>
<string name="segment_type_recap">Recaps</string>
<string name="segment_type_unknown">Unknown segments</string>
<plurals name="seconds">
<item quantity="one">%1$s second</item>
<item quantity="other">%1$s seconds</item>
Expand Down
Loading