Add timed event API

This commit is contained in:
Niels van Velzen authored and Niels van Velzen committed 2026-02-27 20:53:57 +01:00
1 parent 333c8b9630
commit 6a03b75eb2
8 files changed
+136

No files matched your search

@@ -9,6 +9,7 @@ import org.jellyfin.playback.core.mediastream.MediaStreamService
import org.jellyfin.playback.core.plugin.PlaybackPlugin
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.QueueService
import org.jellyfin.playback.core.timedevent.TimedEventService
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -50,6 +51,7 @@ class PlaybackManagerBuilder(context: Context) {
// Add default services
services.add(QueueService())
services.add(MediaStreamService(mediaStreamResolvers))
services.add(TimedEventService())
// Only support a single backend right now
require(backends.size == 1)
@@ -4,6 +4,7 @@ import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.model.PositionInfo
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.support.PlaySupportReport
import org.jellyfin.playback.core.timedevent.TimedEvent
import org.jellyfin.playback.core.ui.PlayerSubtitleView
import org.jellyfin.playback.core.ui.PlayerSurfaceView
import kotlin.time.Duration
@@ -38,5 +39,7 @@ interface PlayerBackend {
fun setScrubbing(scrubbing: Boolean)
fun setSpeed(speed: Float)
fun setTimedEvents(timedEvents: Collection<TimedEvent>)
}
@@ -0,0 +1,25 @@
package org.jellyfin.playback.core.timedevent
import kotlin.time.Duration
sealed interface TimedEvent {
/**
* Optional key for identifying this timed event.
*/
val key: String?
/**
* The position of this timed event.
*/
val position: Duration
/**
* Function callback variant of a timed event.
*/
data class Callback(
override val key: String? = null,
override val position: Duration,
val callback: () -> Unit,
) : TimedEvent
}
@@ -0,0 +1,23 @@
package org.jellyfin.playback.core.timedevent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.queue
import timber.log.Timber
class TimedEventService : PlayerService() {
override suspend fun onInitialize() {
manager.queue.entry
.filterNotNull()
.flatMapLatest { entry -> entry.timedEventsFlow }
.onEach { timedEvents ->
Timber.d("Timed events changed to $timedEvents")
manager.backend.setTimedEvents(timedEvents.orEmpty())
}.launchIn(coroutineScope + Dispatchers.Main)
}
}
@@ -0,0 +1,27 @@
package org.jellyfin.playback.core.timedevent
import org.jellyfin.playback.core.element.ElementKey
import org.jellyfin.playback.core.element.element
import org.jellyfin.playback.core.element.elementFlow
import org.jellyfin.playback.core.queue.QueueEntry
private val timedEventsKey = ElementKey<Collection<TimedEvent>>("TimedEvents")
/**
* Get or set the [Collection<TimedEvent>] for this [QueueEntry].
*/
var QueueEntry.timedEvents by element(timedEventsKey)
val QueueEntry.timedEventsFlow by elementFlow(timedEventsKey)
fun QueueEntry.addTimedEvent(timedEvent: TimedEvent) {
val timedEvents = getOrNull(timedEventsKey).orEmpty()
val newTimedEvents = timedEvents + timedEvent
put(timedEventsKey, newTimedEvents)
}
fun QueueEntry.removeTimedEvent(timedEvent: TimedEvent) {
val timedEvents = getOrNull(timedEventsKey).orEmpty()
val newTimedEvents = timedEvents - timedEvent
if (newTimedEvents.isEmpty()) remove(timedEventsKey)
else put(timedEventsKey, newTimedEvents)
}
@@ -3,6 +3,7 @@ package org.jellyfin.playback.jellyfin
import androidx.lifecycle.Lifecycle
import org.jellyfin.playback.core.plugin.playbackPlugin
import org.jellyfin.playback.jellyfin.lyrics.LyricsPlayerService
import org.jellyfin.playback.jellyfin.mediasegment.MediaSegmentService
import org.jellyfin.playback.jellyfin.mediastream.JellyfinMediaStreamResolver
import org.jellyfin.playback.jellyfin.playsession.PlaySessionService
import org.jellyfin.playback.jellyfin.playsession.PlaySessionSocketService
@@ -21,4 +22,5 @@ fun jellyfinPlugin(
provide(PlaySessionSocketService(api, playSessionService, lifecycle))
provide(LyricsPlayerService(api))
provide(MediaSegmentService(api))
}
@@ -16,6 +16,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.util.EventLogger
@@ -33,6 +34,7 @@ import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PositionInfo
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.support.PlaySupportReport
import org.jellyfin.playback.core.timedevent.TimedEvent
import org.jellyfin.playback.core.ui.PlayerSubtitleView
import org.jellyfin.playback.core.ui.PlayerSurfaceView
import org.jellyfin.playback.media3.exoplayer.support.getPlaySupportReport
@@ -56,6 +58,7 @@ class ExoPlayerBackend(
private var subtitleView: SubtitleView? = null
private val audioPipeline = ExoPlayerAudioPipeline()
private val audioAttributeState = AudioAttributeState()
private val messageHandler = ExoPlayerMessageHandler()
private val exoPlayer by lazy {
val dataSourceFactory = DefaultDataSource.Factory(
@@ -278,4 +281,26 @@ class ExoPlayerBackend(
buffer = exoPlayer.bufferedPosition.milliseconds,
duration = if (exoPlayer.duration == C.TIME_UNSET) Duration.ZERO else exoPlayer.duration.milliseconds,
)
private var _currentTimedEventMessages = listOf<PlayerMessage>()
override fun setTimedEvents(timedEvents: Collection<TimedEvent>) {
// Cancel any existing timed messages
for (message in _currentTimedEventMessages) {
message.cancel()
}
// Create new timed messages
_currentTimedEventMessages = timedEvents.map { timedEvent ->
val positionMs = timedEvent.position.inWholeMilliseconds
// Messages at position 0 will never be invoked by ExoPlayer
.coerceAtLeast(1L)
exoPlayer.createMessage(messageHandler).apply {
setType(ExoPlayerMessageHandler.TYPE_TIMED_EVENT)
setPosition(positionMs)
setPayload(timedEvent)
setDeleteAfterDelivery(false)
}.send()
}
}
}
@@ -0,0 +1,29 @@
package org.jellyfin.playback.media3.exoplayer
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.PlayerMessage
import org.jellyfin.playback.core.timedevent.TimedEvent
import timber.log.Timber
class ExoPlayerMessageHandler : PlayerMessage.Target {
companion object {
const val TYPE_TIMED_EVENT = 1
}
@OptIn(UnstableApi::class)
override fun handleMessage(messageType: Int, message: Any?): Unit = when (messageType) {
TYPE_TIMED_EVENT -> {
val timedEvent = message as? TimedEvent ?: return
handleTimedEventMessage(timedEvent)
}
else -> {
Timber.w("Unknown message type $messageType")
}
}
private fun handleTimedEventMessage(timedEvent: TimedEvent) = when (timedEvent) {
is TimedEvent.Callback -> timedEvent.callback()
}
}