Merge branch 'feature/exoplayer' into develop
This commit is contained in:
commit
8ef78c36d2
17 changed files with 474 additions and 10 deletions
|
@ -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'
|
||||
|
|
BIN
app/libs/extension-ffmpeg-release.aar
Normal file
BIN
app/libs/extension-ffmpeg-release.aar
Normal file
Binary file not shown.
|
@ -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" />
|
||||
|
||||
|
|
67
app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
Normal file
67
app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -81,7 +81,8 @@ class HomeFragment : Fragment() {
|
|||
findNavController().navigate(
|
||||
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
|
||||
item.id,
|
||||
item.name
|
||||
item.name,
|
||||
item.type ?: "Unknown"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -47,7 +47,8 @@ class LibraryFragment : Fragment() {
|
|||
findNavController().navigate(
|
||||
LibraryFragmentDirections.actionLibraryFragmentToMediaInfoFragment(
|
||||
item.id,
|
||||
item.name
|
||||
item.name,
|
||||
item.type ?: "Unknown"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
16
app/src/main/res/layout/activity_player.xml
Normal file
16
app/src/main/res/layout/activity_player.xml
Normal 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>
|
|
@ -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>
|
Loading…
Reference in a new issue