Skip to content

Commit ff1917f

Browse files
committed
Implement media segment support
Most of the proposed changes in the files listed below have been shamelessly copied from the Android TV implementation in jellyfin/jellyfin-androidtv#4052. Authorship of these changes belongs to nielsvanvelzen. app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt app/src/main/java/org/jellyfin/mobile/player/mediasegments/MediaSegmentAction.kt app/src/main/java/org/jellyfin/mobile/player/mediasegments/MediaSegmentRepository.kt app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaSegment.kt
1 parent 95ef468 commit ff1917f

File tree

11 files changed

+264
-1
lines changed

11 files changed

+264
-1
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export class MediaSegmentsPlugin {
2+
SETTING_PREFIX = 'segmentTypeAction';
3+
4+
constructor({ events, appSettings, dashboard }) {
5+
this.appSettings = appSettings;
6+
this.dashboard = dashboard;
7+
8+
events.on(appSettings, 'change', (_, name) => this.onSettingsChanged(name));
9+
}
10+
11+
getSettingId(type) {
12+
return `${this.SETTING_PREFIX}__${type}`;
13+
}
14+
15+
getSettingValue(id) {
16+
var userId = this.dashboard.getCurrentUserId();
17+
18+
return this.appSettings.get(id, userId);
19+
}
20+
21+
// Update media segment action
22+
onSettingsChanged(name) {
23+
if (name.startsWith(this.SETTING_PREFIX)) {
24+
var type = name.slice(this.SETTING_PREFIX.length + 2);
25+
var action = this.getSettingValue(this.getSettingId(type));
26+
27+
if (type != null && action != null) {
28+
MediaSegments.setSegmentTypeAction(type, action);
29+
}
30+
}
31+
}
32+
}

