Bring Android TV back (#141)

* Merge MainActivity and MainActivityTv

* Merge AddServerFragment and TvAddServerFragment

* Merge LoginFragment and TvLoginFragment

* Add new focus effect

* Add libraries to tv home

* Fix home empty when navigating back on mobile

* Add loading indicator to home fragment

* Add empty LibraryFragment

* Add focus outline to settings button

* Use DiffCallback for updating home fragment

* Visually upgrade MediaDetailFragment

* Make all home items focusable in touch mode

* Add new focus border to person item

* Add LibraryFragment layout for TV

(Whilst also making a clusterfuck of the navigation)

* Add missing try-catch in HomeViewModel

* Don't show CancellationException on AddServerFragment

* Fix a few crashes plus errors
This commit is contained in:
Jarne Demeulemeester 2022-08-20 14:41:38 +02:00 committed by GitHub
parent bde1e44174
commit 3b7473b7a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1060 additions and 771 deletions

View file

@ -36,19 +36,6 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivityTv"
android:exported="true"
android:theme="@style/Theme.Jellyfin.Tv"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>

View file

@ -1,69 +1,86 @@
package dev.jdtech.jellyfin
import android.app.UiModeManager
import android.content.res.Configuration
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI
import androidx.navigation.ui.NavigationUiSaveStateControl
import androidx.navigation.ui.setupActionBarWithNavController
import com.google.android.material.navigation.NavigationBarView
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
import dev.jdtech.jellyfin.databinding.ActivityMainBinding
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainAppBinding
private lateinit var binding: ActivityMainBinding
private lateinit var uiModeManager: UiModeManager
@Inject
lateinit var database: ServerDatabaseDao
@OptIn(NavigationUiSaveStateControl::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainAppBinding.inflate(layoutInflater)
binding = ActivityMainBinding.inflate(layoutInflater)
uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager
setContentView(binding.root)
val navView: NavigationBarView = binding.navView as NavigationBarView
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_main) as NavHostFragment
setSupportActionBar(binding.mainToolbar)
val navController = navHostFragment.navController
val inflater = navController.navInflater
val graph = inflater.inflate(R.navigation.app_navigation)
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
graph.setStartDestination(R.id.homeFragmentTv)
}
val nServers = database.getServersCount()
if (nServers < 1) {
val inflater = navController.navInflater
val graph = inflater.inflate(R.navigation.app_navigation)
graph.setStartDestination(R.id.addServerFragment)
navController.setGraph(graph, intent.extras)
}
// Passing each menu ID as a set of Ids because each
// menu should be considered as top level destinations.
val appBarConfiguration = AppBarConfiguration(
setOf(
R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment, R.id.downloadFragment
navController.setGraph(graph, intent.extras)
if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_TELEVISION) {
val navView: NavigationBarView = binding.navView as NavigationBarView
setSupportActionBar(binding.mainToolbar)
// Passing each menu ID as a set of Ids because each
// menu should be considered as top level destinations.
val appBarConfiguration = AppBarConfiguration(
setOf(
R.id.homeFragment,
R.id.mediaFragment,
R.id.favoriteFragment,
R.id.downloadFragment
)
)
)
setupActionBarWithNavController(navController, appBarConfiguration)
// navView.setupWithNavController(navController)
// Don't save the state of other main navigation items, only this experimental function allows turning off this behavior
NavigationUI.setupWithNavController(navView, navController, false)
setupActionBarWithNavController(navController, appBarConfiguration)
// navView.setupWithNavController(navController)
// Don't save the state of other main navigation items, only this experimental function allows turning off this behavior
NavigationUI.setupWithNavController(navView, navController, false)
navController.addOnDestinationChangedListener { _, destination, _ ->
binding.navView.visibility = when (destination.id) {
R.id.twoPaneSettingsFragment, R.id.serverSelectFragment, R.id.addServerFragment, R.id.loginFragment, R.id.about_libraries_dest -> View.GONE
else -> View.VISIBLE
navController.addOnDestinationChangedListener { _, destination, _ ->
binding.navView!!.visibility = when (destination.id) {
R.id.twoPaneSettingsFragment, R.id.serverSelectFragment, R.id.addServerFragment, R.id.loginFragment, R.id.about_libraries_dest -> View.GONE
else -> View.VISIBLE
}
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar?.title =
getString(R.string.app_info)
}
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar.title = getString(R.string.app_info)
}
loadDownloadLocation(applicationContext)

View file

@ -1,37 +0,0 @@
package dev.jdtech.jellyfin
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import androidx.navigation.fragment.NavHostFragment
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.databinding.ActivityMainTvBinding
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import javax.inject.Inject
@AndroidEntryPoint
internal class MainActivityTv : FragmentActivity() {
private lateinit var binding: ActivityMainTvBinding
@Inject
lateinit var database: ServerDatabaseDao
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
val nServers = database.getServersCount()
if (nServers < 1) {
val inflater = navController.navInflater
val graph = inflater.inflate(R.navigation.tv_navigation)
graph.setStartDestination(R.id.addServerTvFragment)
navController.setGraph(graph, intent.extras)
}
loadDownloadLocation(applicationContext)
}
}

View file

@ -94,6 +94,7 @@ class ViewListAdapter(
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is HomeItem.Libraries -> -1
is HomeItem.Section -> ITEM_VIEW_TYPE_NEXT_UP
is HomeItem.ViewItem -> ITEM_VIEW_TYPE_VIEW
}
@ -105,6 +106,10 @@ class ViewListAdapter(
}
sealed class HomeItem {
data class Libraries(val section: HomeSection) : HomeItem() {
override val id = section.id
}
data class Section(val homeSection: HomeSection) : HomeItem() {
override val id = homeSection.id
}

View file

@ -1,10 +1,14 @@
package dev.jdtech.jellyfin.fragments
import android.app.UiModeManager
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@ -22,6 +26,7 @@ import timber.log.Timber
class AddServerFragment : Fragment() {
private lateinit var binding: FragmentAddServerBinding
private lateinit var uiModeManager: UiModeManager
private val viewModel: AddServerViewModel by viewModels()
override fun onCreateView(
@ -29,8 +34,10 @@ class AddServerFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = FragmentAddServerBinding.inflate(inflater)
uiModeManager =
requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager
binding.editTextServerAddress.setOnEditorActionListener { _, actionId, _ ->
(binding.editTextServerAddress as AppCompatEditText).setOnEditorActionListener { _, actionId, _ ->
return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> {
connectToServer()
@ -56,6 +63,7 @@ class AddServerFragment : Fragment() {
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navigateToLogin.collect {
@ -77,17 +85,25 @@ class AddServerFragment : Fragment() {
private fun bindUiStateError(uiState: AddServerViewModel.UiState.Error) {
binding.buttonConnect.isEnabled = true
binding.progressCircular.isVisible = false
binding.editTextServerAddressLayout.error = uiState.message
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
(binding.editTextServerAddress as AppCompatEditText).error = uiState.message
} else {
binding.editTextServerAddressLayout!!.error = uiState.message
}
}
private fun bindUiStateLoading() {
binding.buttonConnect.isEnabled = false
binding.progressCircular.isVisible = true
binding.editTextServerAddressLayout.error = null
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
(binding.editTextServerAddress as AppCompatEditText).error = null
} else {
binding.editTextServerAddressLayout!!.error = null
}
}
private fun connectToServer() {
val serverAddress = binding.editTextServerAddress.text.toString()
val serverAddress = (binding.editTextServerAddress as AppCompatEditText).text.toString()
viewModel.checkServer(serverAddress.removeSuffix("/"))
}

View file

@ -77,12 +77,12 @@ class HomeFragment : Fragment() {
override fun onResume() {
super.onResume()
viewModel.refreshData()
viewModel.loadData()
}
private fun setupView() {
binding.refreshLayout.setOnRefreshListener {
viewModel.refreshData()
viewModel.loadData()
}
binding.viewsRecyclerView.adapter = ViewListAdapter(
@ -100,7 +100,7 @@ class HomeFragment : Fragment() {
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.refreshData()
viewModel.loadData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
@ -187,7 +187,7 @@ class HomeFragment : Fragment() {
private fun navigateToSettingsFragment() {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToNavigationSettings()
HomeFragmentDirections.actionHomeFragmentToSettingsFragment()
)
}
}

View file

@ -1,8 +1,11 @@
package dev.jdtech.jellyfin.fragments
import android.app.UiModeManager
import android.content.SharedPreferences
import android.content.res.Configuration
import android.os.Bundle
import android.view.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
@ -14,6 +17,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.paging.LoadState
import androidx.recyclerview.widget.LinearSnapHelper
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
@ -33,6 +37,7 @@ import javax.inject.Inject
class LibraryFragment : Fragment() {
private lateinit var binding: FragmentLibraryBinding
private lateinit var uiModeManager: UiModeManager
private val viewModel: LibraryViewModel by viewModels()
private val args: LibraryFragmentArgs by navArgs()
@ -46,6 +51,8 @@ class LibraryFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = FragmentLibraryBinding.inflate(inflater, container, false)
uiModeManager =
requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager
return binding.root
}
@ -91,6 +98,8 @@ class LibraryFragment : Fragment() {
}, viewLifecycleOwner, Lifecycle.State.RESUMED
)
binding.title?.text = args.libraryName
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadItems(args.libraryId, args.libraryType)
}
@ -102,6 +111,11 @@ class LibraryFragment : Fragment() {
)
}
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
val snapHelper = LinearSnapHelper()
snapHelper.attachToRecyclerView(binding.itemsRecyclerView)
}
binding.itemsRecyclerView.adapter =
ViewItemPagingAdapter(ViewItemPagingAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
@ -182,12 +196,22 @@ class LibraryFragment : Fragment() {
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate(
LibraryFragmentDirections.actionLibraryFragmentToMediaInfoFragment(
item.id,
item.name,
item.type
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
findNavController().navigate(
LibraryFragmentDirections.actionLibraryFragmentToMediaDetailFragment(
item.id,
item.name,
item.type
)
)
)
} else {
findNavController().navigate(
LibraryFragmentDirections.actionLibraryFragmentToMediaInfoFragment(
item.id,
item.name,
item.type
)
)
}
}
}

View file

@ -1,10 +1,14 @@
package dev.jdtech.jellyfin.fragments
import android.app.UiModeManager
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@ -22,6 +26,7 @@ import timber.log.Timber
class LoginFragment : Fragment() {
private lateinit var binding: FragmentLoginBinding
private lateinit var uiModeManager: UiModeManager
private val viewModel: LoginViewModel by viewModels()
override fun onCreateView(
@ -29,8 +34,10 @@ class LoginFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = FragmentLoginBinding.inflate(inflater)
uiModeManager =
requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager
binding.editTextPassword.setOnEditorActionListener { _, actionId, _ ->
(binding.editTextPassword as AppCompatEditText).setOnEditorActionListener { _, actionId, _ ->
return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> {
login()
@ -61,7 +68,7 @@ class LoginFragment : Fragment() {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navigateToMain.collect {
if (it) {
navigateToMainActivity()
navigateToHomeFragment()
}
}
}
@ -78,22 +85,34 @@ class LoginFragment : Fragment() {
private fun bindUiStateError(uiState: LoginViewModel.UiState.Error) {
binding.buttonLogin.isEnabled = true
binding.progressCircular.isVisible = false
binding.editTextUsernameLayout.error = uiState.message
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
(binding.editTextUsername as AppCompatEditText).error = uiState.message
} else {
binding.editTextUsernameLayout!!.error = uiState.message
}
}
private fun bindUiStateLoading() {
binding.buttonLogin.isEnabled = false
binding.progressCircular.isVisible = true
binding.editTextUsernameLayout.error = null
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
(binding.editTextUsername as AppCompatEditText).error = null
} else {
binding.editTextUsernameLayout!!.error = null
}
}
private fun login() {
val username = binding.editTextUsername.text.toString()
val password = binding.editTextPassword.text.toString()
val username = (binding.editTextUsername as AppCompatEditText).text.toString()
val password = (binding.editTextPassword as AppCompatEditText).text.toString()
viewModel.login(username, password)
}
private fun navigateToMainActivity() {
findNavController().navigate(LoginFragmentDirections.actionLoginFragmentToNavigationHome())
private fun navigateToHomeFragment() {
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
findNavController().navigate(LoginFragmentDirections.actionLoginFragmentToHomeFragmentTv())
} else {
findNavController().navigate(LoginFragmentDirections.actionLoginFragmentToHomeFragment())
}
}
}

View file

@ -1,16 +1,16 @@
package dev.jdtech.jellyfin.tv.ui
import android.app.UiModeManager
import android.content.res.Configuration
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.appcompat.app.AppCompatActivity
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.leanback.widget.*
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@ -18,6 +18,7 @@ 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.fragments.HomeFragmentDirections
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
@ -29,12 +30,21 @@ internal class HomeFragment : BrowseSupportFragment() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var rowsAdapter: ArrayObjectAdapter
private lateinit var uiModeManager: UiModeManager
private val adapterMap = mutableMapOf<String, ArrayObjectAdapter>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
uiModeManager =
requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager
val rowPresenter = ListRowPresenter()
rowPresenter.selectEffectEnabled = false
headersState = HEADERS_ENABLED
rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
rowsAdapter = ArrayObjectAdapter(rowPresenter)
adapter = rowsAdapter
}
@ -59,21 +69,42 @@ internal class HomeFragment : BrowseSupportFragment() {
Timber.d("$uiState")
when (uiState) {
is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is HomeViewModel.UiState.Loading -> Unit
is HomeViewModel.UiState.Loading -> bindUiStateLoading()
is HomeViewModel.UiState.Error -> Unit
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.loadData(includeLibraries = true)
}
}
}
private val diffCallbackListRow = object : DiffCallback<ListRow>() {
override fun areItemsTheSame(oldItem: ListRow, newItem: ListRow): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ListRow, newItem: ListRow): Boolean {
Timber.d((oldItem.adapter.size() == newItem.adapter.size()).toString())
return oldItem.adapter.size() == newItem.adapter.size()
}
}
private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
progressBarManager.hide()
uiState.apply {
rowsAdapter.clear()
homeItems.map { section -> rowsAdapter.add(section.toListRow()) }
rowsAdapter.setItems(homeItems.map { homeItem -> homeItem.toListRow() }, diffCallbackListRow)
}
}
private fun bindUiStateLoading() {
progressBarManager.show()
}
private fun HomeItem.toListRow(): ListRow {
return ListRow(
toHeader(),
@ -83,6 +114,7 @@ internal class HomeFragment : BrowseSupportFragment() {
private fun HomeItem.toHeader(): HeaderItem {
return when (this) {
is HomeItem.Libraries -> HeaderItem(section.name)
is HomeItem.Section -> HeaderItem(homeSection.name)
is HomeItem.ViewItem -> HeaderItem(
String.format(
@ -93,14 +125,59 @@ internal class HomeFragment : BrowseSupportFragment() {
}
}
val diffCallback = object : DiffCallback<BaseItemDto>() {
override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
return oldItem == newItem
}
}
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) }
val name = this.toHeader().name
val items = when (this) {
is HomeItem.Libraries -> section.items
is HomeItem.Section -> homeSection.items
is HomeItem.ViewItem -> view.items
}
if (name in adapterMap) {
adapterMap[name]?.setItems(items, diffCallback)
} else {
adapterMap[name] = when (this) {
is HomeItem.Libraries -> ArrayObjectAdapter(LibaryItemPresenter { item ->
navigateToLibraryFragment(item)
}).apply { setItems(items, diffCallback) }
is HomeItem.Section -> ArrayObjectAdapter(DynamicMediaItemPresenter { item ->
navigateToMediaDetailFragment(item)
}).apply { setItems(items, diffCallback) }
is HomeItem.ViewItem -> ArrayObjectAdapter(MediaItemPresenter { item ->
navigateToMediaDetailFragment(item)
}).apply { setItems(items, diffCallback) }
}
}
return adapterMap[name]!!
}
private fun navigateToLibraryFragment(library: BaseItemDto) {
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
findNavController().navigate(
dev.jdtech.jellyfin.tv.ui.HomeFragmentDirections.actionHomeFragmentToLibraryFragment(
library.id,
library.name,
library.collectionType
)
)
} else {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToLibraryFragment(
library.id,
library.name,
library.collectionType
)
)
}
}
@ -116,7 +193,7 @@ internal class HomeFragment : BrowseSupportFragment() {
private fun navigateToSettingsFragment() {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToSettings()
HomeFragmentDirections.actionHomeFragmentToSettingsFragment()
)
}
}

View file

@ -121,11 +121,9 @@ internal class MediaDetailFragment : Fragment() {
when (viewModel.played) {
true -> {
viewModel.markAsUnplayed(args.itemId)
val typedValue = TypedValue()
requireActivity().theme.resolveAttribute(R.attr.colorOnSecondaryContainer, typedValue, true)
binding.checkButton.imageTintList = ColorStateList.valueOf(
resources.getColor(
typedValue.resourceId,
R.color.white,
requireActivity().theme
)
)
@ -147,11 +145,9 @@ internal class MediaDetailFragment : Fragment() {
true -> {
viewModel.unmarkAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
val typedValue = TypedValue()
requireActivity().theme.resolveAttribute(R.attr.colorOnSecondaryContainer, typedValue, true)
binding.favoriteButton.imageTintList = ColorStateList.valueOf(
resources.getColor(
typedValue.resourceId,
R.color.white,
requireActivity().theme
)
)
@ -168,16 +164,14 @@ internal class MediaDetailFragment : Fragment() {
}
}
}
binding.backButton.setOnClickListener { activity?.onBackPressed() }
}
private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
uiState.apply {
binding.seasonTitle.isVisible = seasons.isNotEmpty()
binding.seasonsLayout.isVisible = seasons.isNotEmpty()
val seasonsAdapter = binding.seasonsRow.gridView.adapter as ViewItemListAdapter
seasonsAdapter.submitList(seasons)
binding.castTitle.isVisible = actors.isNotEmpty()
binding.castLayout.isVisible = actors.isNotEmpty()
val actorsAdapter = binding.castRow.gridView.adapter as PersonListAdapter
actorsAdapter.submitList(actors)
@ -217,11 +211,6 @@ internal class MediaDetailFragment : Fragment() {
)
binding.title.text = item.name
binding.subtitle.text = item.seriesName
item.seriesName.let {
binding.subtitle.text = it
binding.subtitle.isVisible = true
}
binding.genres.text = genresString
binding.year.text = dateString
binding.playtime.text = runTime

View file

@ -1,6 +1,5 @@
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
@ -10,25 +9,52 @@ 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.CollectionItemBinding
import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
class LibaryItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
return ViewHolder(
CollectionItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
).root
)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
if (item is BaseItemDto) {
DataBindingUtil.getBinding<CollectionItemBinding>(viewHolder.view)?.apply {
this.collection = item
viewHolder.view.setOnClickListener { onClick(item) }
}
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
}
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)
return ViewHolder(
BaseItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
).root
)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
if (item is BaseItemDto) {
DataBindingUtil.getBinding<BaseItemBinding>(viewHolder.view)?.apply {
this.item = item
this.itemName.text = if (item.type == BaseItemKind.EPISODE) item.seriesName else item.name
this.itemName.text =
if (item.type == BaseItemKind.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 =
@ -45,11 +71,13 @@ class MediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter
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)
return ViewHolder(
HomeEpisodeItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
).root
)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
@ -59,7 +87,8 @@ class DynamicMediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Pr
item.userData?.playedPercentage?.toInt()?.let {
progressBar.layoutParams.width = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
(it.times(2.24)).toFloat(), progressBar.context.resources.displayMetrics).toInt()
(it.times(2.24)).toFloat(), progressBar.context.resources.displayMetrics
).toInt()
progressBar.isVisible = true
}

View file

@ -1,99 +0,0 @@
package dev.jdtech.jellyfin.tv.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.TvAddServerFragmentBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
internal class TvAddServerFragment : Fragment() {
private lateinit var binding: TvAddServerFragmentBinding
private val viewModel: AddServerViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = TvAddServerFragmentBinding.inflate(inflater)
binding.editTextServerAddress.setOnEditorActionListener { _, actionId, _ ->
return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> {
connectToServer()
true
}
else -> false
}
}
binding.buttonConnect.setOnClickListener {
connectToServer()
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
Timber.d("$uiState")
when (uiState) {
is AddServerViewModel.UiState.Normal -> bindUiStateNormal()
is AddServerViewModel.UiState.Error -> bindUiStateError(uiState)
is AddServerViewModel.UiState.Loading -> bindUiStateLoading()
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navigateToLogin.collect {
if (it) {
navigateToLoginFragment()
}
}
}
}
return binding.root
}
private fun bindUiStateNormal() {
binding.buttonConnect.isEnabled = true
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: AddServerViewModel.UiState.Error) {
binding.buttonConnect.isEnabled = true
binding.progressCircular.isVisible = false
binding.editTextServerAddress.error = uiState.message
}
private fun bindUiStateLoading() {
binding.buttonConnect.isEnabled = false
binding.progressCircular.isVisible = true
binding.editTextServerAddress.error = null
}
private fun connectToServer() {
val serverAddress = binding.editTextServerAddress.text.toString()
viewModel.checkServer(serverAddress.removeSuffix("/"))
}
private fun navigateToLoginFragment() {
findNavController().navigate(TvAddServerFragmentDirections.actionAddServerFragmentToLoginFragment())
}
}

View file

@ -1,99 +0,0 @@
package dev.jdtech.jellyfin.tv.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.TvLoginFragmentBinding
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
class TvLoginFragment : Fragment() {
private lateinit var binding: TvLoginFragmentBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = TvLoginFragmentBinding.inflate(inflater)
binding.editTextPassword.setOnEditorActionListener { _, actionId, _ ->
return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> {
login()
true
}
else -> false
}
}
binding.buttonLogin.setOnClickListener {
login()
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
Timber.d("$uiState")
when(uiState) {
is LoginViewModel.UiState.Normal -> bindUiStateNormal()
is LoginViewModel.UiState.Error -> bindUiStateError(uiState)
is LoginViewModel.UiState.Loading -> bindUiStateLoading()
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navigateToMain.collect {
if (it) {
navigateToMainActivity()
}
}
}
}
return binding.root
}
private fun bindUiStateNormal() {
binding.buttonLogin.isEnabled = true
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: LoginViewModel.UiState.Error) {
binding.buttonLogin.isEnabled = true
binding.progressCircular.isVisible = false
binding.editTextUsername.error = uiState.message
}
private fun bindUiStateLoading() {
binding.buttonLogin.isEnabled = false
binding.progressCircular.isVisible = true
binding.editTextUsername.error = null
}
private fun login() {
val username = binding.editTextUsername.text.toString()
val password = binding.editTextPassword.text.toString()
viewModel.login(username, password)
}
private fun navigateToMainActivity() {
findNavController().navigate(TvLoginFragmentDirections.actionLoginFragmentToNavigationHome())
}
}

