Merge branch 'feature/exoplayer' into develop

This commit is contained in:
jarnedemeulemeester 2021-07-14 12:05:17 +02:00
commit 8ef78c36d2
No known key found for this signature in database
GPG key ID: 60884A0C1EBA43E5
17 changed files with 474 additions and 10 deletions

View file

@ -67,6 +67,7 @@ dependencies {
// RecyclerView
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.recyclerview:recyclerview-selection:1.1.0"
implementation files('libs/extension-ffmpeg-release.aar')
// Room
def room_version = "2.3.0"
@ -87,6 +88,11 @@ dependencies {
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
// ExoPlayer
def exoplayer_version = "2.14.1"
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
// Testing
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'

Binary file not shown.

View file

@ -12,13 +12,14 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin">
<activity android:name=".PlayerActivity" />
<activity
android:name=".MainActivity"
android:label="@string/title_activity_main" />
<activity
android:name=".SetupActivity"
android:windowSoftInputMode="adjustPan"
android:theme="@style/Theme.JellyfinSplashScreen">
android:theme="@style/Theme.JellyfinSplashScreen"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -0,0 +1,67 @@
package dev.jdtech.jellyfin
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import androidx.activity.viewModels
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.navigation.navArgs
import com.google.android.exoplayer2.ui.StyledPlayerView
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
@AndroidEntryPoint
class PlayerActivity : AppCompatActivity() {
private val viewModel: PlayerActivityViewModel by viewModels()
private val args: PlayerActivityArgs by navArgs()
private lateinit var playerView: StyledPlayerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d("PlayerActivity", "onCreate")
setContentView(R.layout.activity_player)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
playerView = findViewById(R.id.video_view)
viewModel.player.observe(this, {
playerView.player = it
})
viewModel.playbackStateListener.navigateBack.observe(this, {
if (it) {
onBackPressed()
}
})
if (viewModel.player.value == null) {
viewModel.initializePlayer(args.itemId, args.mediaSourceId, args.playbackPosition)
}
hideSystemUI()
}
override fun onDestroy() {
super.onDestroy()
Log.d("PlayerActivity", "onDestroy")
showSystemUI()
}
private fun hideSystemUI() {
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, playerView).let { controller ->
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
private fun showSystemUI() {
WindowCompat.setDecorFitsSystemWindows(window, true)
WindowInsetsControllerCompat(window, playerView).show(WindowInsetsCompat.Type.systemBars())
}
}

View file

@ -32,6 +32,9 @@ class JellyfinApi(context: Context, baseUrl: String) {
val userLibraryApi = UserLibraryApi(api)
val showsApi = TvShowsApi(api)
val sessionApi = SessionApi(api)
val videosApi = VideosApi(api)
val mediaInfoApi = MediaInfoApi(api)
val playstateApi = PlayStateApi(api)
companion object {
@Volatile

View file

@ -0,0 +1,24 @@
package dev.jdtech.jellyfin.dialogs
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import java.lang.IllegalStateException
class VideoVersionDialogFragment(
private val viewModel: MediaInfoViewModel
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val items = viewModel.mediaSources.value!!.map { it.name }
return activity?.let {
val builder = AlertDialog.Builder(it)
builder.setTitle("Select a version")
.setItems(items.toTypedArray()) { _, which ->
viewModel.navigateToPlayer(viewModel.mediaSources.value!![which])
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}

View file

@ -6,11 +6,13 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import java.util.*
@AndroidEntryPoint
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
@ -29,11 +31,21 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.lifecycleOwner = this
binding.viewModel = viewModel
binding.playButton.setOnClickListener {
viewModel.mediaSources.value?.get(0)?.id?.let { mediaSourceId ->
navigateToPlayerActivity(args.episodeId,
mediaSourceId,
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
)
}
}
viewModel.item.observe(viewLifecycleOwner, { episode ->
if (episode.userData?.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(), context?.resources?.displayMetrics
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
context?.resources?.displayMetrics
).toInt()
binding.progressBar.visibility = View.VISIBLE
}
@ -43,4 +55,14 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
return binding.root
}
private fun navigateToPlayerActivity(itemId: UUID, mediaSourceId: String, playbackPosition: Long) {
findNavController().navigate(
EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToPlayerActivity(
itemId,
mediaSourceId,
playbackPosition
)
)
}
}

View file

@ -81,7 +81,8 @@ class HomeFragment : Fragment() {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.id,
item.name
item.name,
item.type ?: "Unknown"
)
)
}

View file

@ -47,7 +47,8 @@ class LibraryFragment : Fragment() {
findNavController().navigate(
LibraryFragmentDirections.actionLibraryFragmentToMediaInfoFragment(
item.id,
item.name
item.name,
item.type ?: "Unknown"
)
)
}

View file

@ -14,8 +14,10 @@ import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.*
@AndroidEntryPoint
class MediaInfoFragment : Fragment() {
@ -52,6 +54,16 @@ class MediaInfoFragment : Fragment() {
}
})
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { mediaSource ->
mediaSource.id?.let {
navigateToPlayerActivity(
args.itemId,
it,
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
)
}
})
binding.trailerButton.setOnClickListener {
val intent = Intent(
Intent.ACTION_VIEW,
@ -70,7 +82,26 @@ class MediaInfoFragment : Fragment() {
}, fixedWidth = true)
binding.peopleRecyclerView.adapter = PersonListAdapter()
viewModel.loadData(args.itemId)
binding.playButton.setOnClickListener {
if (args.itemType == "Movie") {
if (!viewModel.mediaSources.value.isNullOrEmpty()) {
if (viewModel.mediaSources.value!!.size > 1) {
VideoVersionDialogFragment(viewModel).show(
parentFragmentManager,
"videoversiondialog"
)
} else {
navigateToPlayerActivity(
args.itemId,
viewModel.mediaSources.value!![0].id!!,
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
)
}
}
}
}
viewModel.loadData(args.itemId, args.itemType)
}
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
@ -91,4 +122,18 @@ class MediaInfoFragment : Fragment() {
)
)
}
private fun navigateToPlayerActivity(
itemId: UUID,
mediaSourceId: String,
playbackPosition: Long
) {
findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
itemId,
mediaSourceId,
playbackPosition
)
)
}
}