app/src/main/assets/native/nativeshell.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ const features = [
1818
const plugins = [
1919
'NavigationPlugin',
2020
'ExoPlayerPlugin',
21-
'ExternalPlayerPlugin'
21+
'ExternalPlayerPlugin',
22+
'MediaSegmentsPlugin'
2223
];
2324

2425
// Add plugin loaders

app/src/main/java/org/jellyfin/mobile/app/AppModule.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import org.jellyfin.mobile.events.ActivityEventHandler
2727
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
2828
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
2929
import org.jellyfin.mobile.player.interaction.PlayerEvent
30+
import org.jellyfin.mobile.player.mediasegments.MediaSegmentRepository
3031
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
3132
import org.jellyfin.mobile.player.source.MediaSourceResolver
3233
import org.jellyfin.mobile.player.ui.PlayerFragment
@@ -82,6 +83,7 @@ val applicationModule = module {
8283
single { MediaSourceResolver(get()) }
8384
single { DeviceProfileBuilder(get()) }
8485
single { QualityOptionsProvider() }
86+
single { MediaSegmentRepository() }
8587

8688
// ExoPlayer factories
8789
single<DataSource.Factory> {

app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import android.content.SharedPreferences
55
import android.os.Environment
66
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
77
import androidx.core.content.edit
8+
import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction
9+
import org.jellyfin.mobile.player.mediasegments.toMediaSegmentActionsString
810
import org.jellyfin.mobile.settings.ExternalPlayerPackage
911
import org.jellyfin.mobile.settings.VideoPlayerType
1012
import org.jellyfin.mobile.utils.Constants
13+
import org.jellyfin.sdk.model.api.MediaSegmentType
1114
import java.io.File
1215

1316
class AppPreferences(context: Context) {
@@ -90,6 +93,19 @@ class AppPreferences(context: Context) {
9093
}
9194
}
9295

96+
/**
97+
* The actions to take for each media segment type. Managed by the MediaSegmentRepository.
98+
*/
99+
var mediaSegmentActions: String
100+
get() = sharedPreferences.getString(
101+
Constants.PREF_MEDIA_SEGMENT_ACTIONS,
102+
mapOf(
103+
MediaSegmentType.INTRO to MediaSegmentAction.ASK_TO_SKIP,
104+
MediaSegmentType.OUTRO to MediaSegmentAction.ASK_TO_SKIP,
105+
).toMediaSegmentActionsString(),
106+
)!!
107+
set(value) = sharedPreferences.edit { putString(Constants.PREF_MEDIA_SEGMENT_ACTIONS, value) }
108+
93109
val musicNotificationAlwaysDismissible: Boolean
94110
get() = sharedPreferences.getBoolean(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE, false)
95111

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.jellyfin.mobile.bridge
2+
3+
import android.content.Context
4+
import android.webkit.JavascriptInterface
5+
import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction
6+
import org.jellyfin.mobile.player.mediasegments.MediaSegmentRepository
7+
import org.jellyfin.sdk.model.api.MediaSegmentType
8+
import org.koin.core.component.KoinComponent
9+
import org.koin.core.component.get
10+
11+
@Suppress("unused")
12+
class MediaSegments(private val context: Context) : KoinComponent {
13+
private val mediaSegmentRepository: MediaSegmentRepository = get()
14+
15+
@JavascriptInterface
16+
fun setSegmentTypeAction(typeString: String, actionString: String) {
17+
val type: MediaSegmentType = when(typeString) {
18+
"Intro" -> MediaSegmentType.INTRO
19+
"Outro" -> MediaSegmentType.OUTRO
20+
"Preview" -> MediaSegmentType.PREVIEW
21+
"Recap" -> MediaSegmentType.RECAP
22+
"Commercial" -> MediaSegmentType.COMMERCIAL
23+
else -> return
24+
}
25+
26+
val action: MediaSegmentAction = when(actionString) {
27+
"None" -> MediaSegmentAction.NOTHING
28+
"Skip" -> MediaSegmentAction.SKIP
29+
"AskToSkip" -> MediaSegmentAction.ASK_TO_SKIP
30+
else -> return
31+
}
32+
33+
mediaSegmentRepository.setDefaultSegmentTypeAction(type, action)
34+
}
35+
}

app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import org.jellyfin.mobile.player.interaction.PlayerEvent
3838
import org.jellyfin.mobile.player.interaction.PlayerLifecycleObserver
3939
import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback
4040
import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper
41+
import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction
42+
import org.jellyfin.mobile.player.mediasegments.MediaSegmentRepository
4143
import org.jellyfin.mobile.player.queue.QueueManager
4244
import org.jellyfin.mobile.player.source.JellyfinMediaSource
4345
import org.jellyfin.mobile.player.ui.DecoderType
@@ -47,7 +49,9 @@ import org.jellyfin.mobile.utils.Constants
4749
import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
4850
import org.jellyfin.mobile.utils.applyDefaultAudioAttributes
4951
import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes
52+
import org.jellyfin.mobile.utils.extensions.end
5053
import org.jellyfin.mobile.utils.extensions.scaleInRange
54+
import org.jellyfin.mobile.utils.extensions.start
5155
import org.jellyfin.mobile.utils.extensions.width
5256
import org.jellyfin.mobile.utils.getVolumeLevelPercent
5357
import org.jellyfin.mobile.utils.getVolumeRange
@@ -65,6 +69,7 @@ import org.jellyfin.sdk.api.operations.DisplayPreferencesApi
6569
import org.jellyfin.sdk.api.operations.HlsSegmentApi
6670
import org.jellyfin.sdk.api.operations.PlayStateApi
6771
import org.jellyfin.sdk.api.operations.UserApi
72+
import org.jellyfin.sdk.model.api.MediaSegmentDto
6873
import org.jellyfin.sdk.model.api.PlayMethod
6974
import org.jellyfin.sdk.model.api.PlaybackOrder
7075
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
@@ -96,6 +101,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
96101
val queueManager = QueueManager(this)
97102
val mediaSourceOrNull: JellyfinMediaSource?
98103
get() = queueManager.currentMediaSourceOrNull
104+
private val mediaSegmentRepository = MediaSegmentRepository()
99105

100106
// ExoPlayer
101107
private val _player = MutableLiveData<ExoPlayer?>()
@@ -264,6 +270,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
264270

265271
val startTime = jellyfinMediaSource.startTimeMs
266272
if (startTime > 0) player.seekTo(startTime)
273+
274+
applyMediaSegments(jellyfinMediaSource)
275+
267276
player.playWhenReady = playWhenReady
268277

269278
mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata())
@@ -403,6 +412,46 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
403412
}
404413
}
405414

