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