Add basic tv support (#58)

* Add basic leanback support

* Add TV home fragment

Adds basic media browsing screen for TV. Shows Home screen media.

* Fix double emit when loading user views

* Fix bug when going back to this screen would duplicate menu items

* Add basic media detail fragment

* Add ability to navigate to detail fragment

* Fix imports and null safe calls

* Fix displaying of home item view type media files

* Playback refactor

* Add basic Tv player controls and split PlayerActivity

* Update strings

* Add progress bar to partially played items on TV home screen

* Track selection dialog PoC

* Update track selection WIP

* Show track selection of focus change

* Fix series display from home

* Minor updates

* Add back button to media detail

* Zero effort add server and login

* Fix colors

* Fix back button from home going back to init fragment

* Add settings button to home screen

* Fix crash after goig back from media detail fragment

* Show seasons and cast

* Merge branch 'develop' into add_basic_tv_support

# Conflicts:
#	app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
#	app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
#	app/src/main/res/navigation/app_navigation.xml

* Fix cast title being shown with empty cast list

* Remove useless method

* Remove unused parameter

* Fix crash due to colorOnPrimary not existing in Leanback styles

* Remove unused theme

* Fix home to addserver fragment navigation

* Reuse home item layouts

This creates some duplicate code which will probably be cleaned up later

* Ignore more MissingDefaultResource

* Add banner

Co-authored-by: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com>
This commit is contained in:
lsrom 2021-10-30 19:46:51 +02:00 committed by GitHub
parent 532e9adac1
commit 07a9e2a853
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1978 additions and 89 deletions

View file

@ -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")

View file

@ -5,28 +5,56 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:name=".BaseApplication"
android:allowBackup="true"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:banner="@mipmap/ic_banner"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Findroid"
android:usesCleartextTraffic="true">
<activity
android:name=".PlayerActivity"
android:screenOrientation="userLandscape" />
<activity
android:name=".tv.TvPlayerActivity"
android:screenOrientation="userLandscape" />
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.FindroidSplashScreen"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<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>
</activity>
</application>

View file

@ -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
}
}
}
}

View file

@ -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)

View file

@ -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()
}
})
}
}

View file

@ -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<View>(R.id.player_controls)
setupVolumeControl()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
binding.playerView.findViewById<View>(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<View>(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
}
}

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
)
)
}

View file

@ -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<View>(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<TextView>(R.id.video_name)
viewModel.currentItemTitle.observe(this@TvPlayerActivity, { title ->
videoNameTextView.text = title
})
findViewById<ImageButton>(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<View>(R.id.back_button).setOnClickListener {
onBackPressed()
}
bindAudioControl()
bindSubtitleControl()
}
private fun bindAudioControl() {
val audioBtn = binding.playerView.findViewById<ImageButton>(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<ImageButton>(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<MPVPlayer.Companion.Track>.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<TrackSelectorAdapter.Track>,
type: String
): PopupWindow {
val popup = PopupWindow(this.context)
popup.contentView = LayoutInflater.from(context).inflate(R.layout.track_selector, null)
val recyclerView = popup.contentView.findViewById<RecyclerView>(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
}
}

View file

@ -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<ImageButton>(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()
)
}
}

View file

@ -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<PlayerItem>,
) {
findNavController().navigate(
MediaDetailFragmentDirections.actionMediaDetailFragmentToPlayerActivity(
playerItems
)
)
}
}

View file

@ -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<BaseItemDto>,
resources: Resources,
transformed: (State) -> Unit
): LiveData<State> {
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()
}
}

View file

@ -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<BaseItemBinding>(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<HomeEpisodeItemBinding>(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
}

View file

@ -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<Track>,
private val viewModel: PlayerActivityViewModel,
private val trackType: String,
private val dismissWindow: () -> Unit
) : RecyclerView.Adapter<TrackSelectorAdapter.TrackSelectorViewHolder>() {
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<Button>(R.id.track_name).apply {
text = String.format(
view.resources.getString(R.string.track_selection),
item.language,
item.title,
item.codec
)
setOnClickListener {
when (trackType) {
TrackType.AUDIO -> viewModel.switchToTrack(
TrackType.AUDIO,
item.playerTrack
)
TrackType.SUBTITLE -> viewModel.switchToTrack(
TrackType.SUBTITLE,
item.playerTrack
)
}
dismissWindow()
}
}
}
}
data class Track(
val title: String,
val language: String,
val codec: String,
val selected: Boolean,
val playerTrack: MPVPlayer.Companion.Track
)
}