415+
private fun applyMediaSegments(jellyfinMediaSource: JellyfinMediaSource) {
416+
viewModelScope.launch {
417+
if (jellyfinMediaSource.item != null) {
418+
val mediaSegments = runCatching {
419+
mediaSegmentRepository.getSegmentsForItem(jellyfinMediaSource.item)
420+
}.getOrNull().orEmpty()
421+
422+
for (mediaSegment in mediaSegments) {
423+
val action = mediaSegmentRepository.getMediaSegmentAction(mediaSegment)
424+
425+
when(action) {
426+
MediaSegmentAction.SKIP -> addSkipAction(mediaSegment)
427+
MediaSegmentAction.NOTHING -> Unit
428+
// Unimplemented
429+
MediaSegmentAction.ASK_TO_SKIP -> Unit
430+
}
431+
}
432+
}
433+
}
434+
}
435+
436+
private fun addSkipAction(mediaSegment: MediaSegmentDto) {
437+
val player = playerOrNull ?: return
438+
439+
player.createMessage { _, _ ->
440+
// We can't seek directly on the ExoPlayer instance as not all media is seekable
441+
// the seek function in the PlaybackController checks this and optionally starts a transcode
442+
// at the requested position
443+
// TODO: The above is probably true for jellyfin-android as well.
444+
// But I believe there is no such logic here.
445+
viewModelScope.launch(Dispatchers.Main) {
446+
player.seekTo(mediaSegment.end.inWholeMilliseconds)
447+
}
448+
}
449+
// Segments at position 0 will never be hit by ExoPlayer so we need to add a minimum value
450+
.setPosition(mediaSegment.start.inWholeMilliseconds.coerceAtLeast(1))
451+
.setDeleteAfterDelivery(false)
452+
.send()
453+
}
454+
406455
// Player controls
407456

