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

ExoPlayer: Implement media segment support #1507

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
32 changes: 32 additions & 0 deletions app/src/main/assets/native/MediaSegmentsPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export class MediaSegmentsPlugin {
SETTING_PREFIX = 'segmentTypeAction';

constructor({ events, appSettings, dashboard }) {
this.appSettings = appSettings;
this.dashboard = dashboard;

events.on(appSettings, 'change', (_, name) => this.onSettingsChanged(name));
}

getSettingId(type) {
return `${this.SETTING_PREFIX}__${type}`;
}

getSettingValue(id) {
var userId = this.dashboard.getCurrentUserId();

return this.appSettings.get(id, userId);
}

// Update media segment action
onSettingsChanged(name) {
if (name.startsWith(this.SETTING_PREFIX)) {
var type = name.slice(this.SETTING_PREFIX.length + 2);
var action = this.getSettingValue(this.getSettingId(type));

if (type != null && action != null) {
MediaSegments.setSegmentTypeAction(type, action);
}
}
}
}
3 changes: 2 additions & 1 deletion app/src/main/assets/native/nativeshell.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const features = [
const plugins = [
'NavigationPlugin',
'ExoPlayerPlugin',
'ExternalPlayerPlugin'
'ExternalPlayerPlugin',
'MediaSegmentsPlugin'
];

// Add plugin loaders
Expand Down
21 changes: 11 additions & 10 deletions app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import org.jellyfin.mobile.data.entity.ServerEntity
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUID

class ApiClientController(
private val appPreferences: AppPreferences,
Expand All @@ -27,7 +26,7 @@ class ApiClientController(
appPreferences.currentServerId = withContext(Dispatchers.IO) {
serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname)
}
apiClient.baseUrl = hostname
apiClient.update(baseUrl = hostname)
}

suspend fun setupUser(serverId: Long, userId: String, accessToken: String) {
Expand Down Expand Up @@ -69,19 +68,21 @@ class ApiClientController(
}

private fun configureApiClientServer(server: ServerEntity?) {
apiClient.baseUrl = server?.hostname
apiClient.update(baseUrl = server?.hostname)
}

private fun configureApiClientUser(userId: String, accessToken: String) {
apiClient.userId = userId.toUUID()
apiClient.accessToken = accessToken
// Append user id to device id to ensure uniqueness across sessions
apiClient.deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId)
apiClient.update(
accessToken = accessToken,
// Append user id to device id to ensure uniqueness across sessions
deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId),
)
}

private fun resetApiClientUser() {
apiClient.userId = null
apiClient.accessToken = null
apiClient.deviceInfo = baseDeviceInfo
apiClient.update(
accessToken = null,
deviceInfo = baseDeviceInfo,
)
}
}
30 changes: 29 additions & 1 deletion app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.jellyfin.mobile.app

import android.content.Context
import androidx.core.net.toUri
import coil.ImageLoader
import com.google.android.exoplayer2.ext.cronet.CronetDataSource
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
Expand All @@ -11,8 +12,10 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.SingleSampleMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.ResolvingDataSource
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
Expand All @@ -24,6 +27,7 @@ import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.player.mediasegments.MediaSegmentRepository
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.player.ui.PlayerFragment
Expand All @@ -34,6 +38,8 @@ import org.jellyfin.mobile.utils.isLowRamDevice
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.mobile.webapp.WebViewFragment
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.koin.android.ext.koin.androidApplication
import org.koin.androidx.fragment.dsl.fragment
import org.koin.androidx.viewmodel.dsl.viewModel
Expand Down Expand Up @@ -77,10 +83,12 @@ val applicationModule = module {
single { MediaSourceResolver(get()) }
single { DeviceProfileBuilder(get()) }
single { QualityOptionsProvider() }
single { MediaSegmentRepository() }

// ExoPlayer factories
single<DataSource.Factory> {
val context: Context = get()
val apiClient: ApiClient = get()

val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
(provider.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) && provider.isEnabled
Expand All @@ -102,7 +110,27 @@ val applicationModule = module {
}
}

DefaultDataSource.Factory(context, baseDataSourceFactory)
val dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory)

// Add authorization header. This is needed as we don't pass the
// access token in the URL for Android Auto.
ResolvingDataSource.Factory(dataSourceFactory) { dataSpec: DataSpec ->
// Only send authorization header if URI matches the jellyfin server
val baseUrlAuthority = apiClient.baseUrl?.toUri()?.authority

if (dataSpec.uri.authority == baseUrlAuthority) {
val authorizationHeaderString = AuthorizationHeaderBuilder.buildHeader(
clientName = apiClient.clientInfo.name,
clientVersion = apiClient.clientInfo.version,
deviceId = apiClient.deviceInfo.id,
deviceName = apiClient.deviceInfo.name,
accessToken = apiClient.accessToken,
)

dataSpec.withRequestHeaders(hashMapOf("Authorization" to authorizationHeaderString))
} else
dataSpec
}
}
single<MediaSource.Factory> {
val context: Context = get()
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import android.content.SharedPreferences
import android.os.Environment
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import androidx.core.content.edit
import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction
import org.jellyfin.mobile.player.mediasegments.toMediaSegmentActionsString
import org.jellyfin.mobile.settings.ExternalPlayerPackage
import org.jellyfin.mobile.settings.VideoPlayerType
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.model.api.MediaSegmentType
import java.io.File

class AppPreferences(context: Context) {
Expand Down Expand Up @@ -90,6 +93,19 @@ class AppPreferences(context: Context) {
}
}

/**
* The actions to take for each media segment type. Managed by the MediaSegmentRepository.
*/
var mediaSegmentActions: String
get() = sharedPreferences.getString(
Constants.PREF_MEDIA_SEGMENT_ACTIONS,
mapOf(
MediaSegmentType.INTRO to MediaSegmentAction.ASK_TO_SKIP,
MediaSegmentType.OUTRO to MediaSegmentAction.ASK_TO_SKIP,
).toMediaSegmentActionsString(),
)!!
set(value) = sharedPreferences.edit { putString(Constants.PREF_MEDIA_SEGMENT_ACTIONS, value) }

val musicNotificationAlwaysDismissible: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE, false)