View file

@ -0,0 +1,58 @@
package dev.jdtech.jellyfin.tv.ui
import android.os.Bundle
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.TvAddServerFragmentBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
@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.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.buttonConnect.setOnClickListener {
val serverAddress = binding.serverAddress.text.toString()
if (serverAddress.isNotBlank()) {
viewModel.checkServer(serverAddress)
binding.progressCircular.visibility = View.VISIBLE
} else {
binding.serverAddress.error = resources.getString(R.string.add_server_empty_error)
}
}
viewModel.navigateToLogin.observe(viewLifecycleOwner, {
if (it) {
navigateToLoginFragment()
}
binding.progressCircular.visibility = View.GONE
})
viewModel.error.observe(viewLifecycleOwner, {
binding.serverAddress.error = it
})
return binding.root
}
private fun navigateToLoginFragment() {
findNavController().navigate(TvAddServerFragmentDirections.actionAddServerFragmentToLoginFragment())
viewModel.onNavigateToLoginDone()
}
}

View file

@ -0,0 +1,53 @@
package dev.jdtech.jellyfin.tv.ui
import android.os.Bundle
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.databinding.TvLoginFragmentBinding
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
@AndroidEntryPoint
class TvLoginFragment : Fragment() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = TvLoginFragmentBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.buttonLogin.setOnClickListener {
val username = binding.username.text.toString()
val password = binding.password.text.toString()
binding.progressCircular.visibility = View.VISIBLE
viewModel.login(username, password)
}
viewModel.error.observe(viewLifecycleOwner, {
binding.progressCircular.visibility = View.GONE
binding.username.error = it
})
viewModel.navigateToMain.observe(viewLifecycleOwner, {
if (it) {
navigateToMainActivity()
}
})
return binding.root
}
private fun navigateToMainActivity() {
findNavController().navigate(TvLoginFragmentDirections.actionLoginFragmentToNavigationHome())
viewModel.doneNavigatingToMain()
}
}

View file

