diff --git a/app/build.gradle b/app/build.gradle
index b5c31eee..582b2491 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
diff --git a/app/libs/extension-ffmpeg-release.aar b/app/libs/extension-ffmpeg-release.aar
new file mode 100644
index 00000000..c563cae6
Binary files /dev/null and b/app/libs/extension-ffmpeg-release.aar differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 610b4cf6..c69357d6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,13 +12,14 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin">
+
+ android:theme="@style/Theme.JellyfinSplashScreen"
+ android:windowSoftInputMode="adjustPan">
diff --git a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
new file mode 100644
index 00000000..3be4ce01
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
@@ -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())
+ }
+}
+
diff --git a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt
index 441b58bf..3637a91b 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt
@@ -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
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
new file mode 100644
index 00000000..912d4074
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
@@ -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")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
index 121c13c1..cadba184 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
@@ -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
+ )
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
index b9ae3b4c..cbb387da 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
@@ -81,7 +81,8 @@ class HomeFragment : Fragment() {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.id,
- item.name
+ item.name,
+ item.type ?: "Unknown"
)
)
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
index ea132a93..68c59992 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
@@ -47,7 +47,8 @@ class LibraryFragment : Fragment() {
findNavController().navigate(
LibraryFragmentDirections.actionLibraryFragmentToMediaInfoFragment(
item.id,
- item.name
+ item.name,
+ item.type ?: "Unknown"
)
)
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
index dfad556e..7e5608c9 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
@@ -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
+ )
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
index 84949023..e338a363 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
@@ -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
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID, fields: List? = null): List
+
+ suspend fun getMediaSources(itemId: UUID): List
+
+ 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)
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
index b751a0ef..9cbecb71 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
@@ -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 {
+ val mediaSourceInfoList: List
+ 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)
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt
index 6bf96236..31c5fe7a 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt
@@ -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()
val dateString: LiveData = _dateString
+ private val _mediaSources = MutableLiveData>()
+ val mediaSources: LiveData> = _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)
}
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt
index 05cf40ae..15c5dcd0 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt
@@ -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>()
val seasons: LiveData> = _seasons
- fun loadData(itemId: UUID) {
+ private val _mediaSources = MutableLiveData>()
+ val mediaSources: LiveData> = _mediaSources
+
+ private val _navigateToPlayer = MutableLiveData()
+ val navigateToPlayer: LiveData = _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
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
new file mode 100644
index 00000000..f002954f
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
@@ -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()
+ var player: LiveData = _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()
+ val navigateBack: LiveData = _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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml
new file mode 100644
index 00000000..3d53e4e8
--- /dev/null
+++ b/app/src/main/res/layout/activity_player.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml
index 5972e47d..afedf67f 100644
--- a/app/src/main/res/navigation/main_navigation.xml
+++ b/app/src/main/res/navigation/main_navigation.xml
@@ -88,6 +88,12 @@
+
+
+
+
+
+
+
+
\ No newline at end of file