View file

@ -2,6 +2,7 @@ package dev.jdtech.jellyfin.repository
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.*
interface JellyfinRepository {
@ -14,4 +15,14 @@ interface JellyfinRepository {
suspend fun getNextUp(seriesId: UUID): List<BaseItemDto>
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID, fields: List<ItemFields>? = null): List<BaseItemDto>
suspend fun getMediaSources(itemId: UUID): List<MediaSourceInfo>
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String
suspend fun postPlaybackStart(itemId: UUID)
suspend fun postPlaybackStop(itemId: UUID, positionTicks: Long)
suspend fun postPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean)
}

View file

@ -1,10 +1,10 @@
package dev.jdtech.jellyfin.repository
import android.util.Log
import dev.jdtech.jellyfin.api.JellyfinApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.*
import java.util.*
class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRepository {
@ -60,4 +60,79 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
}
return episodes
}
override suspend fun getMediaSources(itemId: UUID): List<MediaSourceInfo> {
val mediaSourceInfoList: List<MediaSourceInfo>
val mediaInfo by jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId, PlaybackInfoDto(
userId = jellyfinApi.userId!!,
deviceProfile = DeviceProfile(
name = "Direct play all",
maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000,
codecProfiles = listOf(),
containerProfiles = listOf(),
directPlayProfiles = listOf(
DirectPlayProfile(
type = DlnaProfileType.VIDEO
), DirectPlayProfile(type = DlnaProfileType.AUDIO)
),
transcodingProfiles = listOf(),
responseProfiles = listOf(),
enableAlbumArtInDidl = false,
enableMsMediaReceiverRegistrar = false,
enableSingleAlbumArtLimit = false,
enableSingleSubtitleLimit = false,
ignoreTranscodeByteRangeRequests = false,
maxAlbumArtHeight = 1_000_000_000,
maxAlbumArtWidth = 1_000_000_000,
requiresPlainFolders = false,
requiresPlainVideoItems = false,
timelineOffsetSeconds = 0
),
startTimeTicks = null,
audioStreamIndex = null,
subtitleStreamIndex = null,
maxStreamingBitrate = 1_000_000_000,
)
)
mediaSourceInfoList = mediaInfo.mediaSources ?: listOf()
return mediaSourceInfoList
}
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String {
var streamUrl = ""
withContext(Dispatchers.IO) {
try {
streamUrl = jellyfinApi.videosApi.getVideoStreamUrl(
itemId,
static = true,
mediaSourceId = mediaSourceId
)
} catch (e: Exception) {
Log.e("JellyfinRepository", "${e.message}")
}
}
return streamUrl
}
override suspend fun postPlaybackStart(itemId: UUID) {
Log.d("PlayerActivity", "Sending start $itemId")
withContext(Dispatchers.IO) {
jellyfinApi.playstateApi.onPlaybackStart(jellyfinApi.userId!!, itemId)
}
}
override suspend fun postPlaybackStop(itemId: UUID, positionTicks: Long) {
Log.d("PlayerActivity", "Sending stop $itemId")
withContext(Dispatchers.IO) {
jellyfinApi.playstateApi.onPlaybackStopped(jellyfinApi.userId!!, itemId, positionTicks = positionTicks)
}
}
override suspend fun postPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) {
withContext(Dispatchers.IO) {
jellyfinApi.playstateApi.onPlaybackProgress(jellyfinApi.userId!!, itemId, positionTicks = positionTicks, isPaused = isPaused)
}
}
}

