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, + private val viewModel: PlayerActivityViewModel, + private val trackType: String, + private val dismissWindow: () -> Unit +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackSelectorViewHolder { + return TrackSelectorViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.track_item, parent, false) + ) + } + + override fun onBindViewHolder(holder: TrackSelectorViewHolder, position: Int) { + holder.bind(items[position], viewModel, trackType, dismissWindow) + } + + override fun getItemCount(): Int = items.size + + class TrackSelectorViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + + fun bind( + item: Track, + viewModel: PlayerActivityViewModel, + trackType: String, + dismissWindow: () -> Unit + ) { + view.findViewById