View file

@ -10,11 +10,8 @@ import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.discovery.RecommendedServerIssue
@ -68,7 +65,6 @@ constructor(
RecommendedServerInfoScore.OK
)
val greatServers = mutableListOf<RecommendedServerInfo>()
val goodServers = mutableListOf<RecommendedServerInfo>()
val okServers = mutableListOf<RecommendedServerInfo>()
@ -76,9 +72,6 @@ constructor(
.onCompletion {
if (serverFound) return@onCompletion
when {
greatServers.isNotEmpty() -> {
connectToServer(greatServers.first())
}
goodServers.isNotEmpty() -> {
val issuesString = createIssuesString(goodServers.first())
Toast.makeText(
@ -109,6 +102,8 @@ constructor(
RecommendedServerInfoScore.BAD -> Unit
}
}
} catch (e: CancellationException) {
} catch (e: Exception) {
_uiState.emit(
UiState.Error(

View file

@ -38,18 +38,25 @@ class HomeViewModel @Inject internal constructor(
}
init {
loadData(updateCapabilities = true)
viewModelScope.launch {
try {
repository.postCapabilities()
} catch (e: Exception) {
}
}
}
fun refreshData() = loadData(updateCapabilities = false)
private fun loadData(updateCapabilities: Boolean) {
fun loadData(includeLibraries: Boolean = false) {
viewModelScope.launch {
_uiState.emit(UiState.Loading)
try {
if (updateCapabilities) repository.postCapabilities()
val items = mutableListOf<HomeItem>()
val updated = loadDynamicItems() + loadViews()
if (includeLibraries) {
items.add(loadLibraries())
}
val updated = items + loadDynamicItems() + loadViews()
withContext(Dispatchers.Default) {
syncPlaybackProgress(downloadDatabase, repository)
@ -61,6 +68,19 @@ class HomeViewModel @Inject internal constructor(
}
}
private suspend fun loadLibraries(): HomeItem {
val items = repository.getItems()
val collections =
items.filter { collection -> CollectionType.unsupportedCollections.none { it.type == collection.collectionType } }
return HomeItem.Libraries(
HomeSection(
UUID.fromString("38f5ca96-9e4b-4c0e-a8e4-02225ed07e02"),
application.resources.getString(R.string.libraries),
collections
)
)
}
private suspend fun loadDynamicItems(): List<Section> {
val resumeItems = repository.getResumeItems()
val nextUpItems = repository.getNextUp()

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700"/>
<stroke android:width="2dp" android:color="@color/white" />
<corners android:radius="10dp" />
</shape>
</item>
<item android:state_enabled="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700"/>
<corners android:radius="10dp" />
</shape>
</item>
</selector>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="?attr/colorPrimary"/>
<stroke android:width="2dp" android:color="@color/white" />
<corners android:radius="10dp" />
</shape>
</item>
<item android:state_enabled="true">
<shape android:shape="rectangle">
<solid android:color="?attr/colorPrimary"/>
<corners android:radius="10dp" />
</shape>
</item>
</selector>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="?android:attr/windowBackground"/>
<padding android:bottom="4dp" android:left="4dp" android:right="4dp" android:top="4dp" />
<stroke android:width="2dp" android:color="@color/white" />
<corners android:bottomLeftRadius="10dp" android:bottomRightRadius="10dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" />
</shape>
</item>
<item android:state_enabled="true">
<shape android:shape="rectangle">
<solid android:color="?android:attr/windowBackground"/>
<padding android:bottom="4dp" android:left="4dp" android:right="4dp" android:top="4dp" />
<corners android:bottomLeftRadius="10dp" android:bottomRightRadius="10dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" />
</shape>
</item>
</selector>

View file

@ -2,8 +2,7 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
android:viewportHeight="24">
<path
android:pathData="M5,3l14,9l-14,9l0,-18z"
android:strokeLineJoin="round"

View file

@ -1,13 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/tv_nav_host"
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -16,7 +14,6 @@
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="parent"
app:navGraph="@navigation/tv_navigation"
/>
app:navGraph="@navigation/app_navigation" />
</FrameLayout>

View file

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="item"
type="org.jellyfin.sdk.model.api.BaseItemDto" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginBottom="24dp"
android:background="@drawable/focus_border"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/item_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:itemImage="@{item}"
app:layout_constraintDimensionRatio="H,2:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image"
app:strokeColor="@null" />
<TextView
android:id="@+id/item_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/item_image"
tools:text="Movie title" />
<TextView
android:id="@+id/item_count"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/circle_background"
android:gravity="center"
android:text="@{item.userData.unplayedItemCount.toString()}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="9" />
<ImageView
android:id="@+id/played_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/circle_background"
android:contentDescription="@string/episode_watched_indicator"
android:padding="4dp"
android:src="@drawable/ic_check"
android:visibility="@{item.userData.played == true ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toEndOf="@id/item_image"
app:layout_constraintTop_toTopOf="@id/item_image" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -1,38 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:ignore="MissingDefaultResource"
android:paddingTop="16dp"
>
tools:ignore="MissingDefaultResource">
<ImageButton
android:id="@+id/settings"
android:layout_width="wrap_content"
android:layout_height="24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@+id/clock"
android:src="@drawable/ic_settings"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="24dp"
android:background="@drawable/focus_border"
android:contentDescription="@string/title_settings"
android:background="@drawable/transparent_circle_background"
android:focusable="true"
android:focusableInTouchMode="true"
android:layout_marginEnd="24dp"
/>
android:src="@drawable/ic_settings"
app:layout_constraintEnd_toStartOf="@+id/clock"
app:layout_constraintTop_toTopOf="parent" />
<TextClock
android:id="@+id/clock"
android:layout_width="wrap_content"
android:layout_height="24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_height="32dp"
android:layout_marginEnd="24dp"
android:gravity="center_vertical"
android:textSize="18sp"
android:layout_marginEnd="24dp"
tools:text="12:00"
/>
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="12:00" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="collection"
type="org.jellyfin.sdk.model.api.BaseItemDto" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="240dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginBottom="24dp"
android:background="@drawable/focus_border"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/collection_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
app:baseItemImage="@{collection}"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image"
app:strokeColor="@null" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@{collection.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/collection_image"
tools:text="Movies" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -1,14 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".fragments.AddServerFragment"
tools:ignore="MissingDefaultResource">
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
tools:context=".fragments.AddServerFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="wrap_content">
<ImageView
android:id="@+id/image_banner"
@ -49,6 +50,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:autofillHints=""
android:focusedByDefault="true"
android:hint="@string/edit_text_server_address_hint"
android:imeOptions="actionGo"
android:inputType="textUri" />
@ -76,4 +78,4 @@
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</ScrollView>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.LibraryFragment">
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Movies" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/items_recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingTop="16dp"
android:scrollbars="none"
android:layout_marginTop="12dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
app:spanCount="@integer/library_columns"
tools:itemCount="14"
tools:listitem="@layout/base_item">
<requestFocus/>
</androidx.recyclerview.widget.RecyclerView>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,14 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingDefaultResource">
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
tools:context=".fragments.LoginFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.LoginFragment">
android:layout_height="wrap_content">
<ImageView
android:id="@+id/image_banner"
@ -82,8 +83,9 @@
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
</ScrollView>

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="episode"
type="org.jellyfin.sdk.model.api.BaseItemDto" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="240dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:background="@drawable/focus_border"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/episode_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:baseItemImage="@{episode}"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image"
app:strokeColor="@null" />
<TextView
android:id="@+id/primary_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/episode_image"
tools:text="Wonder Egg Priority" />
<TextView
android:id="@+id/secondary_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="1"
android:text="@{String.format(@string/episode_name_extended, episode.parentIndexNumber, episode.indexNumber, episode.name)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/primary_name"
tools:text="The Girl Flautist" />
<FrameLayout
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="4dp"
android:layout_marginHorizontal="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/button_setup_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/episode_image"
app:layout_constraintStart_toStartOf="parent"
tools:layout_width="50dp"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -19,27 +19,14 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/back_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_exit"
android:focusable="true"
android:padding="16dp"
android:src="@drawable/ic_arrow_left"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:paddingBottom="16dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintStart_toEndOf="@id/back_button"
android:layout_marginHorizontal="@dimen/horizontal_margin"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Alita: Battle Angel" />
@ -47,6 +34,7 @@
android:id="@+id/clock"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
android:gravity="center_vertical"
android:textSize="18sp"
@ -59,189 +47,209 @@
android:layout_width="320dp"
android:layout_height="180dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginTop="12dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
<TextView
android:id="@+id/subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="Subtitle" />
app:shapeAppearance="@style/ShapeAppearanceOverlay.Findroid.Image"
app:strokeColor="@null" />
<TextView
android:id="@+id/genres"
<LinearLayout
android:id="@+id/main_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:orientation="vertical"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/subtitle"
tools:text="Action, Science Fiction, Adventure" />
app:layout_constraintTop_toTopOf="@id/poster">
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="2019" />
<LinearLayout
android:id="@+id/info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/playtime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/year"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="122 min" />
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="2019" />
<TextView
android:id="@+id/official_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/playtime"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="PG-13" />
<TextView
android:id="@+id/playtime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="122 min" />
<TextView
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:drawablePadding="4dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow"
app:layout_constraintStart_toEndOf="@id/official_rating"
app:layout_constraintTop_toBottomOf="@id/genres"
tools:text="7.3" />
<TextView
android:id="@+id/official_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="PG-13" />
<TextView
android:id="@+id/description"
android:layout_width="400dp"
<TextView
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="4dp"
android:gravity="bottom"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow"
tools:text="7.3" />
</LinearLayout>
<TextView
android:id="@+id/genres"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="Action, Science Fiction, Adventure" />
<TextView
android:id="@+id/description"
android:layout_width="400dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="5"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
<LinearLayout
android:id="@+id/buttons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp">
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp">
<ImageButton
android:id="@+id/play_button"
android:layout_width="72dp"
android:layout_height="48dp"
android:background="@drawable/button_setup_background"
android:contentDescription="@string/play_button_description"
android:focusable="true"
android:focusableInTouchMode="true"
android:focusedByDefault="true"
android:nextFocusLeft="@id/play_button"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:src="@drawable/ic_play">
<requestFocus />
</ImageButton>
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerHorizontal="true"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
<ImageButton
android:id="@+id/trailer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/trailer_button_description"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="12dp"
android:src="@drawable/ic_film" />
<ImageButton
android:id="@+id/check_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/check_button_description"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="12dp"
android:src="@drawable/ic_check" />
<ImageButton
android:id="@+id/favorite_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/button_accent_background"
android:contentDescription="@string/favorite_button_description"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="12dp"
android:src="@drawable/ic_heart" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/seasons_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginBottom="24dp"
android:ellipsize="end"
android:maxLines="5"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/year"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/play_button"
android:layout_width="72dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:contentDescription="@string/play_button_description"
android:focusable="true"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:src="@drawable/ic_play"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/trailer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:contentDescription="@string/trailer_button_description"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_film"
app:layout_constraintStart_toEndOf="@id/play_button"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/check_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:contentDescription="@string/check_button_description"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_check"
app:layout_constraintStart_toEndOf="@id/trailer_button"
app:layout_constraintTop_toBottomOf="@id/description" />
<ImageButton
android:id="@+id/favorite_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/favorite_button_description"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_heart"
app:layout_constraintStart_toEndOf="@id/check_button"
app:layout_constraintTop_toBottomOf="@id/description" />
<TextView
android:id="@+id/season_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginTop="32dp"
android:text="@string/seasons"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
android:visibility="gone"
android:orientation="vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/play_button" />
app:layout_constraintTop_toBottomOf="@id/main_info">
<androidx.leanback.widget.ListRowView
android:id="@+id/seasons_row"
<TextView
android:id="@+id/season_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginTop="32dp"
android:text="@string/seasons"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
<androidx.leanback.widget.ListRowView
android:id="@+id/seasons_row"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:id="@+id/cast_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:orientation="vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/season_title" />
app:layout_constraintTop_toBottomOf="@id/seasons_layout">
<TextView
android:id="@+id/cast_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginTop="16dp"
android:text="@string/cast_amp_crew"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/seasons_row" />
<TextView
android:id="@+id/cast_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginTop="16dp"
android:text="@string/cast_amp_crew"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
<androidx.leanback.widget.ListRowView
android:id="@+id/cast_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cast_title" />
<androidx.leanback.widget.ListRowView
android:id="@+id/cast_row"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="person"
type="org.jellyfin.sdk.model.api.BaseItemPerson" />
</data>
<LinearLayout
android:id="@+id/item_layout"
android:layout_width="110dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:background="@drawable/focus_border"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/person_image"
android:layout_width="match_parent"
android:layout_height="160dp"
android:layout_marginBottom="4dp"
android:scaleType="centerCrop"
app:personImage="@{person}"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image"
app:strokeColor="@null" />
<TextView
android:id="@+id/person_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{person.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="Rosa Salazar" />
<TextView
android:id="@+id/person_role"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{person.role}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
tools:text="Alita" />
</LinearLayout>
</layout>

View file

@ -0,0 +1 @@
../layout-television/activity_main.xml

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.navigationrail.NavigationRailView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"
app:menu="@menu/bottom_nav_menu" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toRightOf="@id/nav_view"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"
app:navGraph="@navigation/app_navigation" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/main_toolbar_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/nav_host_fragment_activity_main"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/main_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.navigationrail.NavigationRailView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:menu="@menu/bottom_nav_menu" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toRightOf="@id/nav_view"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"
app:navGraph="@navigation/app_navigation" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/main_toolbar_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/nav_host_fragment_activity_main"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/main_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:layout_marginEnd="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/bottom_nav_menu" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/nav_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"
app:navGraph="@navigation/app_navigation" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/main_toolbar_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/nav_host_fragment_activity_main"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/main_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:layout_marginEnd="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/bottom_nav_menu" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/nav_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"
app:navGraph="@navigation/app_navigation" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/main_toolbar_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/nav_host_fragment_activity_main"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/main_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -21,7 +21,7 @@
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/episode_image"
android:layout_width="240dp"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:baseItemImage="@{episode}"
@ -36,7 +36,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:singleLine="true"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -47,7 +47,7 @@
android:id="@+id/secondary_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:maxLines="1"
android:text="@{String.format(@string/episode_name_extended, episode.parentIndexNumber, episode.indexNumber, episode.name)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toEndOf="parent"

View file

@ -22,7 +22,7 @@
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/person_image"
android:layout_width="110dp"
android:layout_width="match_parent"
android:layout_height="160dp"
android:layout_marginBottom="4dp"
android:scaleType="centerCrop"
@ -34,7 +34,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:maxLines="1"
android:text="@{person.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="Rosa Salazar" />
@ -44,7 +44,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:maxLines="1"
android:text="@{person.role}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
tools:text="Alita" />

View file

@ -28,7 +28,7 @@
android:id="@+id/action_navigation_home_to_episodeBottomSheetFragment"
app:destination="@id/episodeBottomSheetFragment" />
<action
android:id="@+id/action_navigation_home_to_navigation_settings"
android:id="@+id/action_homeFragment_to_settingsFragment"
app:destination="@id/twoPaneSettingsFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
@ -39,6 +39,37 @@
app:destination="@id/addServerFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_homeFragment_to_mediaDetailFragment"
app:destination="@id/mediaDetailFragment" />
</fragment>
<fragment
android:id="@+id/homeFragmentTv"
android:name="dev.jdtech.jellyfin.tv.ui.HomeFragment"
android:label="@string/title_home" >
<action
android:id="@+id/action_homeFragment_to_mediaDetailFragment"
app:destination="@id/mediaDetailFragment" />
<action
android:id="@+id/action_homeFragment_to_settingsFragment"
app:destination="@id/twoPaneSettingsFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_homeFragment_to_addServerFragment"
app:destination="@id/addServerFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_homeFragment_to_libraryFragment"
app:destination="@id/libraryFragment" />
</fragment>
<fragment
@ -93,6 +124,9 @@
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_libraryFragment_to_mediaDetailFragment"
app:destination="@id/mediaDetailFragment" />
<argument
android:name="libraryType"
android:defaultValue="unknown"
@ -138,6 +172,29 @@
app:argType="boolean"
android:defaultValue="false" />
</fragment>
<fragment
android:id="@+id/mediaDetailFragment"
android:name="dev.jdtech.jellyfin.tv.ui.MediaDetailFragment"
android:label="{itemName}"
tools:layout="@layout/media_detail_fragment">
<argument
android:name="itemId"
app:argType="java.util.UUID" />
<argument
android:name="itemName"
android:defaultValue="Media Info"
app:argType="string"
app:nullable="true" />
<argument
android:name="itemType"
app:argType="org.jellyfin.sdk.model.api.BaseItemKind"
android:defaultValue="MOVIE" />
<action
android:id="@+id/action_mediaDetailFragment_to_playerActivity"
app:destination="@id/playerActivityTv" />
</fragment>
<fragment
android:id="@+id/seasonFragment"
android:name="dev.jdtech.jellyfin.fragments.SeasonFragment"
@ -265,6 +322,11 @@
app:destination="@id/homeFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_serverSelectFragment_to_homeFragmentTv"
app:destination="@id/homeFragmentTv"
app:popUpTo="@id/homeFragmentTv"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/loginFragment"
@ -272,10 +334,15 @@
android:label="@string/login"
tools:layout="@layout/fragment_login">
<action
android:id="@+id/action_loginFragment_to_navigation_home"
android:id="@+id/action_loginFragment_to_homeFragment"
app:destination="@id/homeFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_loginFragment_to_homeFragmentTv"
app:destination="@id/homeFragmentTv"
app:popUpTo="@id/homeFragmentTv"
app:popUpToInclusive="true" />
</fragment>
<fragment
@ -303,6 +370,16 @@
app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />
</activity>
<activity
android:id="@+id/playerActivityTv"
android:name="dev.jdtech.jellyfin.tv.TvPlayerActivity"
android:label="activity_player_tv"
tools:layout="@layout/activity_player_tv">
<argument
android:name="items"
app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />
</activity>
<include app:graph="@navigation/aboutlibs_navigation" />
<action
android:id="@+id/action_global_loginFragment"

View file

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/tv_navigation"
app:startDestination="@+id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="dev.jdtech.jellyfin.tv.ui.HomeFragment"
android:label="@string/title_home" >
<action
android:id="@+id/action_homeFragment_to_mediaDetailFragment"
app:destination="@id/mediaDetailFragment" />
<action
android:id="@+id/action_navigation_home_to_settings"
app:destination="@id/twoPaneSettingsFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_homeFragment_to_addServerFragment"
app:destination="@id/addServerTvFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/mediaDetailFragment"
android:name="dev.jdtech.jellyfin.tv.ui.MediaDetailFragment"
android:label="{itemName}"
tools:layout="@layout/media_detail_fragment">
<argument
android:name="itemId"
app:argType="java.util.UUID" />
<argument
android:name="itemName"
android:defaultValue="Media Info"
app:argType="string"
app:nullable="true" />
<argument
android:name="itemType"
app:argType="org.jellyfin.sdk.model.api.BaseItemKind"
android:defaultValue="MOVIE" />
<action
android:id="@+id/action_mediaDetailFragment_to_playerActivity"
app:destination="@id/playerActivityTv" />
</fragment>
<fragment
android:id="@+id/addServerTvFragment"
android:name="dev.jdtech.jellyfin.tv.ui.TvAddServerFragment"
android:label="@string/add_server"
tools:layout="@layout/tv_add_server_fragment">
<action
android:id="@+id/action_addServerFragment_to_loginFragment"
app:destination="@id/loginFragment" />
</fragment>
<fragment
android:id="@+id/loginFragment"
android:name="dev.jdtech.jellyfin.tv.ui.TvLoginFragment"
android:label="@string/login"
tools:layout="@layout/fragment_login">
<action
android:id="@+id/action_loginFragment_to_navigation_home"
app:destination="@id/homeFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/twoPaneSettingsFragment"
android:name="dev.jdtech.jellyfin.fragments.SettingsFragment"
android:label="@string/title_settings">
</fragment>
<activity
android:id="@+id/playerActivityTv"
android:name="dev.jdtech.jellyfin.tv.TvPlayerActivity"
android:label="activity_player_tv"
tools:layout="@layout/activity_player_tv">
<argument
android:name="items"
app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />
</activity>
</navigation>

View file

@ -11,4 +11,6 @@
<dimen name="track_selection_item_height">48dp</dimen>
<dimen name="track_selection_item_text_size">16sp</dimen>
<item name="library_columns" type="integer">6</item>
</resources>

View file

@ -1,6 +1,6 @@
<resources>
<!-- Base application theme. -->
<style name="Theme.Jellyfin.Tv" parent="Theme.AppCompat.Leanback">
<style name="Theme.Findroid" parent="Theme.AppCompat.Leanback">
<item name="colorPrimary">@color/blue_600</item>
<item name="colorPrimaryVariant">@color/blue_800</item>
<item name="colorSecondary">@color/green_500</item>

View file

@ -46,6 +46,7 @@
<string name="next_up">Next Up</string>
<string name="continue_watching">Continue Watching</string>
<string name="latest_library">Latest %1$s</string>
<string name="libraries">Libraries</string>
<string name="series_poster">Series poster</string>
<string name="no_favorites">You have no favorites</string>
<string name="no_downloads">You have nothing downloaded</string>