@ -5,7 +5,7 @@ import android.widget.Toast
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import dev.jdtech.jellyfin.MainNavigationDirections
import dev.jdtech.jellyfin.AppNavigationDirections
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.models.View
import org.jellyfin.sdk.model.api.BaseItemDto
@ -28,7 +28,7 @@ fun BaseItemDto.contentType() = when (type) {
fun Fragment.checkIfLoginRequired(error: String) {
if (error.contains("401")) {
Timber.d("Login required!")
findNavController().navigate(MainNavigationDirections.actionGlobalLoginFragment())
findNavController().navigate(AppNavigationDirections.actionGlobalLoginFragment())
}
}

View file

@ -11,7 +11,9 @@ 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.flow.*
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.discovery.RecommendedServerInfo

View file

@ -53,8 +53,6 @@ constructor(
_finishedLoading.value = false
viewModelScope.launch {
try {
jellyfinRepository.postCapabilities()
val items = mutableListOf<HomeItem>()
@ -77,8 +75,6 @@ constructor(
}
}
_views.value = items
val views: MutableList<View> = mutableListOf()
withContext(Dispatchers.Default) {

View file

@ -86,7 +86,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
_dateString.value = getDateString(_item.value!!)
_played.value = _item.value?.userData?.played
_favorite.value = _item.value?.userData?.isFavorite
if (itemType == "Series") {
if (itemType == "Series" || itemType == "Episode") {
_nextUp.value = getNextUp(itemId)
_seasons.value = jellyfinRepository.getSeasons(itemId)
}

View file

@ -0,0 +1,60 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<group android:scaleX="0.6666667"
android:scaleY="0.6666667"
android:translateX="53.333332"
android:translateY="30">
<group android:scaleX="1.6666666"
android:scaleY="1.6666666"
android:translateX="-16.4">
<group android:scaleX="0.056557618"
android:scaleY="0.056557618"
android:translateX="25.0425"
android:translateY="25.0425">
<path
android:pathData="m586.76,542.3 l25.39,-43.97c3.53,-6.1 -5.62,-11.39 -9.15,-5.29l-25.71,44.52c-19.66,-8.97 -41.74,-13.97 -65.29,-13.97 -23.56,0 -45.63,5 -65.29,13.97l-25.7,-44.52c-3.52,-6.1 -12.67,-0.82 -9.15,5.28l25.39,43.98c-43.59,23.71 -73.41,67.84 -77.77,119.98L664.53,662.28C660.16,610.14 630.35,566.01 586.76,542.3m-74.71,-405.8c-99.41,0 -419.26,580 -370.53,677.95 48.74,97.96 692.8,96.83 741.05,0 48.25,-96.83 -271.12,-677.95 -370.53,-677.95zM754.92,729.57c-31.63,63.42 -453.64,64.23 -485.59,0C237.38,665.34 447.01,285.29 512.04,285.29c65.04,0 274.51,380.69 242.88,444.28z"
android:strokeWidth="0.23938">
<aapt:attr name="android:fillColor">
<gradient
android:startY="479.77658"
android:startX="363.41766"
android:endY="702.5666"
android:endX="749.3077"
android:type="linear">
<item android:offset="0" android:color="#FF3DDC84"/>
<item android:offset="1" android:color="#FF00A4DC"/>
</gradient>
</aapt:attr>
</path>
</group>
</group>
<group android:scaleX="0.29483145"
android:scaleY="0.29483145"
android:translateX="128"
android:translateY="63.46516">
<group android:translateY="144.00006">
<path android:pathData="M17.28125,0.5Q15.984375,0.5,15.1875,-0.359375Q14.40625,-1.21875,14.40625,-2.375L14.40625,-97.125Q14.40625,-98.265625,15.265625,-99.125Q16.125,-100,17.28125,-100L71.5625,-100Q72.71875,-100,73.578125,-99.125Q74.453125,-98.265625,74.453125,-97.125Q74.453125,-95.828125,73.578125,-95.03125Q72.71875,-94.25,71.5625,-94.25L19.734375,-94.25L20.15625,-94.828125L20.15625,-53.59375L19.578125,-54.734375L65.09375,-54.734375Q66.234375,-54.734375,67.09375,-53.875Q67.96875,-53.015625,67.96875,-51.875Q67.96875,-50.578125,67.09375,-49.78125Q66.234375,-49,65.09375,-49L19.296875,-49L20.15625,-50.140625L20.15625,-2.375Q20.15625,-1.21875,19.359375,-0.359375Q18.578125,0.5,17.28125,0.5Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M99.71875,-2.875Q99.71875,-1.71875,98.84375,-0.859375Q97.984375,0,96.828125,0Q95.53125,0,94.734375,-0.859375Q93.953125,-1.71875,93.953125,-2.875L93.953125,-71.125Q93.953125,-72.28125,94.8125,-73.140625Q95.6875,-74,96.828125,-74Q98.125,-74,98.921875,-73.140625Q99.71875,-72.28125,99.71875,-71.125L99.71875,-2.875ZM96.828125,-83.65625Q94.53125,-83.65625,93.15625,-84.9375Q91.796875,-86.234375,91.796875,-88.25L91.796875,-89.390625Q91.796875,-91.40625,93.234375,-92.703125Q94.671875,-94,96.96875,-94Q98.984375,-94,100.359375,-92.703125Q101.734375,-91.40625,101.734375,-89.390625L101.734375,-88.25Q101.734375,-86.234375,100.359375,-84.9375Q98.984375,-83.65625,96.828125,-83.65625Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M156.64062,-74Q165.71875,-74,171.40625,-70.40625Q177.09375,-66.8125,179.75,-60.640625Q182.42188,-54.484375,182.42188,-46.859375L182.42188,-2.921875Q182.42188,-1.765625,181.54688,-0.90625Q180.6875,-0.046875,179.53125,-0.046875Q178.23438,-0.046875,177.4375,-0.90625Q176.65625,-1.765625,176.65625,-2.921875L176.65625,-46.28125Q176.65625,-52.46875,174.5625,-57.484375Q172.48438,-62.515625,167.9375,-65.53125Q163.40625,-68.546875,156.0625,-68.546875Q149.73438,-68.546875,143.67188,-65.53125Q137.625,-62.515625,133.67188,-57.484375Q129.71875,-52.46875,129.71875,-46.28125L129.71875,-2.921875Q129.71875,-1.765625,128.84375,-0.90625Q127.984375,-0.046875,126.828125,-0.046875Q125.53125,-0.046875,124.734375,-0.90625Q123.953125,-1.765625,123.953125,-2.921875L123.953125,-68.828125Q123.953125,-69.984375,124.8125,-70.84375Q125.6875,-71.703125,126.828125,-71.703125Q128.125,-71.703125,128.92188,-70.84375Q129.71875,-69.984375,129.71875,-68.828125L129.71875,-54.765625L126.109375,-47.875Q126.109375,-53.0625,128.84375,-57.796875Q131.57812,-62.53125,136.04688,-66.1875Q140.51562,-69.84375,145.90625,-71.921875Q151.3125,-74,156.64062,-74Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M264.84375,-106Q266.14062,-106,266.9375,-105.140625Q267.73438,-104.28125,267.73438,-103.15625L267.73438,-2.84375Q267.73438,-1.6875,266.85938,-0.828125Q266,0.03125,264.84375,0.03125Q263.54688,0.03125,262.75,-0.828125Q261.96875,-1.6875,261.96875,-2.84375L261.96875,-23.265625L264.26562,-26.421875Q264.26562,-21.53125,262.03125,-16.5625Q259.8125,-11.609375,255.70312,-7.515625Q251.59375,-3.421875,246.125,-0.96875Q240.65625,1.46875,234.3125,1.46875Q224.8125,1.46875,217.25,-3.484375Q209.70312,-8.453125,205.29688,-17Q200.90625,-25.5625,200.90625,-36.34375Q200.90625,-47.109375,205.29688,-55.671875Q209.70312,-64.234375,217.25,-69.109375Q224.8125,-74,234.3125,-74Q240.21875,-74,245.625,-71.765625Q251.03125,-69.546875,255.20312,-65.515625Q259.375,-61.5,261.8125,-56.09375Q264.26562,-50.703125,264.26562,-44.390625L261.96875,-47.984375L261.96875,-103.15625Q261.96875,-104.296875,262.75,-105.140625Q263.54688,-106,264.84375,-106ZM234.60938,-4Q242.8125,-4,249.07812,-8.15625Q255.34375,-12.328125,258.9375,-19.65625Q262.54688,-27,262.54688,-36.34375Q262.54688,-45.6875,258.9375,-52.9375Q255.34375,-60.203125,249,-64.375Q242.67188,-68.546875,234.60938,-68.546875Q226.6875,-68.546875,220.34375,-64.375Q214.01562,-60.203125,210.34375,-52.9375Q206.67188,-45.6875,206.67188,-36.34375Q206.67188,-27.140625,210.34375,-19.796875Q214.01562,-12.46875,220.34375,-8.234375Q226.6875,-4,234.60938,-4Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M294.82812,0.03125Q293.53125,0.03125,292.73438,-0.828125Q291.95312,-1.6875,291.95312,-2.84375L291.95312,-68.828125Q291.95312,-69.984375,292.8125,-70.84375Q293.6875,-71.703125,294.82812,-71.703125Q296.125,-71.703125,296.92188,-70.84375Q297.71875,-69.984375,297.71875,-68.828125L297.71875,-44.546875L295.26562,-40.796875Q295.26562,-46.390625,297.20312,-52.140625Q299.15625,-57.90625,302.89062,-62.859375Q306.64062,-67.828125,312.03125,-70.90625Q317.4375,-74,324.5,-74Q326.51562,-74,328.8125,-73.421875Q331.125,-72.859375,331.125,-70.703125Q331.125,-69.40625,330.40625,-68.546875Q329.6875,-67.6875,328.53125,-67.6875Q327.65625,-67.6875,326.4375,-68.328125Q325.21875,-68.96875,322.90625,-68.96875Q318.29688,-68.96875,313.82812,-66.234375Q309.375,-63.515625,305.70312,-58.984375Q302.03125,-54.46875,299.875,-49.140625Q297.71875,-43.8125,297.71875,-38.78125L297.71875,-2.84375Q297.71875,-1.6875,296.84375,-0.828125Q295.98438,0.03125,294.82812,0.03125Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M410.90625,-36.203125Q410.90625,-25.5625,406.29688,-17Q401.6875,-8.453125,393.6875,-3.484375Q385.70312,1.46875,375.48438,1.46875Q365.40625,1.46875,357.32812,-3.484375Q349.26562,-8.453125,344.57812,-17Q339.90625,-25.5625,339.90625,-36.203125Q339.90625,-46.96875,344.57812,-55.53125Q349.26562,-64.09375,357.32812,-69.046875Q365.40625,-74,375.48438,-74Q385.70312,-74,393.6875,-69.046875Q401.6875,-64.09375,406.29688,-55.53125Q410.90625,-46.96875,410.90625,-36.203125ZM405.14062,-36.203125Q405.14062,-45.53125,401.32812,-52.796875Q397.51562,-60.0625,390.8125,-64.296875Q384.125,-68.546875,375.48438,-68.546875Q366.98438,-68.546875,360.21875,-64.296875Q353.45312,-60.0625,349.5625,-52.796875Q345.67188,-45.53125,345.67188,-36.203125Q345.67188,-27,349.5625,-19.734375Q353.45312,-12.46875,360.21875,-8.234375Q366.98438,-4,375.48438,-4Q384.125,-4,390.8125,-8.234375Q397.51562,-12.46875,401.32812,-19.734375Q405.14062,-27,405.14062,-36.203125Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M435.71875,-2.875Q435.71875,-1.71875,434.84375,-0.859375Q433.98438,0,432.82812,0Q431.53125,0,430.73438,-0.859375Q429.95312,-1.71875,429.95312,-2.875L429.95312,-71.125Q429.95312,-72.28125,430.8125,-73.140625Q431.6875,-74,432.82812,-74Q434.125,-74,434.92188,-73.140625Q435.71875,-72.28125,435.71875,-71.125L435.71875,-2.875ZM432.82812,-83.65625Q430.53125,-83.65625,429.15625,-84.9375Q427.79688,-86.234375,427.79688,-88.25L427.79688,-89.390625Q427.79688,-91.40625,429.23438,-92.703125Q430.67188,-94,432.96875,-94Q434.98438,-94,436.35938,-92.703125Q437.73438,-91.40625,437.73438,-89.390625L437.73438,-88.25Q437.73438,-86.234375,436.35938,-84.9375Q434.98438,-83.65625,432.82812,-83.65625Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M518.84375,-106Q520.1406,-106,520.9375,-105.140625Q521.7344,-104.28125,521.7344,-103.15625L521.7344,-2.84375Q521.7344,-1.6875,520.8594,-0.828125Q520,0.03125,518.84375,0.03125Q517.5469,0.03125,516.75,-0.828125Q515.96875,-1.6875,515.96875,-2.84375L515.96875,-23.265625L518.2656,-26.421875Q518.2656,-21.53125,516.03125,-16.5625Q513.8125,-11.609375,509.70312,-7.515625Q505.59375,-3.421875,500.125,-0.96875Q494.65625,1.46875,488.3125,1.46875Q478.8125,1.46875,471.25,-3.484375Q463.70312,-8.453125,459.29688,-17Q454.90625,-25.5625,454.90625,-36.34375Q454.90625,-47.109375,459.29688,-55.671875Q463.70312,-64.234375,471.25,-69.109375Q478.8125,-74,488.3125,-74Q494.21875,-74,499.625,-71.765625Q505.03125,-69.546875,509.20312,-65.515625Q513.375,-61.5,515.8125,-56.09375Q518.2656,-50.703125,518.2656,-44.390625L515.96875,-47.984375L515.96875,-103.15625Q515.96875,-104.296875,516.75,-105.140625Q517.5469,-106,518.84375,-106ZM488.60938,-4Q496.8125,-4,503.07812,-8.15625Q509.34375,-12.328125,512.9375,-19.65625Q516.5469,-27,516.5469,-36.34375Q516.5469,-45.6875,512.9375,-52.9375Q509.34375,-60.203125,503,-64.375Q496.67188,-68.546875,488.60938,-68.546875Q480.6875,-68.546875,474.34375,-64.375Q468.01562,-60.203125,464.34375,-52.9375Q460.67188,-45.6875,460.67188,-36.34375Q460.67188,-27.140625,464.34375,-19.796875Q468.01562,-12.46875,474.34375,-8.234375Q480.6875,-4,488.60938,-4Z"
android:fillColor="#FFFFFF"/>
</group>
</group>
</group>
</vector>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
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"
>
<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:contentDescription="@string/title_settings"
android:background="@drawable/transparent_circle_background"
android:focusable="true"
android:focusableInTouchMode="true"
android:layout_marginEnd="24dp"
/>
<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:gravity="center_vertical"
android:textSize="18sp"
android:layout_marginEnd="24dp"
tools:text="12:00"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true"
android:orientation="horizontal"
tools:ignore="MissingDefaultResource"
>
<ImageView
android:id="@+id/header_icon"
android:layout_width="32dp"
android:layout_height="32dp"
/>
<TextView
android:id="@+id/header_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
/>
</LinearLayout>

View file

@ -0,0 +1,297 @@
<?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"
tools:ignore="MissingDefaultResource"
>
<data>
<import type="android.view.View" />
<variable
name="item"
type="androidx.lifecycle.LiveData&lt;dev.jdtech.jellyfin.tv.ui.MediaDetailViewModel.State&gt;"
/>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel"
/>
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<include
android:id="@+id/error_layout"
layout="@layout/error_panel"
tools:visibility="gone"
/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<androidx.constraintlayout.widget.ConstraintLayout
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:padding="16dp"
android:src="@drawable/ic_arrow_left"
android:focusable="true"
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"
app:layout_constraintTop_toTopOf="parent"
tools:text="Alita: Battle Angel"
/>
<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:gravity="center_vertical"
android:textSize="18sp"
android:layout_marginEnd="24dp"
tools:text="12:00"
/>
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/poster"
android:layout_width="320dp"
android:layout_height="180dp"
android:layout_marginStart="@dimen/horizontal_margin"
android:scaleType="centerCrop"
app:baseItemImage="@{item.dto}"
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"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/title"
android:visibility="gone"
tools:text="Subtitle"
/>
<TextView
android:id="@+id/genres"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:text="@{item.genres}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/subtitle"
tools:text="Action, Science Fiction, Adventure"
/>
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/poster"
android:layout_marginEnd="8dp"
android:text="@{item.year}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="2019" />
<TextView
android:id="@+id/playtime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/year"
android:layout_marginEnd="8dp"
android:text="@{item.runtimeMinutes}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="122 min" />
<TextView
android:id="@+id/official_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/playtime"
android:layout_marginEnd="8dp"
android:text="@{item.officialRating}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="PG-13" />
<TextView
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
app:layout_constraintTop_toBottomOf="@id/genres"
app:layout_constraintStart_toEndOf="@id/official_rating"
android:drawablePadding="4dp"
android:text="@{item.communityRating}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:drawableStart="@drawable/ic_star"
android:drawableTint="@color/yellow"
tools:text="7.3" />
<TextView
android:id="@+id/description"
android:layout_width="400dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:text="@{item.description}"
android:maxLines="5"
android:ellipsize="end"
android:layout_marginStart="@dimen/horizontal_margin"
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"
app:layout_constraintStart_toEndOf="@id/poster"
app:layout_constraintTop_toBottomOf="@id/description"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
<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:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:src="@drawable/ic_play"
android:focusable="true"
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:padding="12dp"
android:src="@drawable/ic_film"
android:focusable="true"
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:padding="12dp"
android:focusable="true"
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:padding="12dp"
android:focusable="true"
android:src="@drawable/ic_heart"
android:contentDescription="@string/favorite_button_description"
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_marginTop="32dp"
app:layout_constraintTop_toBottomOf="@id/play_button"
app:layout_constraintStart_toStartOf="parent"
android:text="@string/seasons"
android:visibility="gone"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
/>
<androidx.leanback.widget.ListRowView
android:id="@+id/seasons_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/season_title"
app:layout_constraintStart_toStartOf="parent"
/>
<TextView
android:id="@+id/cast_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/seasons_row"
app:layout_constraintStart_toStartOf="parent"
android:text="@string/cast_amp_crew"
android:visibility="gone"
android:layout_marginStart="@dimen/horizontal_margin"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
/>
<androidx.leanback.widget.ListRowView
android:id="@+id/cast_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/cast_title"
app:layout_constraintStart_toStartOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</FrameLayout>
</layout>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="@dimen/track_selection_item_height"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:ignore="MissingDefaultResource"
android:focusable="true"
android:focusableInTouchMode="true"
>
<Button
android:id="@+id/track_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:textSize="@dimen/track_selection_item_text_size"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:maxLines="1"
android:ellipsize="end"
tools:text="subtitle track"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.leanback.widget.BrowseFrameLayout
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/selector_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:descendantFocusability="afterDescendants"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:ignore="MissingDefaultResource"
>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/track_selector"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:descendantFocusability="afterDescendants"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:ignore="MissingDefaultResource"
/>
</androidx.leanback.widget.BrowseFrameLayout>

View file

@ -0,0 +1,97 @@
<?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"
tools:ignore="MissingDefaultResource">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.AddServerViewModel"
/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.AddServerFragment"
>
<ImageView
android:id="@+id/image_banner"
android:layout_width="268dp"
android:layout_height="75dp"
android:layout_marginTop="64dp"
android:contentDescription="@string/jellyfin_banner"
android:src="@drawable/ic_banner"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="@dimen/setup_container_width"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/image_banner"
app:layout_constraintVertical_bias="0.36"
>
<TextView
android:id="@+id/text_add_server"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="@string/add_server"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
android:textColor="?android:textColorPrimary"
/>
<EditText
android:id="@+id/server_address"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/edit_text_server_address_hint"
app:errorEnabled="true"
app:startIconDrawable="@drawable/ic_server"
android:inputType="textUri"
/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<Button
android:id="@+id/button_connect"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:drawableStart="@drawable/ic_launcher_foreground"
android:text="@string/button_connect"
/>
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible"
/>
</RelativeLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout></layout>

View file

@ -0,0 +1,110 @@
<?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"
tools:ignore="MissingDefaultResource">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.LoginViewModel"
/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.LoginFragment"
>
<ImageView
android:id="@+id/image_banner"
android:layout_width="268dp"
android:layout_height="75dp"
android:layout_marginTop="64dp"
android:contentDescription="@string/jellyfin_banner"
android:src="@drawable/ic_banner"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="@dimen/setup_container_width"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/image_banner"
app:layout_constraintVertical_bias="0.36"
>
<TextView
android:id="@+id/text_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="@string/login"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
android:textColor="?android:textColorPrimary"
/>
<EditText
android:id="@+id/username"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:autofillHints="username"
android:hint="@string/edit_text_username_hint"
android:inputType="text"
app:startIconDrawable="@drawable/ic_user"
/>
<EditText
android:id="@+id/password"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:autofillHints="password"
android:hint="@string/edit_text_password_hint"
android:inputType="textPassword"
app:passwordToggleEnabled="true"
app:startIconDrawable="@drawable/ic_lock"
/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<Button
android:id="@+id/button_login"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:text="@string/button_login"
/>
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible"
/>
</RelativeLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,136 @@
<?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:id="@+id/tv_player_controls"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/player_background"
tools:ignore="MissingDefaultResource"
>
<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:padding="@dimen/tv_controls_padding"
android:src="@drawable/ic_arrow_left"
android:focusable="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<TextView
android:id="@+id/video_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:padding="@dimen/tv_controls_padding"
android:textAppearance="@style/TextAppearance.AppCompat.Title"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/back_button"
app:layout_constraintTop_toTopOf="parent"
tools:text="The Dawn of Despair"
/>
<ImageButton
android:id="@+id/btn_audio_track"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/select_audio_track"
android:padding="@dimen/tv_controls_padding"
android:src="@drawable/ic_speaker"
android:focusable="true"
app:layout_constraintBottom_toTopOf="@id/exo_progress"
app:layout_constraintStart_toStartOf="parent"
/>
<ImageButton
android:id="@+id/btn_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/tv_controls_margin_start"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/select_subtile_track"
android:padding="@dimen/tv_controls_padding"
android:src="@drawable/ic_closed_caption"
android:focusable="true"
app:layout_constraintBottom_toTopOf="@id/exo_progress"
app:layout_constraintStart_toEndOf="@id/btn_audio_track"
/>
<com.google.android.exoplayer2.ui.DefaultTimeBar
android:id="@+id/exo_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/exo_play_pause"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:focusable="true"
app:played_color="?colorPrimary"
/>
<ImageButton
android:id="@+id/exo_rew"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_rewind"
android:padding="@dimen/tv_controls_padding"
android:src="@drawable/ic_rewind"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<ImageButton
android:id="@+id/exo_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/tv_controls_margin_start"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/play_button_description"
android:padding="@dimen/tv_controls_padding"
android:src="@drawable/ic_pause"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/exo_rew"
android:focusedByDefault="true"
/>
<ImageButton
android:id="@+id/exo_ffwd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/tv_controls_margin_start"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_fast_forward"
android:padding="@dimen/tv_controls_padding"
android:src="@drawable/ic_fast_forward"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/exo_play_pause"
/>
<ImageButton
android:id="@+id/exo_next"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/tv_controls_margin_start"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_skip"
android:padding="@dimen/tv_controls_padding"
android:src="@drawable/ic_skip_forward"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/exo_ffwd"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -27,7 +27,7 @@
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"
app:navGraph="@navigation/main_navigation" />
app:navGraph="@navigation/app_navigation" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/main_toolbar_layout"

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<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"
>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/tv_nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="parent"
app:navGraph="@navigation/tv_navigation"
/>
</FrameLayout>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
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=".PlayerActivity"
>
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
app:controller_layout_id="@layout/tv_player_controls"
app:show_buffering="always"
/>
</FrameLayout>

View file

@ -57,7 +57,7 @@
android:gravity="center"
android:text="@{item.userData.unplayedItemCount.toString()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:textColor="?attr/colorOnPrimary"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="9" />

View file

@ -26,6 +26,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_exit"
android:padding="16dp"
android:src="@drawable/ic_arrow_left" />
@ -75,6 +76,7 @@
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/select_audio_track"
android:padding="16dp"
android:src="@drawable/ic_speaker"
app:tint="@color/white" />
@ -89,6 +91,7 @@
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/select_subtile_track"
android:padding="16dp"
android:src="@drawable/ic_closed_caption"
app:tint="@color/white" />

View file

@ -21,7 +21,7 @@
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/episode_image"
android:layout_width="0dp"
android:layout_width="240dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:baseItemImage="@{episode}"

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/>
<foreground android:drawable="@drawable/ic_banner_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -2,7 +2,7 @@
<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/main_navigation"
android:id="@+id/app_navigation"
app:startDestination="@+id/homeFragment">
<fragment
@ -178,15 +178,6 @@
app:argType="boolean"
android:defaultValue="false" />
</dialog>
<activity
android:id="@+id/playerActivity"
android:name="dev.jdtech.jellyfin.PlayerActivity"
android:label="activity_player"
tools:layout="@layout/activity_player">
<argument
android:name="items"
app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />
</activity>
<fragment
android:id="@+id/favoriteFragment"
android:name="dev.jdtech.jellyfin.fragments.FavoriteFragment"
@ -276,6 +267,16 @@
app:destination="@id/mediaInfoFragment" />
</fragment>
<activity
android:id="@+id/playerActivity"
android:name="dev.jdtech.jellyfin.PlayerActivity"
android:label="activity_player"
tools:layout="@layout/activity_player">
<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

@ -0,0 +1,93 @@
<?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/settingsFragment"
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="string" />
<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/fragment_add_server">
<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/settingsFragment"
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

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingDefaultResource">
<dimen name="horizontal_margin">48dp</dimen>
<dimen name="vertical_margin">48dp</dimen>
<dimen name="tv_controls_padding">8dp</dimen>
<dimen name="tv_controls_margin_start">32dp</dimen>
<dimen name="track_selection_item_height">48dp</dimen>
<dimen name="track_selection_item_text_size">16sp</dimen>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="roundedCardView">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">40dp</item>
</style>
</resources>

View file

@ -0,0 +1,29 @@
<resources>
<!-- Base application theme. -->
<style name="Theme.Jellyfin.Tv" 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>
<item name="colorSecondaryVariant">@color/green_800</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">@color/neutral_900</item>
<!-- Customize your theme here. -->
<item name="android:windowBackground">@color/neutral_900</item>
<item name="android:colorBackgroundFloating">@color/neutral_900</item>
<item name="defaultBrandColor">@color/neutral_900</item>
<item name="browseTitleViewLayout">@layout/browse_support_fragment_title_view</item>
</style>
<string-array name="themes">
<item>Follow system</item>
<item>Light</item>
<item>Dark</item>
</string-array>
<string-array name="themes_value">
<item>system</item>
<item>light</item>
<item>dark</item>
</string-array>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_banner_background">#000000</color>
</resources>

View file

@ -90,4 +90,13 @@
<item>Ascending</item>
<item>Descending</item>
</string-array>
<string name="runtime_minutes">%1$d mins</string>
<string name="select_video_version_title">Select version</string>
<string name="player_controls_exit">Exit player</string>
<string name="player_controls_rewind">Rewind</string>
<string name="player_controls_fast_forward">Fast forward</string>
<string name="player_controls_pause">Pause</string>
<string name="player_controls_skip">Skip</string>
<string name="track_selection">[%1$s] %2$s (%3$s)</string>
<string name="add_server_empty_error">Empty server address</string>
</resources>