View file

@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.text.DateFormat
import java.time.ZoneOffset
import java.util.*
@ -30,11 +31,15 @@ constructor(
private val _dateString = MutableLiveData<String>()
val dateString: LiveData<String> = _dateString
private val _mediaSources = MutableLiveData<List<MediaSourceInfo>>()
val mediaSources: LiveData<List<MediaSourceInfo>> = _mediaSources
fun loadEpisode(episodeId: UUID) {
viewModelScope.launch {
_item.value = jellyfinRepository.getItem(episodeId)
_runTime.value = "${_item.value?.runTimeTicks?.div(600000000)} min"
_dateString.value = getDateString(_item.value!!)
_mediaSources.value = jellyfinRepository.getMediaSources(episodeId)
}
}

View file

@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.*
import javax.inject.Inject
@ -49,7 +50,13 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _seasons = MutableLiveData<List<BaseItemDto>>()
val seasons: LiveData<List<BaseItemDto>> = _seasons
fun loadData(itemId: UUID) {
private val _mediaSources = MutableLiveData<List<MediaSourceInfo>>()
val mediaSources: LiveData<List<MediaSourceInfo>> = _mediaSources
private val _navigateToPlayer = MutableLiveData<MediaSourceInfo>()
val navigateToPlayer: LiveData<MediaSourceInfo> = _navigateToPlayer
fun loadData(itemId: UUID, itemType: String) {
viewModelScope.launch {
_item.value = jellyfinRepository.getItem(itemId)
_actors.value = getActors(_item.value!!)
@ -60,10 +67,13 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
_genresString.value = _item.value?.genres?.joinToString(separator = ", ")
_runTime.value = "${_item.value?.runTimeTicks?.div(600000000)} min"
_dateString.value = getDateString(_item.value!!)
if (_item.value!!.type == "Series") {
if (itemType == "Series") {
_nextUp.value = getNextUp(itemId)
_seasons.value = jellyfinRepository.getSeasons(itemId)
}
if (itemType == "Movie") {
_mediaSources.value = jellyfinRepository.getMediaSources(itemId)
}
}
}
@ -120,4 +130,8 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
else -> dateString
}
}
fun navigateToPlayer(mediaSource: MediaSourceInfo) {
_navigateToPlayer.value = mediaSource
}
}

View file