408457
fun play() {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package org.jellyfin.mobile.player.mediasegments
2+
3+
enum class MediaSegmentAction {
4+
/**
5+
* Don't take any action for this segment.
6+
*/
7+
NOTHING,
8+
9+
/**
10+
* 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
11+
* lag. The skip action will only execute when playing over the segment start, not when seeking into the segment block.
12+
*/
13+
SKIP,
14+
15+
/**
16+
* Ask the user if they want to skip this segment. When the user agrees this behaves like [SKIP]. Confirmation should only be asked for
17+
* segments with a duration of at least 3 seconds to avoid UI flickering.
18+
*/
19+
ASK_TO_SKIP,
20+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package org.jellyfin.mobile.player.mediasegments
2+
3+
import org.jellyfin.mobile.app.AppPreferences
4+
import org.jellyfin.mobile.utils.extensions.duration
5+
import org.jellyfin.sdk.api.client.ApiClient
6+
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
7+
import org.jellyfin.sdk.api.operations.MediaSegmentsApi
8+
import org.jellyfin.sdk.model.api.BaseItemDto
9+
import org.jellyfin.sdk.model.api.MediaSegmentDto
10+
import org.jellyfin.sdk.model.api.MediaSegmentType
11+
import org.koin.core.component.KoinComponent
12+
import org.koin.core.component.get
13+
import org.koin.core.component.inject
14+
import kotlin.time.Duration.Companion.seconds
15+
16+
fun Map<MediaSegmentType, MediaSegmentAction>.toMediaSegmentActionsString() =
17+
map { "${it.key.serialName}=${it.value.name}" }
18+
.joinToString(",")
19+
20+
class MediaSegmentRepository : KoinComponent {
21+
companion object {
22+
/**
23+
* All media segments currently supported by the app.
24+
*/
25+
val SupportedTypes = listOf(
26+
MediaSegmentType.INTRO,
27+
MediaSegmentType.OUTRO,
28+
MediaSegmentType.PREVIEW,
29+
MediaSegmentType.RECAP,
30+
MediaSegmentType.COMMERCIAL,
31+
)
32+
33+
/**
34+
* The minimum duration for a media segment to allow the [MediaSegmentAction.SKIP] action.
35+
*/
36+
val SkipMinDuration = 1.seconds
37+
}
38+
39+
private val appPreferences: AppPreferences by inject()
40+
private val apiClient: ApiClient = get()
41+
private val mediaSegmentsApi: MediaSegmentsApi = apiClient.mediaSegmentsApi
42+
43+
private val mediaTypeActions = mutableMapOf<MediaSegmentType, MediaSegmentAction>()
44+
45+
init {
46+
restoreMediaTypeActions()
47+
}
48+
49+
private fun restoreMediaTypeActions() {
50+
val restoredMediaTypeActions = appPreferences.mediaSegmentActions
51+
.split(",")
52+
.mapNotNull {
53+
runCatching {
54+
val (type, action) = it.split('=', limit = 2)
55+
MediaSegmentType.fromName(type) to MediaSegmentAction.valueOf(action)
56+
}.getOrNull()
57+
}
58+
59+
mediaTypeActions.clear()
60+
mediaTypeActions.putAll(restoredMediaTypeActions)
61+
}
62+
63+
private fun saveMediaTypeActions() {
64+
appPreferences.mediaSegmentActions = mediaTypeActions.toMediaSegmentActionsString()
65+
}
66+
67+
fun getDefaultSegmentTypeAction(type: MediaSegmentType): MediaSegmentAction {
68+
// Always return no action for unsupported types
69+
if (!SupportedTypes.contains(type)) return MediaSegmentAction.NOTHING
70+
71+
return mediaTypeActions.getOrDefault(type, MediaSegmentAction.NOTHING)
72+
}
73+
74+
fun setDefaultSegmentTypeAction(type: MediaSegmentType, action: MediaSegmentAction) {
75+
// Don't allow modifying actions for unsupported types
76+
if (!SupportedTypes.contains(type)) return
77+
78+
mediaTypeActions[type] = action
79+
saveMediaTypeActions()
80+
}
81+
82+
fun getMediaSegmentAction(segment: MediaSegmentDto): MediaSegmentAction {
83+
val action = getDefaultSegmentTypeAction(segment.type)
84+
// Skip the skip action if timespan is too short
85+
if (action == MediaSegmentAction.SKIP && segment.duration < SkipMinDuration) return MediaSegmentAction.NOTHING
86+
return action
87+
}
88+
89+
suspend fun getSegmentsForItem(item: BaseItemDto): List<MediaSegmentDto> = runCatching {
90+
mediaSegmentsApi.getItemSegments(
91+
itemId = item.id,
92+
includeSegmentTypes = SupportedTypes,
93+
).content.items
94+
}.getOrDefault(emptyList())
95+
}

app/src/main/java/org/jellyfin/mobile/utils/Constants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ object Constants {
4242
const val PREF_EXTERNAL_PLAYER_APP = "pref_external_player_app"
4343
const val PREF_SUBTITLE_STYLE = "pref_subtitle_style"
4444
const val PREF_DOWNLOAD_LOCATION = "pref_download_location"
45+
const val PREF_MEDIA_SEGMENT_ACTIONS = "pref_media_segment_actions"
4546

4647
// InputManager commands
4748
const val PLAYBACK_MANAGER_COMMAND_PLAY = "unpause"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.jellyfin.mobile.utils.extensions
2+
3+
import org.jellyfin.sdk.model.api.MediaSegmentDto
4+
import org.jellyfin.sdk.model.extensions.ticks
5+
import kotlin.time.Duration
6+
7+
val MediaSegmentDto.start get() = startTicks.ticks
8+
val MediaSegmentDto.end get() = endTicks.ticks
9+
10+
val MediaSegmentDto.duration get() = (endTicks - startTicks).ticks.coerceAtLeast(Duration.ZERO)

app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import org.jellyfin.mobile.R
2828
import org.jellyfin.mobile.app.ApiClientController
2929
import org.jellyfin.mobile.app.AppPreferences
3030
import org.jellyfin.mobile.bridge.ExternalPlayer
31+
import org.jellyfin.mobile.bridge.MediaSegments
3132
import org.jellyfin.mobile.bridge.NativeInterface
3233
import org.jellyfin.mobile.bridge.NativePlayer
3334
import org.jellyfin.mobile.data.entity.ServerEntity
@@ -192,6 +193,7 @@ class WebViewFragment : Fragment(), BackPressInterceptor, JellyfinWebChromeClien
192193
addJavascriptInterface(NativeInterface(requireContext()), "NativeInterface")
193194
addJavascriptInterface(nativePlayer, "NativePlayer")
194195
addJavascriptInterface(externalPlayer, "ExternalPlayer")
196+
addJavascriptInterface(MediaSegments(requireContext()), "MediaSegments")
195197

196198
loadUrl(server.hostname)
197199
postDelayed(timeoutRunnable, Constants.INITIAL_CONNECTION_TIMEOUT)

0 commit comments

Comments
 (0)