diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 40309f38..e22ea575 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,6 +54,8 @@ android {
}
dependencies {
+ implementation("androidx.leanback:leanback:1.2.0-alpha01")
+
implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
implementation("androidx.appcompat:appcompat:1.3.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 447b4ca6..9d0528a2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,28 +5,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
new file mode 100644
index 00000000..806fcf3b
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
@@ -0,0 +1,69 @@
+package dev.jdtech.jellyfin
+
+import android.os.Build
+import android.view.View
+import android.view.WindowManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.updatePadding
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+
+abstract class BasePlayerActivity: AppCompatActivity() {
+
+ abstract val viewModel: PlayerActivityViewModel
+
+ override fun onPause() {
+ super.onPause()
+ viewModel.playWhenReady = viewModel.player.playWhenReady == true
+ viewModel.player.playWhenReady = false
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.player.playWhenReady = viewModel.playWhenReady
+ hideSystemUI()
+ }
+
+ @Suppress("DEPRECATION")
+ protected fun hideSystemUI() {
+ // These methods are deprecated but we still use them because the new WindowInsetsControllerCompat has a bug which makes the action bar reappear
+ window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
+ View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
+
+ window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ window.attributes.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+ }
+
+ protected fun isRendererType(
+ mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
+ rendererIndex: Int,
+ type: Int
+ ): Boolean {
+ val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
+ if (trackGroupArray.length == 0) {
+ return false
+ }
+ val trackType = mappedTrackInfo.getRendererType(rendererIndex)
+ return type == trackType
+ }
+
+ protected fun configureInsets(playerControls: View) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ playerControls
+ .setOnApplyWindowInsetsListener { _, windowInsets ->
+ val cutout = windowInsets.displayCutout
+ playerControls.updatePadding(
+ left = cutout?.safeInsetLeft ?: 0,
+ top = cutout?.safeInsetTop ?: 0,
+ right = cutout?.safeInsetRight ?: 0,
+ bottom = cutout?.safeInsetBottom ?: 0,
+ )
+ return@setOnApplyWindowInsetsListener windowInsets
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
index 76ce9bb1..e105bb58 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
@@ -3,29 +3,29 @@ package dev.jdtech.jellyfin
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
-import com.google.android.material.bottomnavigation.BottomNavigationView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
+import com.google.android.material.bottomnavigation.BottomNavigationView
import dagger.hilt.android.AndroidEntryPoint
-import dev.jdtech.jellyfin.databinding.ActivityMainBinding
+import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
import dev.jdtech.jellyfin.fragments.HomeFragmentDirections
import dev.jdtech.jellyfin.viewmodels.MainViewModel
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
- private lateinit var binding: ActivityMainBinding
+ private lateinit var binding: ActivityMainAppBinding
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
- binding = ActivityMainBinding.inflate(layoutInflater)
+ binding = ActivityMainAppBinding.inflate(layoutInflater)
setContentView(binding.root)
diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt
new file mode 100644
index 00000000..ef50f30a
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt
@@ -0,0 +1,34 @@
+package dev.jdtech.jellyfin
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.fragment.app.FragmentActivity
+import androidx.navigation.fragment.NavHostFragment
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.databinding.ActivityMainTvBinding
+import dev.jdtech.jellyfin.tv.ui.HomeFragmentDirections
+import dev.jdtech.jellyfin.viewmodels.MainViewModel
+
+@AndroidEntryPoint
+internal class MainActivityTv : FragmentActivity() {
+
+ private lateinit var binding: ActivityMainTvBinding
+ private val viewModel: MainViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMainTvBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.tv_nav_host) as NavHostFragment
+ val navController = navHostFragment.navController
+
+ viewModel.navigateToAddServer.observe(this, {
+ if (it) {
+ navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
+ viewModel.doneNavigateToAddServer()
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
index 48692319..918414c9 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
@@ -1,18 +1,15 @@
package dev.jdtech.jellyfin
import android.os.Build
-import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
import android.widget.TextView
import androidx.activity.viewModels
-import androidx.core.view.updatePadding
import androidx.navigation.navArgs
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.SimpleExoPlayer
-import com.google.android.exoplayer2.trackselection.MappingTrackSelector
import com.google.android.exoplayer2.ui.TrackSelectionDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
@@ -27,16 +24,17 @@ import timber.log.Timber
import kotlin.math.max
@AndroidEntryPoint
-class PlayerActivity : AppCompatActivity() {
+class PlayerActivity : BasePlayerActivity() {
private lateinit var binding: ActivityPlayerBinding
- private val viewModel: PlayerActivityViewModel by viewModels()
+ override val viewModel: PlayerActivityViewModel by viewModels()
private val args: PlayerActivityArgs by navArgs()
private val audioController by lazy { AudioController(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("Creating player activity")
+
binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -46,19 +44,7 @@ class PlayerActivity : AppCompatActivity() {
val playerControls = binding.playerView.findViewById(R.id.player_controls)
setupVolumeControl()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- binding.playerView.findViewById(R.id.player_controls)
- .setOnApplyWindowInsetsListener { _, windowInsets ->
- val cutout = windowInsets.displayCutout
- playerControls.updatePadding(
- left = cutout?.safeInsetLeft ?: 0,
- top = cutout?.safeInsetTop ?: 0,
- right = cutout?.safeInsetRight ?: 0,
- bottom = cutout?.safeInsetBottom ?: 0,
- )
- return@setOnApplyWindowInsetsListener windowInsets
- }
- }
+ configureInsets(playerControls)
binding.playerView.findViewById(R.id.back_button).setOnClickListener {
onBackPressed()
@@ -173,18 +159,6 @@ class PlayerActivity : AppCompatActivity() {
hideSystemUI()
}
- override fun onPause() {
- super.onPause()
- viewModel.playWhenReady = viewModel.player.playWhenReady == true
- viewModel.player.playWhenReady = false
- }
-
- override fun onResume() {
- super.onResume()
- viewModel.player.playWhenReady = viewModel.playWhenReady
- hideSystemUI()
- }
-
private fun setupVolumeControl() {
val height = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
windowManager.currentWindowMetrics.bounds.height()
@@ -198,32 +172,5 @@ class PlayerActivity : AppCompatActivity() {
threshold = max(height / 8, 100)
))
}
-
- @Suppress("DEPRECATION")
- private fun hideSystemUI() {
- // These methods are deprecated but we still use them because the new WindowInsetsControllerCompat has a bug which makes the action bar reappear
- window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
- View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
- View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
-
- window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- window.attributes.layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
- }
- }
-
- private fun isRendererType(
- mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
- rendererIndex: Int,
- type: Int
- ): Boolean {
- val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
- if (trackGroupArray.length == 0) {
- return false
- }
- val trackType = mappedTrackInfo.getRendererType(rendererIndex)
- return type == trackType
- }
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
index 6cccf5b4..11377734 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
@@ -4,11 +4,10 @@ import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.lang.IllegalStateException
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
-import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
-import java.lang.IllegalStateException
class VideoVersionDialogFragment(
private val item: BaseItemDto,
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt
index 10fbcddc..12dd430d 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt
@@ -1,13 +1,14 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
-import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.FragmentAddServerBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
@@ -32,7 +33,7 @@ class AddServerFragment : Fragment() {
viewModel.checkServer(serverAddress)
binding.progressCircular.visibility = View.VISIBLE
} else {
- binding.editTextServerAddressLayout.error = "Empty server address"
+ binding.editTextServerAddressLayout.error = resources.getString(R.string.add_server_empty_error)
}
}
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 2752ef5b..171799ca 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
@@ -1,7 +1,12 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
-import android.view.*
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
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 f6505c49..2e48399e 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
@@ -26,8 +26,8 @@ import dev.jdtech.jellyfin.utils.requestDownload
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
-import timber.log.Timber
import org.jellyfin.sdk.model.serializer.toUUID
+import timber.log.Timber
import java.util.UUID
@AndroidEntryPoint
@@ -263,7 +263,7 @@ class MediaInfoFragment : Fragment() {
) {
findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
- playerItems,
+ playerItems
)
)
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/TvPlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/TvPlayerActivity.kt
new file mode 100644
index 00000000..5af58b91
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/TvPlayerActivity.kt
@@ -0,0 +1,155 @@
+package dev.jdtech.jellyfin.tv
+
+import android.os.Bundle
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.WindowManager
+import android.widget.ImageButton
+import android.widget.PopupWindow
+import android.widget.TextView
+import androidx.activity.viewModels
+import androidx.navigation.navArgs
+import androidx.recyclerview.widget.RecyclerView
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.BasePlayerActivity
+import dev.jdtech.jellyfin.PlayerActivityArgs
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.databinding.ActivityPlayerTvBinding
+import dev.jdtech.jellyfin.mpv.MPVPlayer
+import dev.jdtech.jellyfin.mpv.TrackType.AUDIO
+import dev.jdtech.jellyfin.mpv.TrackType.SUBTITLE
+import dev.jdtech.jellyfin.tv.ui.TrackSelectorAdapter
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+import timber.log.Timber
+
+@AndroidEntryPoint
+internal class TvPlayerActivity : BasePlayerActivity() {
+
+ private lateinit var binding: ActivityPlayerTvBinding
+ override val viewModel: PlayerActivityViewModel by viewModels()
+ private val args: PlayerActivityArgs by navArgs()
+ private var displayedPopup: PopupWindow? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Timber.d("Player activity created.")
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityPlayerTvBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ binding.playerView.player = viewModel.player
+ val playerControls = binding.playerView.findViewById(R.id.tv_player_controls)
+ configureInsets(playerControls)
+
+ bind()
+ viewModel.initializePlayer(args.items)
+ hideSystemUI()
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ return if (!binding.playerView.isControllerVisible) {
+ binding.playerView.showController()
+ true
+ } else {
+ false
+ }
+ }
+
+ private fun bind() = with(binding.playerView) {
+ val videoNameTextView = findViewById(R.id.video_name)
+ viewModel.currentItemTitle.observe(this@TvPlayerActivity, { title ->
+ videoNameTextView.text = title
+ })
+
+ findViewById(R.id.exo_play_pause).apply {
+ setOnClickListener {
+ when {
+ viewModel.player.isPlaying -> {
+ viewModel.player.pause()
+ setImageDrawable(resources.getDrawable(R.drawable.ic_play))
+ }
+ viewModel.player.isLoading -> Unit
+ else -> {
+ viewModel.player.play()
+ setImageDrawable(resources.getDrawable(R.drawable.ic_pause))
+ }
+ }
+ }
+ }
+
+ findViewById(R.id.back_button).setOnClickListener {
+ onBackPressed()
+ }
+
+ bindAudioControl()
+ bindSubtitleControl()
+ }
+
+ private fun bindAudioControl() {
+ val audioBtn = binding.playerView.findViewById(R.id.btn_audio_track)
+
+ audioBtn.setOnFocusChangeListener { v, hasFocus ->
+ displayedPopup = if (hasFocus) {
+ val items = viewModel.currentSubtitleTracks.toUiTrack()
+ audioBtn.showPopupWindowAbove(items, AUDIO)
+ } else {
+ displayedPopup?.dismiss()
+ null
+ }
+ }
+ }
+
+ private fun bindSubtitleControl() {
+ val subtitleBtn = binding.playerView.findViewById(R.id.btn_subtitle)
+
+ subtitleBtn.setOnFocusChangeListener { v, hasFocus ->
+ v.isFocusable = true
+ displayedPopup = if (hasFocus) {
+ val items = viewModel.currentSubtitleTracks.toUiTrack()
+ subtitleBtn.showPopupWindowAbove(items, SUBTITLE)
+ } else {
+ displayedPopup?.dismiss()
+ null
+ }
+ }
+ }
+
+ private fun List.toUiTrack() = map { track ->
+ TrackSelectorAdapter.Track(
+ title = track.title,
+ language = track.lang,
+ codec = track.codec,
+ selected = track.selected,
+ playerTrack = track
+ )
+ }
+
+ private fun View.showPopupWindowAbove(
+ items: List,
+ type: String
+ ): PopupWindow {
+ val popup = PopupWindow(this.context)
+ popup.contentView = LayoutInflater.from(context).inflate(R.layout.track_selector, null)
+ val recyclerView = popup.contentView.findViewById(R.id.track_selector)
+
+ recyclerView.adapter = TrackSelectorAdapter(items, viewModel, type) { popup.dismiss() }
+
+ val startViewCoords = IntArray(2)
+ getLocationInWindow(startViewCoords)
+
+ val itemHeight = resources.getDimension(R.dimen.track_selection_item_height).toInt()
+ val totalHeight = items.size * itemHeight
+
+ popup.showAsDropDown(
+ binding.root,
+ startViewCoords.first(),
+ startViewCoords.last() - totalHeight,
+ Gravity.TOP
+ )
+
+ return popup
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt
new file mode 100644
index 00000000..4a27456d
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt
@@ -0,0 +1,102 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.os.Bundle
+import android.view.KeyEvent.KEYCODE_DPAD_DOWN
+import android.view.KeyEvent.KEYCODE_DPAD_DOWN_LEFT
+import android.view.View
+import android.widget.ImageButton
+import androidx.fragment.app.viewModels
+import androidx.leanback.app.BrowseSupportFragment
+import androidx.leanback.widget.ArrayObjectAdapter
+import androidx.leanback.widget.HeaderItem
+import androidx.leanback.widget.ListRow
+import androidx.leanback.widget.ListRowPresenter
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.adapters.HomeItem
+import dev.jdtech.jellyfin.viewmodels.HomeViewModel
+import org.jellyfin.sdk.model.api.BaseItemDto
+
+@AndroidEntryPoint
+internal class HomeFragment : BrowseSupportFragment() {
+
+ private val viewModel: HomeViewModel by viewModels()
+
+ private lateinit var rowsAdapter: ArrayObjectAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ headersState = HEADERS_ENABLED
+ rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
+ adapter = rowsAdapter
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ view.findViewById(R.id.settings).apply {
+ setOnKeyListener { _, keyCode, _ ->
+ if (keyCode == KEYCODE_DPAD_DOWN || keyCode == KEYCODE_DPAD_DOWN_LEFT) {
+ headersSupportFragment.view?.requestFocus()
+ true
+ } else {
+ false
+ }
+ }
+ setOnClickListener { navigateToSettingsFragment() }
+ }
+
+ viewModel.views.observe(viewLifecycleOwner) { homeItems ->
+ rowsAdapter.clear()
+ homeItems.map { section -> rowsAdapter.add(section.toListRow()) }
+ }
+ }
+
+ private fun HomeItem.toListRow(): ListRow {
+ return ListRow(
+ toHeader(),
+ toItems()
+ )
+ }
+
+ private fun HomeItem.toHeader(): HeaderItem {
+ return when (this) {
+ is HomeItem.Section -> HeaderItem(homeSection.name)
+ is HomeItem.ViewItem -> HeaderItem(
+ String.format(
+ resources.getString(R.string.latest_library),
+ view.name
+ )
+ )
+ }
+ }
+
+ private fun HomeItem.toItems(): ArrayObjectAdapter {
+ return when (this) {
+ is HomeItem.Section -> ArrayObjectAdapter(DynamicMediaItemPresenter { item ->
+ navigateToMediaDetailFragment(item)
+ }).apply { addAll(0, homeSection.items) }
+ is HomeItem.ViewItem -> ArrayObjectAdapter(MediaItemPresenter { item ->
+ navigateToMediaDetailFragment(item)
+ }).apply { addAll(0, view.items) }
+ }
+ }
+
+ private fun navigateToMediaDetailFragment(item: BaseItemDto) {
+ findNavController().navigate(
+ HomeFragmentDirections.actionHomeFragmentToMediaDetailFragment(
+ item.id,
+ item.seriesName ?: item.name,
+ item.type ?: "Unknown"
+ )
+ )
+ }
+
+ private fun navigateToSettingsFragment() {
+ findNavController().navigate(
+ HomeFragmentDirections.actionNavigationHomeToSettings()
+ )
+ }
+}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt
new file mode 100644
index 00000000..f1f01379
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt
@@ -0,0 +1,197 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.adapters.PersonListAdapter
+import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
+import dev.jdtech.jellyfin.databinding.MediaDetailFragmentBinding
+import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
+import dev.jdtech.jellyfin.models.PlayerItem
+import dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State.Movie
+import dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State.TvShow
+import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItemError
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItems
+import timber.log.Timber
+
+@AndroidEntryPoint
+internal class MediaDetailFragment : Fragment() {
+
+ private lateinit var binding: MediaDetailFragmentBinding
+
+ private val viewModel: MediaInfoViewModel by viewModels()
+ private val detailViewModel: MediaDetailViewModel by viewModels()
+ private val playerViewModel: PlayerViewModel by viewModels()
+
+ private val args: MediaDetailFragmentArgs by navArgs()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ viewModel.loadData(args.itemId, args.itemType)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = MediaDetailFragmentBinding.inflate(inflater)
+ binding.lifecycleOwner = viewLifecycleOwner
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.viewModel = viewModel
+ binding.item = detailViewModel.transformData(viewModel.item, resources) {
+ bindActions(it)
+ bindState(it)
+ }
+
+ val seasonsAdapter = ViewItemListAdapter(
+ fixedWidth = true,
+ onClickListener = ViewItemListAdapter.OnClickListener {})
+
+ viewModel.seasons.observe(viewLifecycleOwner) {
+ seasonsAdapter.submitList(it)
+ binding.seasonTitle.isVisible = true
+ }
+
+ binding.seasonsRow.gridView.adapter = seasonsAdapter
+ binding.seasonsRow.gridView.verticalSpacing = 25
+
+ val castAdapter = PersonListAdapter { person ->
+ Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show()
+ }
+
+ viewModel.actors.observe(viewLifecycleOwner) { cast ->
+ castAdapter.submitList(cast)
+ binding.castTitle.isVisible = cast.isNotEmpty()
+ }
+
+ binding.castRow.gridView.adapter = castAdapter
+ binding.castRow.gridView.verticalSpacing = 25
+ }
+
+ private fun bindState(state: MediaDetailViewModel.State) {
+ playerViewModel.onPlaybackRequested(lifecycleScope) { state ->
+ when (state) {
+ is PlayerItemError -> bindPlayerItemsError(state)
+ is PlayerItems -> bindPlayerItems(state)
+ }
+ }
+
+ when (state.media) {
+ is Movie -> binding.title.text = state.media.title
+ is TvShow -> with(binding.subtitle) {
+ binding.title.text = state.media.episode
+ text = state.media.show
+ isVisible = true
+ }
+ }
+ }
+
+ private fun bindPlayerItems(items: PlayerItems) {
+ navigateToPlayerActivity(items.items.toTypedArray())
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ }
+
+ private fun bindPlayerItemsError(error: PlayerItemError) {
+ Timber.e(error.message)
+
+ binding.errorLayout.errorPanel.isVisible = true
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ }
+
+ private fun bindActions(state: MediaDetailViewModel.State) {
+ binding.playButton.setOnClickListener {
+ binding.progressCircular.isVisible = true
+ viewModel.item.value?.let { item ->
+ playerViewModel.loadPlayerItems(item) {
+ VideoVersionDialogFragment(item, playerViewModel).show(
+ parentFragmentManager,
+ "videoversiondialog"
+ )
+ }
+ }
+ }
+
+ if (state.trailerUrl != null) {
+ with(binding.trailerButton) {
+ isVisible = true
+ setOnClickListener { playTrailer(state.trailerUrl) }
+ }
+ } else {
+ binding.trailerButton.isVisible = false
+ }
+
+ if (state.isPlayed) {
+ with(binding.checkButton) {
+ setImageDrawable(resources.getDrawable(R.drawable.ic_check_filled))
+ setOnClickListener { viewModel.markAsUnplayed(args.itemId) }
+ }
+ } else {
+ with(binding.checkButton) {
+ setImageDrawable(resources.getDrawable(R.drawable.ic_check))
+ setOnClickListener { viewModel.markAsPlayed(args.itemId) }
+ }
+ }
+
+ if (state.isFavorite) {
+ with(binding.favoriteButton) {
+ setImageDrawable(resources.getDrawable(R.drawable.ic_heart_filled))
+ setOnClickListener { viewModel.unmarkAsFavorite(args.itemId) }
+ }
+ } else {
+ with(binding.favoriteButton) {
+ setImageDrawable(resources.getDrawable(R.drawable.ic_heart))
+ setOnClickListener { viewModel.markAsFavorite(args.itemId) }
+ }
+ }
+
+ binding.backButton.setOnClickListener { activity?.onBackPressed() }
+ }
+
+ private fun playTrailer(url: String) {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }
+
+ private fun navigateToPlayerActivity(
+ playerItems: Array,
+ ) {
+ findNavController().navigate(
+ MediaDetailFragmentDirections.actionMediaDetailFragmentToPlayerActivity(
+ playerItems
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt
new file mode 100644
index 00000000..d4d46a70
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt
@@ -0,0 +1,69 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.content.res.Resources
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.models.ContentType.MOVIE
+import org.jellyfin.sdk.model.api.BaseItemDto
+import javax.inject.Inject
+
+@HiltViewModel
+internal class MediaDetailViewModel @Inject internal constructor() : ViewModel() {
+
+ fun transformData(
+ data: LiveData,
+ resources: Resources,
+ transformed: (State) -> Unit
+ ): LiveData {
+ return Transformations.map(data) { baseItemDto ->
+ State(
+ dto = baseItemDto,
+ description = baseItemDto.overview.orEmpty(),
+ year = baseItemDto.productionYear.toString(),
+ officialRating = baseItemDto.officialRating.orEmpty(),
+ communityRating = baseItemDto.communityRating.toString(),
+ runtimeMinutes = String.format(
+ resources.getString(R.string.runtime_minutes),
+ baseItemDto.runTimeTicks?.div(600_000_000)
+ ),
+ genres = baseItemDto.genres?.joinToString(" / ").orEmpty(),
+ trailerUrl = baseItemDto.remoteTrailers?.firstOrNull()?.url,
+ isPlayed = baseItemDto.userData?.played == true,
+ isFavorite = baseItemDto.userData?.isFavorite == true,
+ media = if (baseItemDto.type == MOVIE.type) {
+ State.Movie(
+ title = baseItemDto.name.orEmpty()
+ )
+ } else {
+ State.TvShow(
+ episode = baseItemDto.episodeTitle ?: baseItemDto.name.orEmpty(),
+ show = baseItemDto.seriesName.orEmpty()
+ )
+ }
+ ).also(transformed)
+ }
+ }
+
+ data class State(
+ val dto: BaseItemDto,
+ val description: String,
+ val year: String,
+ val officialRating: String,
+ val communityRating: String,
+ val runtimeMinutes: String,
+ val genres: String,
+ val trailerUrl: String?,
+ val isPlayed: Boolean,
+ val isFavorite: Boolean,
+ val media: Media
+ ) {
+
+ sealed class Media
+
+ data class Movie(val title: String): Media()
+ data class TvShow(val episode: String, val show: String): Media()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaSectionPresenter.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaSectionPresenter.kt
new file mode 100644
index 00000000..f70a0524
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaSectionPresenter.kt
@@ -0,0 +1,78 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.content.Context.LAYOUT_INFLATER_SERVICE
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.databinding.DataBindingUtil
+import androidx.leanback.widget.Presenter
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.databinding.BaseItemBinding
+import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
+import org.jellyfin.sdk.model.api.BaseItemDto
+
+class MediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter() {
+
+ override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
+ val mediaView =
+ BaseItemBinding
+ .inflate(parent.context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater)
+ .root
+ return ViewHolder(mediaView)
+ }
+
+ override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
+ if (item is BaseItemDto) {
+ DataBindingUtil.getBinding(viewHolder.view)?.apply {
+ this.item = item
+ this.itemName.text = if (item.type == "Episode") item.seriesName else item.name
+ this.itemCount.visibility =
+ if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE
+ this.itemLayout.layoutParams.width =
+ this.itemLayout.resources.getDimension(R.dimen.overview_media_width).toInt()
+ (this.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
+ viewHolder.view.setOnClickListener { onClick(item) }
+ }
+ }
+ }
+
+ override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
+}
+
+class DynamicMediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter() {
+
+ override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
+ val mediaView =
+ HomeEpisodeItemBinding
+ .inflate(parent.context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater)
+ .root
+ return ViewHolder(mediaView)
+ }
+
+ override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
+ if (item is BaseItemDto) {
+ DataBindingUtil.getBinding(viewHolder.view)?.apply {
+ episode = item
+ item.userData?.playedPercentage?.toInt()?.let {
+ progressBar.layoutParams.width = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ (it.times(2.24)).toFloat(), progressBar.context.resources.displayMetrics).toInt()
+ progressBar.isVisible = true
+ }
+
+ if (item.type == "Movie") {
+ primaryName.text = item.name
+ secondaryName.visibility = View.GONE
+ } else if (item.type == "Episode") {
+ primaryName.text = item.seriesName
+ }
+
+ viewHolder.view.setOnClickListener { onClick(item) }
+ }
+ }
+ }
+
+ override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/TrackSelectorAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/TrackSelectorAdapter.kt
new file mode 100644
index 00000000..600c93ec
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/TrackSelectorAdapter.kt
@@ -0,0 +1,71 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.recyclerview.widget.RecyclerView
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.mpv.MPVPlayer
+import dev.jdtech.jellyfin.mpv.TrackType
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+
+class TrackSelectorAdapter(
+ private val items: List