Skip to content

Commit

Permalink
Implement bitrate selection
Browse files Browse the repository at this point in the history
  • Loading branch information
Maxr1998 committed Sep 21, 2022
1 parent 78eb8d8 commit 87ec4f0
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 22 deletions.
19 changes: 15 additions & 4 deletions app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
_player.value = null
}

fun play(queueItem: QueueManager.QueueItem.Loaded) {
fun load(queueItem: QueueManager.QueueItem.Loaded, playWhenReady: Boolean) {
val player = playerOrNull ?: return

player.setMediaSource(queueItem.exoMediaSource)
Expand All @@ -203,7 +203,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),

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

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

Expand Down Expand Up @@ -319,11 +319,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
// Player controls

fun play() {
playerOrNull?.playWhenReady = true
playerOrNull?.play()
}

fun pause() {
playerOrNull?.playWhenReady = false
playerOrNull?.pause()
}

fun rewind() {
Expand Down Expand Up @@ -371,6 +371,17 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
if (success) playerOrNull?.logTracks(analyticsCollector)
}

fun changeBitrate(bitrate: Int?) {
val player = playerOrNull ?: return

val playWhenReady = player.playWhenReady
pause()
val position = player.contentPosition
viewModelScope.launch {
mediaQueueManager.changeBitrate(bitrate, position, playWhenReady)
}
}

/**
* Set the playback speed to [speed]
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ class DeviceProfileBuilder {
)
}

@Suppress("LongMethod")
fun getDeviceProfile(): DeviceProfile {
val containerProfiles = ArrayList<ContainerProfile>()
val directPlayProfiles = ArrayList<DirectPlayProfile>()
Expand Down Expand Up @@ -163,6 +162,9 @@ class DeviceProfileBuilder {
containerProfiles = containerProfiles,
codecProfiles = codecProfiles,
subtitleProfiles = getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES, EXO_EXTERNAL_SUBTITLES),
maxStreamingBitrate = MAX_STREAMING_BITRATE,
maxStaticBitrate = MAX_STATIC_BITRATE,
musicStreamingTranscodingBitrate = MAX_MUSIC_TRANSCODING_BITRATE,

// TODO: remove redundant defaults after API/SDK is fixed
timelineOffsetSeconds = 0,
Expand Down Expand Up @@ -301,5 +303,23 @@ class DeviceProfileBuilder {
private val EXTERNAL_PLAYER_SUBTITLES = arrayOf(
"ssa", "ass", "srt", "subrip", "idx", "sub", "vtt", "webvtt", "ttml", "pgs", "pgssub", "smi", "smil",
)

/**
* Taken from Jellyfin Web:
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L276
*/
private const val MAX_STREAMING_BITRATE = 120000000

/**
* Taken from Jellyfin Web:
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L372
*/
private const val MAX_STATIC_BITRATE = 100000000

/**
* Taken from Jellyfin Web:
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L373
*/
private const val MAX_MUSIC_TRANSCODING_BITRATE = 384000
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ data class PlayOptions(
val startPositionTicks: Long?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val maxBitrate: Int?,
) : Parcelable {
companion object {
fun fromJson(json: JSONObject): PlayOptions? = try {
Expand All @@ -33,6 +34,7 @@ data class PlayOptions(
startPositionTicks = json.optLong("startPositionTicks").takeIf { it > 0 },
audioStreamIndex = json.optString("audioStreamIndex").toIntOrNull(),
subtitleStreamIndex = json.optString("subtitleStreamIndex").toIntOrNull(),
maxBitrate = null,
)
} catch (e: JSONException) {
Timber.e(e, "Failed to parse playback options: %s", json)
Expand Down
33 changes: 29 additions & 4 deletions app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType
import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup
import org.jellyfin.sdk.api.client.ApiClient
Expand Down Expand Up @@ -52,19 +53,21 @@ class QueueManager(
*
* @return an error of type [PlayerException] or null on success.
*/
suspend fun startPlayback(playOptions: PlayOptions): PlayerException? {
suspend fun startPlayback(playOptions: PlayOptions, playWhenReady: Boolean): PlayerException? {
if (playOptions != currentPlayOptions) {
val itemId = playOptions.run { mediaSourceId ?: ids[playOptions.startIndex] }
mediaSourceResolver.resolveMediaSource(
itemId = itemId,
deviceProfile = deviceProfile,
maxStreamingBitrate = playOptions.maxBitrate,
startTimeTicks = playOptions.startPositionTicks,
audioStreamIndex = playOptions.audioStreamIndex,
subtitleStreamIndex = playOptions.subtitleStreamIndex,
).onSuccess { jellyfinMediaSource ->
val previous = QueueItem.Stub(playOptions.ids.take(playOptions.startIndex))
val next = QueueItem.Stub(playOptions.ids.drop(playOptions.startIndex + 1))
createQueueItem(jellyfinMediaSource, previous, next).play()
createQueueItem(jellyfinMediaSource, previous, next).play(playWhenReady)
currentPlayOptions = playOptions
}.onFailure { error ->
// Should always be of this type, other errors are silently dropped
return error as? PlayerException
Expand All @@ -73,10 +76,32 @@ class QueueManager(
return null
}

/**
* Reinitialize current media source without changing settings
*/
fun tryRestartPlayback() {
_mediaQueue.value?.play()
}

/**
* Change the maximum bitrate to the specified value.
*
* @param positionMs the current stream position to return to after reloading the source.
* @param playWhenReady whether the stream should play after the source was reloaded.
*/
suspend fun changeBitrate(bitrate: Int?, positionMs: Long, playWhenReady: Boolean) {
val currentPlayOptions = currentPlayOptions ?: return

// Bitrate didn't change, ignore
if (currentPlayOptions.maxBitrate == bitrate) return

val playOptions = currentPlayOptions.copy(
startPositionTicks = positionMs * Constants.TICKS_PER_MILLISECOND,
maxBitrate = bitrate,
)
startPlayback(playOptions, playWhenReady)
}

@CheckResult
private fun createQueueItem(jellyfinMediaSource: JellyfinMediaSource, previous: QueueItem, next: QueueItem): QueueItem.Loaded {
val exoMediaSource = prepareStreams(jellyfinMediaSource)
Expand Down Expand Up @@ -329,8 +354,8 @@ class QueueManager(
) : QueueItem()
}

private fun QueueItem.Loaded.play() {
private fun QueueItem.Loaded.play(playWhenReady: Boolean = true) {
_mediaQueue.value = this
viewModel.play(this)
viewModel.load(this, playWhenReady)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.*
import java.util.UUID

class MediaSourceResolver(private val apiClient: ApiClient) {
private val mediaInfoApi: MediaInfoApi = apiClient.mediaInfoApi
Expand All @@ -21,6 +21,7 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
suspend fun resolveMediaSource(
itemId: UUID,
deviceProfile: DeviceProfile,
maxStreamingBitrate: Int? = null,
startTimeTicks: Long? = null,
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
Expand All @@ -33,10 +34,10 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
data = PlaybackInfoDto(
userId = apiClient.userId,
deviceProfile = deviceProfile,
maxStreamingBitrate = maxStreamingBitrate ?: deviceProfile.maxStreamingBitrate,
startTimeTicks = startTimeTicks,
audioStreamIndex = audioStreamIndex,
subtitleStreamIndex = subtitleStreamIndex,
maxStreamingBitrate = /* 1 GB/s */ 1_000_000_000,
autoOpenLiveStream = true,
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class PlayerFragment : Fragment() {
context.toast(R.string.player_error_invalid_play_options)
return@launch
}
when (viewModel.mediaQueueManager.startPlayback(playOptions)) {
when (viewModel.mediaQueueManager.startPlayback(playOptions, playWhenReady = true)) {
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
Expand Down Expand Up @@ -270,6 +270,10 @@ class PlayerFragment : Fragment() {
return viewModel.mediaQueueManager.toggleSubtitles()
}

fun onBitrateChanged(bitrate: Int?) {
viewModel.changeBitrate(bitrate)
}

/**
* @return true if the playback speed was changed
*/
Expand Down
67 changes: 59 additions & 8 deletions app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import androidx.core.view.isVisible
import org.jellyfin.mobile.R
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
import org.jellyfin.mobile.player.queue.QueueManager
import org.jellyfin.sdk.model.api.MediaStream
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.util.Locale

/**
Expand All @@ -23,19 +26,24 @@ class PlayerMenus(
private val fragment: PlayerFragment,
private val playerBinding: FragmentPlayerBinding,
private val playerControlsBinding: ExoPlayerControlViewBinding,
) : PopupMenu.OnDismissListener {
) : PopupMenu.OnDismissListener,
KoinComponent {

private val context = playerBinding.root.context
private val qualityOptionsProvider: QualityOptionsProvider by inject()
private val previousButton: View by playerControlsBinding::previousButton
private val nextButton: View by playerControlsBinding::nextButton
private val lockScreenButton: View by playerControlsBinding::lockScreenButton
private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton
private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton
private val speedButton: View by playerControlsBinding::speedButton
private val qualityButton: View by playerControlsBinding::qualityButton
private val infoButton: View by playerControlsBinding::infoButton
private val playbackInfo: TextView by playerBinding::playbackInfo
private val audioStreamsMenu: PopupMenu = createAudioStreamsMenu()
private val subtitlesMenu: PopupMenu = createSubtitlesMenu()
private val speedMenu: PopupMenu = createSpeedMenu()
private val qualityMenu: PopupMenu = createQualityMenu()

private var subtitleCount = 0
private var subtitlesOn = false
Expand Down Expand Up @@ -71,6 +79,10 @@ class PlayerMenus(
fragment.suppressControllerAutoHide(true)
speedMenu.show()
}
qualityButton.setOnClickListener {
fragment.suppressControllerAutoHide(true)
qualityMenu.show()
}
infoButton.setOnClickListener {
playbackInfo.isVisible = !playbackInfo.isVisible
}
Expand All @@ -91,19 +103,21 @@ class PlayerMenus(

updateSubtitlesButton()

val height = mediaSource.selectedVideoStream?.height
val width = mediaSource.selectedVideoStream?.width
if (height != null && width != null) {
buildQualityMenu(qualityMenu.menu, width, height)
} else {
qualityButton.isVisible = false
}

val playMethod = context.getString(R.string.playback_info_play_method, mediaSource.playMethod)
val videoTracksInfo = buildMediaStreamsInfo(
mediaStreams = mediaSource.videoStreams,
prefix = R.string.playback_info_video_streams,
maxStreams = MAX_VIDEO_STREAMS_DISPLAY,
streamSuffix = { stream ->
val bitrate = stream.bitRate
when {
bitrate == null -> ""
bitrate > BITRATE_MEGA_BIT -> " (%.2f Mbps)".format(Locale.getDefault(), bitrate.toDouble() / BITRATE_MEGA_BIT)
bitrate > BITRATE_KILO_BIT -> " (%.2f Kbps)".format(Locale.getDefault(), bitrate.toDouble() / BITRATE_KILO_BIT)
else -> " (%d bps)".format(bitrate)
}
stream.bitRate?.let { bitrate -> " (${formatBitrate(bitrate.toDouble())})" }.orEmpty()
},
)
val audioTracksInfo = buildMediaStreamsInfo(
Expand Down Expand Up @@ -189,6 +203,18 @@ class PlayerMenus(
setOnDismissListener(this@PlayerMenus)
}

private fun createQualityMenu() = PopupMenu(context, qualityButton).apply {
setOnMenuItemClickListener { clickedItem: MenuItem ->
fragment.onBitrateChanged(clickedItem.itemId.takeUnless { bitrate -> bitrate == 0 })
menu.forEach { item ->
item.isChecked = false
}
clickedItem.isChecked = true
true
}
setOnDismissListener(this@PlayerMenus)
}

private fun buildMenuItems(
menu: Menu,
groupId: Int,
Expand Down Expand Up @@ -217,6 +243,18 @@ class PlayerMenus(
subtitlesButton.setImageState(stateSet, true)
}

private fun buildQualityMenu(menu: Menu, videoWidth: Int, videoHeight: Int) {
menu.clear()
val options = qualityOptionsProvider.getApplicableQualityOptions(videoWidth, videoHeight)
options.map { option ->
val title = when (val bitrate = option.bitrate) {
0 -> context.getString(R.string.menu_item_auto)
else -> "${option.maxHeight}p - ${formatBitrate(bitrate.toDouble())}"
}
menu.add(QUALITY_MENU_GROUP, option.bitrate, Menu.NONE, title)
}
}

fun dismissPlaybackInfo() {
playbackInfo.isVisible = false
}
Expand All @@ -225,10 +263,23 @@ class PlayerMenus(
fragment.suppressControllerAutoHide(false)
}

private fun formatBitrate(bitrate: Double): String {
val (value, unit) = when {
bitrate > BITRATE_MEGA_BIT -> bitrate / BITRATE_MEGA_BIT to " Mbps"
bitrate > BITRATE_KILO_BIT -> bitrate / BITRATE_KILO_BIT to " kbps"
else -> bitrate to " bps"
}

// Remove unnecessary trailing zeros
val formatted = "%.2f".format(Locale.getDefault(), value).removeSuffix(".00")
return formatted + unit
}

companion object {
private const val SUBTITLES_MENU_GROUP = 0
private const val AUDIO_MENU_GROUP = 1
private const val SPEED_MENU_GROUP = 2
private const val QUALITY_MENU_GROUP = 3

private const val MAX_VIDEO_STREAMS_DISPLAY = 3
private const val MAX_AUDIO_STREAMS_DISPLAY = 5
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/drawable/ic_settings_white_24dp.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/>
</vector>
Loading

0 comments on commit 87ec4f0

Please sign in to comment.