Expand Down
35 changes: 35 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/bridge/MediaSegments.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.jellyfin.mobile.bridge

import android.content.Context
import android.webkit.JavascriptInterface
import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction
import org.jellyfin.mobile.player.mediasegments.MediaSegmentRepository
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.koin.core.component.KoinComponent
import org.koin.core.component.get

@Suppress("unused")
class MediaSegments(private val context: Context) : KoinComponent {
private val mediaSegmentRepository: MediaSegmentRepository = get()

@JavascriptInterface
fun setSegmentTypeAction(typeString: String, actionString: String) {
val type: MediaSegmentType = when(typeString) {
"Intro" -> MediaSegmentType.INTRO
"Outro" -> MediaSegmentType.OUTRO
"Preview" -> MediaSegmentType.PREVIEW
"Recap" -> MediaSegmentType.RECAP
"Commercial" -> MediaSegmentType.COMMERCIAL
else -> return
}

val action: MediaSegmentAction = when(actionString) {
"None" -> MediaSegmentAction.NOTHING
"Skip" -> MediaSegmentAction.SKIP
"AskToSkip" -> MediaSegmentAction.ASK_TO_SKIP
else -> return
}

mediaSegmentRepository.setDefaultSegmentTypeAction(type, action)
}
}
52 changes: 52 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.player.interaction.PlayerLifecycleObserver
import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback
import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper
import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction
import org.jellyfin.mobile.player.mediasegments.MediaSegmentRepository
import org.jellyfin.mobile.player.queue.QueueManager
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.player.ui.DecoderType
Expand All @@ -47,7 +49,9 @@ import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
import org.jellyfin.mobile.utils.applyDefaultAudioAttributes
import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes
import org.jellyfin.mobile.utils.extensions.end
import org.jellyfin.mobile.utils.extensions.scaleInRange
import org.jellyfin.mobile.utils.extensions.start
import org.jellyfin.mobile.utils.extensions.width
import org.jellyfin.mobile.utils.getVolumeLevelPercent
import org.jellyfin.mobile.utils.getVolumeRange
Expand All @@ -65,7 +69,9 @@ import org.jellyfin.sdk.api.operations.DisplayPreferencesApi
import org.jellyfin.sdk.api.operations.HlsSegmentApi
import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
Expand Down Expand Up @@ -95,6 +101,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
val queueManager = QueueManager(this)
val mediaSourceOrNull: JellyfinMediaSource?
get() = queueManager.currentMediaSourceOrNull
private val mediaSegmentRepository = MediaSegmentRepository()

// ExoPlayer
private val _player = MutableLiveData<ExoPlayer?>()
Expand Down Expand Up @@ -263,6 +270,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),

val startTime = jellyfinMediaSource.startTimeMs
if (startTime > 0) player.seekTo(startTime)

applyMediaSegments(jellyfinMediaSource)

player.playWhenReady = playWhenReady

mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata())
Expand Down Expand Up @@ -319,6 +329,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
positionTicks = mediaSource.startTimeMs * Constants.TICKS_PER_MILLISECOND,
volumeLevel = audioManager.getVolumeLevelPercent(),
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
)
} catch (e: ApiClientException) {
Expand Down Expand Up @@ -347,6 +358,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND,
volumeLevel = (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
)
} catch (e: ApiClientException) {
Expand Down Expand Up @@ -400,6 +412,46 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
}

private fun applyMediaSegments(jellyfinMediaSource: JellyfinMediaSource) {
viewModelScope.launch {
if (jellyfinMediaSource.item != null) {
val mediaSegments = runCatching {
mediaSegmentRepository.getSegmentsForItem(jellyfinMediaSource.item)
}.getOrNull().orEmpty()

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

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

private fun addSkipAction(mediaSegment: MediaSegmentDto) {
val player = playerOrNull ?: return

player.createMessage { _, _ ->
// 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
// TODO: The above is probably true for jellyfin-android as well.
// But I believe there is no such logic here.
viewModelScope.launch(Dispatchers.Main) {
player.seekTo(mediaSegment.end.inWholeMilliseconds)
}
}
// Segments at position 0 will never be hit by ExoPlayer so we need to add a minimum value
.setPosition(mediaSegment.start.inWholeMilliseconds.coerceAtLeast(1))
.setDeleteAfterDelivery(false)
.send()
}

// Player controls

fun play() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import org.jellyfin.mobile.player.cast.ICastPlayerProvider
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.mediaUri
import org.jellyfin.mobile.utils.toast
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
Expand All @@ -53,7 +52,6 @@ import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem

class MediaService : MediaBrowserServiceCompat() {
private val apiClientController: ApiClientController by inject()
private val apiClient: ApiClient by inject()
private val libraryBrowser: LibraryBrowser by inject()

private val serviceScope = MainScope()
Expand Down Expand Up @@ -177,12 +175,7 @@ class MediaService : MediaBrowserServiceCompat() {
loadingJob.join()

val items = try {
if (apiClient.userId != null) {
libraryBrowser.loadLibrary(parentId)
} else {
Timber.e("Missing userId in ApiClient")
null
}
libraryBrowser.loadLibrary(parentId)
} catch (e: ApiClientException) {
Timber.e(e)
null
Expand Down
Loading