Skip to content

Commit

Permalink
Add ffmpeg extension (#520)
Browse files Browse the repository at this point in the history
  • Loading branch information
Maxr1998 authored Sep 4, 2021
1 parent 630fd78 commit a9f07e1
Show file tree
Hide file tree
Showing 10 changed files with 158 additions and 79 deletions.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ dependencies {
implementation(libs.androidx.media)
implementation(libs.androidx.mediarouter)
implementation(libs.bundles.exoplayer)
implementation(libs.jellyfin.exoplayer.ffmpegextension)
@Suppress("UnstableApiUsage")
proprietaryImplementation(libs.exoplayer.cast)
@Suppress("UnstableApiUsage")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,32 +262,31 @@ class DeviceProfileBuilder {
/**
* List of PCM codecs supported by ExoPlayer by default
*/
private val PCM_CODECS = arrayOf("pcm_s8", "pcm_s16be", "pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le")
private val PCM_CODECS = arrayOf("pcm_s8", "pcm_s16be", "pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "pcm_alaw", "pcm_mulaw")

/**
* IMPORTANT: Must have same length as [SUPPORTED_CONTAINER_FORMATS],
* as it maps the codecs to the containers with the same index!
*/
// TODO: add ffmpeg extension to support all (temporarily disabled) codecs
private val AVAILABLE_AUDIO_CODECS = arrayOf(
// mp4
arrayOf("mp1", "mp2", "mp3", "aac"),
arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3"),
// fmp4
emptyArray(),
arrayOf("mp3", "aac", "ac3", "eac3"),
// webm
arrayOf("vorbis", "opus"),
// mkv
arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac" /*, "ac3", "eac3", "dts"*/),
arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts"),
// mp3
arrayOf("mp3"),
// ogg
arrayOf("vorbis", "opus", "flac"),
// wav
PCM_CODECS,
// ts
arrayOf("mp1", "mp2", "mp3", "aac" /*, "ac3", "dts"*/),
arrayOf("mp1", "mp2", "mp3", "aac", "ac3", "dts"),
// m2ts
arrayOf(*PCM_CODECS, "aac" /*, "ac3", "dts"*/),
arrayOf(*PCM_CODECS, "aac", "ac3", "dts"),
// flv
arrayOf("mp3", "aac"),
// aac
Expand All @@ -302,7 +301,7 @@ class DeviceProfileBuilder {
* List of audio codecs that will be added to the device profile regardless of [MediaCodecList] advertising them.
* This is especially useful for codecs supported by decoders integrated to ExoPlayer or added through an extension.
*/
private val FORCED_AUDIO_CODECS = arrayOf(*PCM_CODECS)
private val FORCED_AUDIO_CODECS = arrayOf(*PCM_CODECS, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd")

private val EXO_EMBEDDED_SUBTITLES = arrayOf("srt", "subrip", "ttml")
private val EXO_EXTERNAL_SUBTITLES = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,14 @@ class PlayerFragment : Fragment() {
* @return true if the audio track was changed
*/
fun onAudioTrackSelected(index: Int): Boolean {
return viewModel.mediaQueueManager.selectAudioTrack(index)
return viewModel.selectAudioTrack(index)
}

/**
* @return true if the subtitle was changed
*/
fun onSubtitleSelected(index: Int): Boolean {
return viewModel.mediaQueueManager.selectSubtitle(index)
return viewModel.selectSubtitle(index)
}

/**
Expand Down
52 changes: 37 additions & 15 deletions app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultRenderersFactory
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.analytics.AnalyticsCollector
import com.google.android.exoplayer2.util.Clock
import com.google.android.exoplayer2.util.EventLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand All @@ -32,9 +34,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.getRendererIndexByType
import org.jellyfin.mobile.utils.getVolumeLevelPercent
import org.jellyfin.mobile.utils.getVolumeRange
import org.jellyfin.mobile.utils.logTracks
import org.jellyfin.mobile.utils.scaleInRange
import org.jellyfin.mobile.utils.seekToOffset
import org.jellyfin.mobile.utils.setPlaybackState
Expand All @@ -50,6 +52,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean

class PlayerViewModel(application: Application) : AndroidViewModel(application), KoinComponent, Player.Listener {
private val playStateApi by inject<PlayStateApi>()
Expand All @@ -68,6 +71,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
private val _playerState = MutableLiveData<Int>()
val player: LiveData<ExoPlayer?> get() = _player
val playerState: LiveData<Int> get() = _playerState
private val eventLogger = EventLogger(mediaQueueManager.trackSelector)
private val analyticsCollector = AnalyticsCollector(Clock.DEFAULT).apply {
addListener(eventLogger)
}
private val initialTracksSelected = AtomicBoolean(false)

private var progressUpdateJob: Job? = null

Expand Down Expand Up @@ -112,13 +120,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
* Setup a new [SimpleExoPlayer] for video playback, register callbacks and set attributes
*/
fun setupPlayer() {
_player.value = SimpleExoPlayer.Builder(getApplication()).apply {
val renderersFactory = DefaultRenderersFactory(getApplication()).apply {
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
}
_player.value = SimpleExoPlayer.Builder(getApplication(), renderersFactory).apply {
setTrackSelector(mediaQueueManager.trackSelector)
if (BuildConfig.DEBUG) {
setAnalyticsCollector(AnalyticsCollector(Clock.DEFAULT).apply {
addListener(mediaQueueManager.eventLogger)
})
}
if (BuildConfig.DEBUG) setAnalyticsCollector(analyticsCollector)
}.build().apply {
addListener(this@PlayerViewModel)
applyDefaultAudioAttributes(C.CONTENT_TYPE_MOVIE)
Expand All @@ -141,13 +148,16 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),

fun play(queueItem: MediaQueueManager.QueueItem.Loaded) {
val player = playerOrNull ?: return

player.setMediaSource(queueItem.exoMediaSource)
player.prepare()

initialTracksSelected.set(false)

val startTime = queueItem.jellyfinMediaSource.startTimeMs
if (startTime > 0) {
player.seekTo(startTime)
}
if (startTime > 0) player.seekTo(startTime)
player.playWhenReady = true

mediaSession.setMetadata(queueItem.jellyfinMediaSource.toMediaMetadata())

viewModelScope.launch {
Expand Down Expand Up @@ -295,6 +305,20 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
}

/**
* @see MediaQueueManager.selectAudioTrack
*/
fun selectAudioTrack(streamIndex: Int): Boolean = mediaQueueManager.selectAudioTrack(streamIndex).also { success ->
if (success) playerOrNull?.logTracks(analyticsCollector)
}

/**
* @see MediaQueueManager.selectSubtitle
*/
fun selectSubtitle(streamIndex: Int): Boolean = mediaQueueManager.selectSubtitle(streamIndex).also { success ->
if (success) playerOrNull?.logTracks(analyticsCollector)
}

/**
* Set the playback speed to [speed]
*
Expand Down Expand Up @@ -325,10 +349,6 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
audioManager.setStreamVolume(stream, scaled, 0)
}

fun getPlayerRendererIndex(type: Int): Int {
return playerOrNull?.getRendererIndexByType(type) ?: -1
}

@SuppressLint("SwitchIntDef")
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
val player = playerOrNull ?: return
Expand All @@ -338,7 +358,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),

// Initialise various components
if (playbackState == Player.STATE_READY) {
mediaQueueManager.selectInitialTracks()
if (!initialTracksSelected.getAndSet(true)) {
mediaQueueManager.selectInitialTracks()
}
mediaSession.isActive = true
notificationHelper.postNotification()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ 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.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.util.EventLogger
import org.jellyfin.mobile.bridge.PlayOptions
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.mobile.player.PlayerViewModel
import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType
import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.operations.VideosApi
import org.jellyfin.sdk.model.api.DeviceProfile
Expand All @@ -35,14 +36,12 @@ class MediaQueueManager(
private val mediaSourceResolver: MediaSourceResolver by inject()
private val deviceProfile: DeviceProfile by inject()
private val videosApi: VideosApi by inject()
val trackSelector = DefaultTrackSelector(viewModel.getApplication<Application>())
private val _mediaQueue: MutableLiveData<QueueItem.Loaded> = MutableLiveData()
val mediaQueue: LiveData<QueueItem.Loaded> get() = _mediaQueue

private var currentPlayOptions: PlayOptions? = null

val trackSelector = DefaultTrackSelector(viewModel.getApplication<Application>())
val eventLogger = EventLogger(trackSelector)

/**
* Handle initial playback options from fragment.
* Start of a playback session that can contain one or multiple played videos.
Expand Down Expand Up @@ -207,19 +206,26 @@ class MediaQueueManager(
fun selectInitialTracks() {
val queueItem = _mediaQueue.value ?: return
val mediaSource = queueItem.jellyfinMediaSource
selectAudioTrack(mediaSource.selectedAudioStream?.index ?: -1, true)
selectSubtitle(mediaSource.selectedSubtitleStream?.index ?: -1, true)
selectAudioTrack(mediaSource.selectedAudioStream?.index ?: -1, initial = true)
selectSubtitle(mediaSource.selectedSubtitleStream?.index ?: -1, initial = true)
}

/**
* Select an audio track in the media source and apply changes to the current player.
*
* @param streamIndex the [MediaStream.index] that should be selected
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
* @return true if the audio track was changed
*/
fun selectAudioTrack(streamIndex: Int): Boolean {
return selectAudioTrack(streamIndex, initial = false)
}

/**
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
* @see selectAudioTrack
*/
@Suppress("ReturnCount")
fun selectAudioTrack(streamIndex: Int, initial: Boolean = false): Boolean {
private fun selectAudioTrack(streamIndex: Int, initial: Boolean): Boolean {
val mediaSource = _mediaQueue.value?.jellyfinMediaSource ?: return false
val sourceIndex = mediaSource.audioStreams.binarySearchBy(streamIndex, selector = MediaStream::index)

Expand All @@ -235,32 +241,24 @@ class MediaQueueManager(
!mediaSource.selectAudioStream(sourceIndex) -> return false
}

// Handle selection in player
val parameters = trackSelector.buildUponParameters()
val rendererIndex = viewModel.getPlayerRendererIndex(C.TRACK_TYPE_AUDIO)
val trackInfo = trackSelector.currentMappedTrackInfo
return if (rendererIndex >= 0 && trackInfo != null) {
val trackGroups = trackInfo.getTrackGroups(rendererIndex)
if (sourceIndex in 0 until trackGroups.length) {
val selection = DefaultTrackSelector.SelectionOverride(sourceIndex, 0)
parameters.setSelectionOverride(rendererIndex, trackGroups, selection)
} else {
parameters.clearSelectionOverride(rendererIndex, trackGroups)
}
parameters.setRendererDisabled(rendererIndex, false)
trackSelector.setParameters(parameters)
true
} else false
return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, sourceIndex)
}

/**
* Select a subtitle track in the media source and apply changes to the current player.
*
* @param streamIndex the [MediaStream.index] that should be selected
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
* @return true if the subtitle was changed
*/
fun selectSubtitle(streamIndex: Int, initial: Boolean = false): Boolean {
fun selectSubtitle(streamIndex: Int): Boolean {
return selectSubtitle(streamIndex, initial = false)
}

/**
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
* @see selectSubtitle
*/
private fun selectSubtitle(streamIndex: Int, initial: Boolean): Boolean {
val mediaSource = _mediaQueue.value?.jellyfinMediaSource ?: return false
val sourceIndex = mediaSource.subtitleStreams.binarySearchBy(streamIndex, selector = MediaStream::index)

Expand All @@ -271,24 +269,12 @@ class MediaQueueManager(
!mediaSource.selectSubtitleStream(sourceIndex) -> return false
}

// Handle selection in player
val rendererIndex = viewModel.getPlayerRendererIndex(C.TRACK_TYPE_TEXT)
val trackInfo = trackSelector.currentMappedTrackInfo
return if (rendererIndex >= 0 && trackInfo != null) {
val parameters = trackSelector.buildUponParameters()
val trackGroups = trackInfo.getTrackGroups(rendererIndex)
if (sourceIndex in 0 until trackGroups.length) {
val selection = DefaultTrackSelector.SelectionOverride(sourceIndex, 0)
parameters.setSelectionOverride(rendererIndex, trackGroups, selection)
parameters.setRendererDisabled(rendererIndex, false)
} else {
// No subtitle selected, clear selection overrides and disable renderer
parameters.clearSelectionOverride(rendererIndex, trackGroups)
parameters.setRendererDisabled(rendererIndex, true)
}
trackSelector.setParameters(parameters)
true
} else false
return when {
// Select new subtitle with suitable renderer
sourceIndex >= 0 -> trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, sourceIndex)
// No subtitle selected, clear selection overrides and disable all subtitle renderers
else -> trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT)
}
}

/**
Expand Down
16 changes: 6 additions & 10 deletions app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import android.os.Build
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.analytics.AnalyticsCollector
import com.google.android.exoplayer2.trackselection.MappingTrackSelector
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import com.google.android.exoplayer2.audio.AudioAttributes as ExoPlayerAudioAttributes

Expand Down Expand Up @@ -77,16 +79,6 @@ inline fun ExoPlayer.AudioComponent.applyDefaultAudioAttributes(@C.AudioContentT
setAudioAttributes(audioAttributes, true)
}

/**
* Get the index of the first renderer with the specified [type]
*/
fun ExoPlayer.getRendererIndexByType(type: Int): Int {
for (i in 0 until rendererCount) {
if (getRendererType(i) == type) return i
}
return -1
}

fun Player.seekToOffset(offsetMs: Long) {
var positionMs = currentPosition + offsetMs
val durationMs = duration
Expand All @@ -96,3 +88,7 @@ fun Player.seekToOffset(offsetMs: Long) {
positionMs = positionMs.coerceAtLeast(0)
seekTo(positionMs)
}

fun Player.logTracks(analyticsCollector: AnalyticsCollector) {
analyticsCollector.onTracksChanged(currentTrackGroups, currentTrackSelections)
}
Loading

0 comments on commit a9f07e1

Please sign in to comment.