@ -0,0 +1,149 @@
package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.*
import javax.inject.Inject
@HiltViewModel
class PlayerActivityViewModel
@Inject
constructor(
private val application: Application,
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private var _player = MutableLiveData<SimpleExoPlayer>()
var player: LiveData<SimpleExoPlayer> = _player
private var playWhenReady = true
private var currentWindow = 0
private var playbackPosition: Long = 0
private var _playbackStateListener: PlaybackStateListener
private var itemId: UUID? = null
val playbackStateListener: PlaybackStateListener
get() = _playbackStateListener
init {
_playbackStateListener = PlaybackStateListener()
}
fun initializePlayer(itemId: UUID, mediaSourceId: String, playbackPosition: Long) {
this.itemId = itemId
val renderersFactory =
DefaultRenderersFactory(application).setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
val trackSelector = DefaultTrackSelector(application)
trackSelector.setParameters(
trackSelector.buildUponParameters().setTunnelingEnabled(true),
)
val player = SimpleExoPlayer.Builder(application, renderersFactory)
.setTrackSelector(trackSelector)
.build()
player.addListener(_playbackStateListener)
viewModelScope.launch {
val streamUrl = jellyfinRepository.getStreamUrl(itemId, mediaSourceId)
Log.d("PlayerActivity", streamUrl)
val mediaItem =
MediaItem.Builder()
.setMediaId(itemId.toString())
.setUri(streamUrl)
.build()
player.setMediaItem(mediaItem, playbackPosition)
player.playWhenReady = playWhenReady
player.prepare()
_player.value = player
jellyfinRepository.postPlaybackStart(itemId)
}
pollPosition(player, itemId)
}
private fun releasePlayer() {
itemId?.let { itemId ->
_player.value?.let { player ->
runBlocking {
jellyfinRepository.postPlaybackStop(itemId, player.currentPosition.times(10000))
}
}
}
if (player.value != null) {
playWhenReady = player.value!!.playWhenReady
playbackPosition = player.value!!.currentPosition
currentWindow = player.value!!.currentWindowIndex
player.value!!.removeListener(_playbackStateListener)
player.value!!.release()
_player.value = null
}
}
private fun pollPosition(player: SimpleExoPlayer, itemId: UUID) {
val handler = Handler(Looper.getMainLooper())
val runnable: Runnable = object : Runnable {
override fun run() {
viewModelScope.launch {
Log.d(
"PlayerActivity",
"Posting progress of $itemId, position: ${player.currentPosition}"
)
jellyfinRepository.postPlaybackProgress(
itemId,
player.currentPosition.times(10000),
!player.isPlaying
)
}
handler.postDelayed(this, 2000)
}
}
handler.post(runnable)
}
class PlaybackStateListener : Player.Listener {
private val _navigateBack = MutableLiveData<Boolean>()
val navigateBack: LiveData<Boolean> = _navigateBack
override fun onPlaybackStateChanged(state: Int) {
var stateString = "UNKNOWN_STATE -"
when (state) {
ExoPlayer.STATE_IDLE -> {
stateString = "ExoPlayer.STATE_IDLE -"
}
ExoPlayer.STATE_BUFFERING -> {
stateString = "ExoPlayer.STATE_BUFFERING -"
}
ExoPlayer.STATE_READY -> {
stateString = "ExoPlayer.STATE_READY -"
}
ExoPlayer.STATE_ENDED -> {
stateString = "ExoPlayer.STATE_ENDED -"
_navigateBack.value = true
}
}
Log.d("PlayerActivity", "changed state to $stateString")
}
}
override fun onCleared() {
super.onCleared()
Log.d("PlayerActivity", "onCleared ViewModel")
releasePlayer()
}
}

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".PlayerActivity">
<com.google.android.exoplayer2.ui.StyledPlayerView
android:id="@+id/video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
app:show_subtitle_button="true"/>
</FrameLayout>

View file

@ -88,6 +88,12 @@
<action
android:id="@+id/action_mediaInfoFragment_to_episodeBottomSheetFragment"
app:destination="@id/episodeBottomSheetFragment" />
<action
android:id="@+id/action_mediaInfoFragment_to_playerActivity"
app:destination="@id/playerActivity" />
<argument
android:name="itemType"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/seasonFragment"
@ -122,5 +128,23 @@
<argument
android:name="episodeId"
app:argType="java.util.UUID" />
<action
android:id="@+id/action_episodeBottomSheetFragment_to_playerActivity"
app:destination="@id/playerActivity" />
</dialog>
<activity
android:id="@+id/playerActivity"
android:name="dev.jdtech.jellyfin.PlayerActivity"
android:label="activity_player"
tools:layout="@layout/activity_player" >
<argument
android:name="itemId"
app:argType="java.util.UUID" />
<argument
android:name="mediaSourceId"
app:argType="string" />
<argument
android:name="playbackPosition"
app:argType="long" />
</activity>
</navigation>