Merge pull request #30 from jarnedemeulemeester/develop

Version 0.1.2
This commit is contained in:
Jarne Demeulemeester 2021-08-26 23:59:20 +02:00 committed by GitHub
commit 905332e097
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 419 additions and 215 deletions

View file

@ -16,8 +16,8 @@ android {
applicationId "dev.jdtech.jellyfin"
minSdkVersion 24
targetSdkVersion 31
versionCode 2
versionName "0.1.1"
versionCode 3
versionName "0.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -20,4 +20,4 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep public class dev.jdtech.jellyfin.models.PlayerItem
-keepnames class dev.jdtech.jellyfin.models.PlayerItem

View file

@ -11,6 +11,7 @@ import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.models.FavoriteSection
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import java.util.*
@BindingAdapter("servers")
@ -35,11 +36,12 @@ fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
fun bindItemImage(imageView: ImageView, item: BaseItemDto) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
val itemId = if (item.type == "Episode") item.seriesId else item.id
val itemId =
if (item.type == "Episode" || item.type == "Season" && item.imageTags.isNullOrEmpty()) item.seriesId else item.id
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/Primary"))
.load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/${ImageType.PRIMARY}"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
@ -49,17 +51,16 @@ fun bindItemImage(imageView: ImageView, item: BaseItemDto) {
@BindingAdapter("itemBackdropImage")
fun bindItemBackdropImage(imageView: ImageView, item: BaseItemDto?) {
if (item != null) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
if (item == null) return
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${item.id}/Images/Backdrop"))
.transition(DrawableTransitionOptions.withCrossFade())
.into(imageView)
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${item.id}/Images/${ImageType.BACKDROP}"))
.transition(DrawableTransitionOptions.withCrossFade())
.into(imageView)
imageView.contentDescription = "${item.name} backdrop"
}
imageView.contentDescription = "${item.name} backdrop"
}
@BindingAdapter("itemBackdropById")
@ -68,7 +69,7 @@ fun bindItemBackdropById(imageView: ImageView, itemId: UUID) {
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/Backdrop"))
.load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/${ImageType.BACKDROP}"))
.transition(DrawableTransitionOptions.withCrossFade())
.into(imageView)
}
@ -79,20 +80,6 @@ fun bindCollections(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
adapter.submitList(data)
}
@BindingAdapter("collectionImage")
fun bindCollectionImage(imageView: ImageView, item: BaseItemDto) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${item.id}/Images/Primary"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
imageView.contentDescription = "${item.name} image"
}
@BindingAdapter("people")
fun bindPeople(recyclerView: RecyclerView, data: List<BaseItemPerson>?) {
val adapter = recyclerView.adapter as PersonListAdapter
@ -105,7 +92,7 @@ fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${person.id}/Images/Primary"))
.load(jellyfinApi.api.baseUrl.plus("/items/${person.id}/Images/${ImageType.PRIMARY}"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
@ -125,15 +112,38 @@ fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
adapter.submitList(data)
}
@BindingAdapter("episodeImage")
fun bindEpisodeImage(imageView: ImageView, episode: BaseItemDto) {
@BindingAdapter("baseItemImage")
fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
if (episode == null) return
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
val imageType = if (episode.type == "Movie") "Backdrop" else "Primary"
var imageItemId = episode.id
var imageType = ImageType.PRIMARY
if (!episode.imageTags.isNullOrEmpty()) {
when (episode.type) {
"Movie" -> {
if (!episode.backdropImageTags.isNullOrEmpty()) {
imageType = ImageType.BACKDROP
}
}
else -> {
if (!episode.imageTags!!.keys.contains(ImageType.PRIMARY)) {
imageType = ImageType.BACKDROP
}
}
}
} else {
if (episode.type == "Episode") {
imageItemId = episode.seriesId!!
imageType = ImageType.BACKDROP
}
}
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${episode.id}/Images/$imageType"))
.load(jellyfinApi.api.baseUrl.plus("/items/${imageItemId}/Images/$imageType"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
@ -147,28 +157,12 @@ fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${seasonId}/Images/Primary"))
.load(jellyfinApi.api.baseUrl.plus("/items/${seasonId}/Images/${ImageType.PRIMARY}"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
}
@BindingAdapter("itemPrimaryImage")
fun bindItemPrimaryImage(imageView: ImageView, item: BaseItemDto?) {
if (item != null) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${item.id}/Images/Primary"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
imageView.contentDescription = "${item.name} poster"
}
}
@BindingAdapter("favoriteSections")
fun bindFavoriteSections(recyclerView: RecyclerView, data: List<FavoriteSection>?) {
val adapter = recyclerView.adapter as FavoritesListAdapter

View file

@ -38,7 +38,7 @@ class PlayerActivity : AppCompatActivity() {
})
if (viewModel.player.value == null) {
viewModel.initializePlayer(args.items, args.playbackPosition)
viewModel.initializePlayer(args.items)
}
hideSystemUI()
}

View file

@ -1,14 +1,11 @@
package dev.jdtech.jellyfin.database
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import androidx.room.*
@Dao
interface ServerDatabaseDao {
@Insert
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(server: Server)
@Update

View file

@ -11,12 +11,12 @@ class VideoVersionDialogFragment(
private val viewModel: MediaInfoViewModel
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val items = viewModel.mediaSources.value!!.map { it.name }
val items = viewModel.item.value?.mediaSources?.map { it.name }
return activity?.let {
val builder = AlertDialog.Builder(it)
builder.setTitle("Select a version")
.setItems(items.toTypedArray()) { _, which ->
viewModel.navigateToPlayer(viewModel.mediaSources.value!![which])
.setItems(items?.toTypedArray()) { _, which ->
viewModel.preparePlayerItems(which)
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")

View file

@ -37,7 +37,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE
viewModel.preparePlayer()
viewModel.preparePlayerItems()
}
binding.checkButton.setOnClickListener {
@ -91,10 +91,14 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
if (it) {
navigateToPlayerActivity(
viewModel.playerItems.toTypedArray(),
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
)
viewModel.doneNavigateToPlayer()
binding.playButton.setImageDrawable(ContextCompat.getDrawable(requireActivity(), R.drawable.ic_play))
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
}
})
@ -102,7 +106,12 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
if (errorMessage != null) {
binding.playerItemsError.visibility = View.VISIBLE
binding.playButton.setImageDrawable(ContextCompat.getDrawable(requireActivity(), R.drawable.ic_play))
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
} else {
binding.playerItemsError.visibility = View.GONE
@ -110,7 +119,9 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
})
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(viewModel.playerItemsError.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
ErrorDialogFragment(
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
).show(parentFragmentManager, "errordialog")
}
viewModel.loadEpisode(args.episodeId)
@ -120,12 +131,10 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>,
playbackPosition: Long
) {
findNavController().navigate(
EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToPlayerActivity(
playerItems,
playbackPosition
)
)
}

View file

@ -14,6 +14,7 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
@ -44,6 +45,7 @@ class FavoriteFragment : Fragment() {
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.favoritesRecyclerView.visibility = View.GONE
} else {

View file

@ -12,6 +12,7 @@ import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
@ -73,6 +74,7 @@ class HomeFragment : Fragment() {
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.viewsRecyclerView.visibility = View.GONE
} else {
@ -86,7 +88,10 @@ class HomeFragment : Fragment() {
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
parentFragmentManager,
"errordialog"
)
}
return binding.root
@ -96,7 +101,8 @@ class HomeFragment : Fragment() {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToLibraryFragment(
view.id,
view.name
view.name,
view.type
)
)
}

View file

@ -14,6 +14,7 @@ import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import org.jellyfin.sdk.model.api.BaseItemDto
@AndroidEntryPoint
@ -41,6 +42,7 @@ class LibraryFragment : Fragment() {
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.itemsRecyclerView.visibility = View.GONE
} else {
@ -50,7 +52,7 @@ class LibraryFragment : Fragment() {
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadItems(args.libraryId)
viewModel.loadItems(args.libraryId, args.libraryType)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
@ -65,7 +67,7 @@ class LibraryFragment : Fragment() {
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
})
viewModel.loadItems(args.libraryId)
viewModel.loadItems(args.libraryId, args.libraryType)
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {

View file

@ -11,6 +11,7 @@ import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.CollectionListAdapter
import dev.jdtech.jellyfin.databinding.FragmentMediaBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
@ -66,6 +67,7 @@ class MediaFragment : Fragment() {
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.viewsRecyclerView.visibility = View.GONE
} else {
@ -89,7 +91,8 @@ class MediaFragment : Fragment() {
findNavController().navigate(
MediaFragmentDirections.actionNavigationMediaToLibraryFragment(
library.id,
library.name
library.name,
library.collectionType,
)
)
}

View file

@ -19,6 +19,7 @@ import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
@ -48,6 +49,7 @@ class MediaInfoFragment : Fragment() {
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.mediaInfoScrollview.visibility = View.GONE
} else {
@ -61,7 +63,10 @@ class MediaInfoFragment : Fragment() {
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
parentFragmentManager,
"errordialog"
)
}
viewModel.item.observe(viewLifecycleOwner, { item ->
@ -89,8 +94,7 @@ class MediaInfoFragment : Fragment() {
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems ->
if (playerItems != null) {
navigateToPlayerActivity(
playerItems,
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
playerItems
)
viewModel.doneNavigatingToPlayer()
binding.playButton.setImageDrawable(
@ -137,7 +141,9 @@ class MediaInfoFragment : Fragment() {
})
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(viewModel.playerItemsError.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
ErrorDialogFragment(
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
).show(parentFragmentManager, "errordialog")
}
binding.trailerButton.setOnClickListener {
@ -162,33 +168,18 @@ class MediaInfoFragment : Fragment() {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE
if (args.itemType == "Movie") {
if (!viewModel.mediaSources.value.isNullOrEmpty()) {
if (viewModel.mediaSources.value!!.size > 1) {
if (viewModel.item.value?.mediaSources != null) {
if (viewModel.item.value?.mediaSources?.size!! > 1) {
VideoVersionDialogFragment(viewModel).show(
parentFragmentManager,
"videoversiondialog"
)
} else {
navigateToPlayerActivity(
arrayOf(
PlayerItem(
args.itemId,
viewModel.mediaSources.value!![0].id!!
)
),
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000),
)
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
viewModel.preparePlayerItems()
}
}
} else if (args.itemType == "Series") {
viewModel.preparePlayer()
viewModel.preparePlayerItems()
}
}
@ -230,12 +221,10 @@ class MediaInfoFragment : Fragment() {
private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>,
playbackPosition: Long,
) {
findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
playerItems,
playbackPosition
)
)
}

View file

@ -15,6 +15,7 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
@ -47,6 +48,7 @@ class SearchResultFragment : Fragment() {
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.searchResultsRecyclerView.visibility = View.GONE
} else {

View file

@ -13,6 +13,7 @@ import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.EpisodeListAdapter
import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
@ -39,6 +40,7 @@ class SeasonFragment : Fragment() {
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.episodesRecyclerView.visibility = View.GONE
} else {

View file

@ -7,5 +7,6 @@ import java.util.*
@Parcelize
data class PlayerItem(
val itemId: UUID,
val mediaSourceId: String
val mediaSourceId: String,
val playbackPosition: Long
) : Parcelable

View file

@ -6,5 +6,6 @@ import java.util.*
data class View(
val id: UUID,
val name: String?,
var items: List<BaseItemDto>? = null
var items: List<BaseItemDto>? = null,
val type: String?
)

View file

@ -10,7 +10,11 @@ interface JellyfinRepository {
suspend fun getItem(itemId: UUID): BaseItemDto
suspend fun getItems(parentId: UUID? = null): List<BaseItemDto>
suspend fun getItems(
parentId: UUID? = null,
includeTypes: List<String>? = null,
recursive: Boolean = false
): List<BaseItemDto>
suspend fun getFavoriteItems(): List<BaseItemDto>
@ -28,7 +32,7 @@ interface JellyfinRepository {
seriesId: UUID,
seasonId: UUID,
fields: List<ItemFields>? = null,
startIndex: Int? = null
startItemId: UUID? = null
): List<BaseItemDto>
suspend fun getMediaSources(itemId: UUID): List<MediaSourceInfo>
@ -50,4 +54,6 @@ interface JellyfinRepository {
suspend fun markAsPlayed(itemId: UUID)
suspend fun markAsUnplayed(itemId: UUID)
suspend fun getIntros(itemId: UUID): List<BaseItemDto>
}

View file

@ -25,12 +25,18 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
return item
}
override suspend fun getItems(parentId: UUID?): List<BaseItemDto> {
override suspend fun getItems(
parentId: UUID?,
includeTypes: List<String>?,
recursive: Boolean
): List<BaseItemDto> {
val items: List<BaseItemDto>
withContext(Dispatchers.IO) {
items = jellyfinApi.itemsApi.getItems(
jellyfinApi.userId!!,
parentId = parentId
parentId = parentId,
includeItemTypes = includeTypes,
recursive = recursive
).content.items ?: listOf()
}
return items
@ -109,7 +115,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
seriesId: UUID,
seasonId: UUID,
fields: List<ItemFields>?,
startIndex: Int?
startItemId: UUID?
): List<BaseItemDto> {
val episodes: List<BaseItemDto>
withContext(Dispatchers.IO) {
@ -118,7 +124,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
jellyfinApi.userId!!,
seasonId = seasonId,
fields = fields,
startIndex = startIndex
startItemId = startItemId
).content.items ?: listOf()
}
return episodes
@ -260,4 +266,14 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
jellyfinApi.playStateApi.markUnplayedItem(jellyfinApi.userId!!, itemId)
}
}
override suspend fun getIntros(itemId: UUID): List<BaseItemDto> {
val intros: List<BaseItemDto>
withContext(Dispatchers.IO) {
intros =
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items
?: listOf()
}
return intros
}
}

View file

@ -1,11 +1,23 @@
package dev.jdtech.jellyfin.utils
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import dev.jdtech.jellyfin.MainNavigationDirections
import dev.jdtech.jellyfin.models.View
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
fun BaseItemDto.toView(): View {
return View(
id = id,
name = name
name = name,
type = collectionType
)
}
fun Fragment.checkIfLoginRequired(error: String) {
if (error.contains("401")) {
Timber.d("Login required!")
findNavController().navigate(MainNavigationDirections.actionGlobalLoginFragment())
}
}

View file

@ -10,6 +10,8 @@ import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber
import java.text.DateFormat
import java.time.ZoneOffset
@ -61,28 +63,53 @@ constructor(
}
}
fun preparePlayer() {
fun preparePlayerItems() {
_playerItemsError.value = null
viewModelScope.launch {
try {
createPlayerItems(_item.value!!)
_navigateToPlayer.value = true
} catch (e: Exception) {
_playerItemsError.value = e.message
_playerItemsError.value = e.toString()
}
}
}
private suspend fun createPlayerItems(startEpisode: BaseItemDto) {
playerItems.clear()
val playbackPosition = startEpisode.userData?.playbackPositionTicks?.div(10000) ?: 0
// Intros
var introsCount = 0
if (playbackPosition <= 0) {
val intros = jellyfinRepository.getIntros(startEpisode.id)
for (intro in intros) {
if (intro.mediaSources.isNullOrEmpty()) continue
playerItems.add(PlayerItem(intro.id, intro.mediaSources?.get(0)?.id!!, 0))
introsCount += 1
}
}
val episodes = jellyfinRepository.getEpisodes(
startEpisode.seriesId!!,
startEpisode.seasonId!!,
startIndex = startEpisode.indexNumber?.minus(1)
startItemId = startEpisode.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
)
for (episode in episodes) {
val mediaSources = jellyfinRepository.getMediaSources(episode.id)
playerItems.add(PlayerItem(episode.id, mediaSources[0].id!!))
if (episode.mediaSources.isNullOrEmpty()) continue
if (episode.locationType == LocationType.VIRTUAL) continue
playerItems.add(
PlayerItem(
episode.id,
episode.mediaSources?.get(0)?.id!!,
playbackPosition
)
)
}
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
}
fun markAsPlayed(itemId: UUID) {

View file

@ -78,7 +78,7 @@ constructor(
_favoriteSections.value = tempFavoriteSections
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
_finishedLoading.value = true
}

View file

@ -12,7 +12,9 @@ import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.View
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.toView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import java.util.*
@ -50,45 +52,59 @@ constructor(
_finishedLoading.value = false
viewModelScope.launch {
try {
jellyfinRepository.postCapabilities()
val views: MutableList<View> = mutableListOf()
val userViews = jellyfinRepository.getUserViews()
for (view in userViews) {
Timber.d("Collection type: ${view.collectionType}")
if (view.collectionType == "homevideos" ||
view.collectionType == "music" ||
view.collectionType == "playlists" ||
view.collectionType == "books"
) continue
val latestItems = jellyfinRepository.getLatestMedia(view.id)
if (latestItems.isEmpty()) continue
val v = view.toView()
v.items = latestItems
views.add(v)
}
val items = mutableListOf<HomeItem>()
val resumeItems = jellyfinRepository.getResumeItems()
val resumeSection =
HomeSection(UUID.randomUUID(), continueWatchingString, resumeItems)
withContext(Dispatchers.Default) {
if (!resumeItems.isNullOrEmpty()) {
items.add(HomeItem.Section(resumeSection))
val resumeItems = jellyfinRepository.getResumeItems()
val resumeSection =
HomeSection(UUID.randomUUID(), continueWatchingString, resumeItems)
if (!resumeItems.isNullOrEmpty()) {
items.add(HomeItem.Section(resumeSection))
}
val nextUpItems = jellyfinRepository.getNextUp()
val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems)
if (!nextUpItems.isNullOrEmpty()) {
items.add(HomeItem.Section(nextUpSection))
}
}
val nextUpItems = jellyfinRepository.getNextUp()
val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems)
_views.value = items
if (!nextUpItems.isNullOrEmpty()) {
items.add(HomeItem.Section(nextUpSection))
val views: MutableList<View> = mutableListOf()
withContext(Dispatchers.Default) {
val userViews = jellyfinRepository.getUserViews()
for (view in userViews) {
Timber.d("Collection type: ${view.collectionType}")
if (view.collectionType == "homevideos" ||
view.collectionType == "music" ||
view.collectionType == "playlists" ||
view.collectionType == "books" ||
view.collectionType == "livetv"
) continue
val latestItems = jellyfinRepository.getLatestMedia(view.id)
if (latestItems.isEmpty()) continue
val v = view.toView()
v.items = latestItems
views.add(v)
}
}
_views.value = items + views.map { HomeItem.ViewItem(it) }
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
_finishedLoading.value = true
}

View file

@ -23,15 +23,25 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun loadItems(parentId: UUID) {
fun loadItems(parentId: UUID, libraryType: String?) {
_error.value = null
_finishedLoading.value = false
Timber.d("$libraryType")
val itemType = when (libraryType) {
"movies" -> "Movie"
"tvshows" -> "Series"
else -> null
}
viewModelScope.launch {
try {
_items.value = jellyfinRepository.getItems(parentId)
_items.value = jellyfinRepository.getItems(
parentId,
includeTypes = if (itemType != null) listOf(itemType) else null,
recursive = true
)
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
_finishedLoading.value = true
}

View file

@ -66,7 +66,7 @@ constructor(
_navigateToMain.value = true
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
}
}

View file

@ -13,7 +13,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@ -52,9 +53,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _seasons = MutableLiveData<List<BaseItemDto>>()
val seasons: LiveData<List<BaseItemDto>> = _seasons
private val _mediaSources = MutableLiveData<List<MediaSourceInfo>>()
val mediaSources: LiveData<List<MediaSourceInfo>> = _mediaSources
private val _navigateToPlayer = MutableLiveData<Array<PlayerItem>>()
val navigateToPlayer: LiveData<Array<PlayerItem>> = _navigateToPlayer
@ -91,12 +89,9 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
_nextUp.value = getNextUp(itemId)
_seasons.value = jellyfinRepository.getSeasons(itemId)
}
if (itemType == "Movie") {
_mediaSources.value = jellyfinRepository.getMediaSources(itemId)
}
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
}
}
@ -183,11 +178,11 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
}
}
fun preparePlayer() {
fun preparePlayerItems(mediaSourceIndex: Int? = null) {
_playerItemsError.value = null
viewModelScope.launch {
try {
createPlayerItems(_item.value!!)
createPlayerItems(_item.value!!, mediaSourceIndex)
_navigateToPlayer.value = playerItems.toTypedArray()
} catch (e: Exception) {
_playerItemsError.value = e.message
@ -195,28 +190,78 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
}
}
private suspend fun createPlayerItems(series: BaseItemDto) {
if (nextUp.value != null) {
val startEpisode = nextUp.value!!
val episodes = jellyfinRepository.getEpisodes(startEpisode.seriesId!!, startEpisode.seasonId!!, startIndex = startEpisode.indexNumber?.minus(1))
for (episode in episodes) {
val mediaSources = jellyfinRepository.getMediaSources(episode.id)
playerItems.add(PlayerItem(episode.id, mediaSources[0].id!!))
private suspend fun createPlayerItems(series: BaseItemDto, mediaSourceIndex: Int? = null) {
playerItems.clear()
val playbackPosition = item.value?.userData?.playbackPositionTicks?.div(10000) ?: 0
// Intros
var introsCount = 0
if (playbackPosition <= 0) {
val intros = jellyfinRepository.getIntros(series.id)
for (intro in intros) {
if (intro.mediaSources.isNullOrEmpty()) continue
playerItems.add(PlayerItem(intro.id, intro.mediaSources?.get(0)?.id!!, 0))
introsCount += 1
}
} else {
for (season in seasons.value!!) {
if (season.indexNumber == 0) continue
val episodes = jellyfinRepository.getEpisodes(series.id, season.id)
for (episode in episodes) {
val mediaSources = jellyfinRepository.getMediaSources(episode.id)
playerItems.add(PlayerItem(episode.id, mediaSources[0].id!!))
}
when (series.type) {
"Movie" -> {
playerItems.add(
PlayerItem(
series.id,
series.mediaSources?.get(mediaSourceIndex ?: 0)?.id!!,
playbackPosition
)
)
}
"Series" -> {
if (nextUp.value != null) {
val startEpisode = nextUp.value!!
val episodes = jellyfinRepository.getEpisodes(
startEpisode.seriesId!!,
startEpisode.seasonId!!,
startItemId = startEpisode.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
)
for (episode in episodes) {
if (episode.mediaSources.isNullOrEmpty()) continue
if (episode.locationType == LocationType.VIRTUAL) continue
playerItems.add(
PlayerItem(
episode.id,
episode.mediaSources?.get(0)?.id!!,
0
)
)
}
} else {
for (season in seasons.value!!) {
if (season.indexNumber == 0) continue
val episodes = jellyfinRepository.getEpisodes(
series.id,
season.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
)
for (episode in episodes) {
if (episode.mediaSources.isNullOrEmpty()) continue
if (episode.locationType == LocationType.VIRTUAL) continue
playerItems.add(
PlayerItem(
episode.id,
episode.mediaSources?.get(0)?.id!!,
0
)
)
}
}
}
}
}
}
fun navigateToPlayer(mediaSource: MediaSourceInfo) {
_navigateToPlayer.value = arrayOf(PlayerItem(item.value!!.id, mediaSource.id!!))
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
}
fun doneNavigatingToPlayer() {

View file

@ -44,7 +44,7 @@ constructor(
}
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
_finishedLoading.value = true
}

View file

@ -39,8 +39,7 @@ constructor(
private val sp = PreferenceManager.getDefaultSharedPreferences(application)
fun initializePlayer(
items: Array<PlayerItem>,
playbackPosition: Long
items: Array<PlayerItem>
) {
val renderersFactory =
@ -61,18 +60,22 @@ constructor(
viewModelScope.launch {
val mediaItems: MutableList<MediaItem> = mutableListOf()
for (item in items) {
val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
Timber.d("Stream url: $streamUrl")
val mediaItem =
MediaItem.Builder()
.setMediaId(item.itemId.toString())
.setUri(streamUrl)
.build()
mediaItems.add(mediaItem)
try {
for (item in items) {
val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
Timber.d("Stream url: $streamUrl")
val mediaItem =
MediaItem.Builder()
.setMediaId(item.itemId.toString())
.setUri(streamUrl)
.build()
mediaItems.add(mediaItem)
}
} catch (e: Exception) {
Timber.e(e)
}
player.setMediaItems(mediaItems, currentWindow, playbackPosition)
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
player.playWhenReady = playWhenReady
player.prepare()
_player.value = player
@ -84,10 +87,14 @@ constructor(
private fun releasePlayer() {
_player.value?.let { player ->
runBlocking {
jellyfinRepository.postPlaybackStop(
UUID.fromString(player.currentMediaItem?.mediaId),
player.currentPosition.times(10000)
)
try {
jellyfinRepository.postPlaybackStop(
UUID.fromString(player.currentMediaItem?.mediaId),
player.currentPosition.times(10000)
)
} catch (e: Exception) {
Timber.e(e)
}
}
}
@ -107,11 +114,15 @@ constructor(
override fun run() {
viewModelScope.launch {
if (player.currentMediaItem != null) {
jellyfinRepository.postPlaybackProgress(
UUID.fromString(player.currentMediaItem!!.mediaId),
player.currentPosition.times(10000),
!player.isPlaying
)
try {
jellyfinRepository.postPlaybackProgress(
UUID.fromString(player.currentMediaItem!!.mediaId),
player.currentPosition.times(10000),
!player.isPlaying
)
} catch (e: Exception) {
Timber.e(e)
}
}
}
handler.postDelayed(this, 2000)
@ -123,7 +134,11 @@ constructor(
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
Timber.d("Playing MediaItem: ${mediaItem?.mediaId}")
viewModelScope.launch {
jellyfinRepository.postPlaybackStart(UUID.fromString(mediaItem?.mediaId))
try {
jellyfinRepository.postPlaybackStart(UUID.fromString(mediaItem?.mediaId))
} catch (e: Exception) {
Timber.e(e)
}
}
}

View file

@ -74,7 +74,7 @@ constructor(
_sections.value = tempSections
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
_finishedLoading.value = true
}

View file

@ -35,7 +35,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
_episodes.value = getEpisodes(seriesId, seasonId)
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_error.value = e.toString()
}
_finishedLoading.value = true
}

View file

@ -21,7 +21,7 @@
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/nav_view"
@ -32,7 +32,7 @@
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/main_toolbar_layout"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
app:layout_constraintBottom_toTopOf="@id/nav_host_fragment_activity_main"
@ -47,6 +47,5 @@
</com.google.android.material.appbar.AppBarLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -24,7 +24,7 @@
android:layout_height="0dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
app:collectionImage="@{collection}"
app:baseItemImage="@{collection}"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View file

@ -5,6 +5,10 @@
<data>
<import type="android.view.View" />
<import type="org.jellyfin.sdk.model.api.LocationType" />
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel" />
@ -33,11 +37,32 @@
android:layout_height="85dp"
android:layout_marginStart="24dp"
android:scaleType="centerCrop"
app:itemPrimaryImage="@{viewModel.item}"
app:baseItemImage="@{viewModel.item}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/holder"
app:shapeAppearance="@style/roundedImageView" />
<FrameLayout
android:id="@+id/missing_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/circle_background"
android:backgroundTint="@color/red"
android:visibility="@{viewModel.item.locationType == LocationType.VIRTUAL ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toEndOf="@id/episode_image"
app:layout_constraintTop_toTopOf="@id/episode_image">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="M"
android:textColor="@color/white"
tools:ignore="HardcodedText" />
</FrameLayout>
<FrameLayout
android:id="@+id/progress_bar"
android:layout_width="wrap_content"

View file

@ -7,6 +7,8 @@
<import type="android.view.View" />
<import type="org.jellyfin.sdk.model.api.LocationType" />
<variable
name="episode"
type="org.jellyfin.sdk.model.api.BaseItemDto" />
@ -24,7 +26,7 @@
android:layout_width="100dp"
android:layout_height="100dp"
android:scaleType="centerCrop"
app:episodeImage="@{episode}"
app:baseItemImage="@{episode}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
@ -44,6 +46,27 @@
app:layout_constraintEnd_toEndOf="@id/episode_image"
app:layout_constraintTop_toTopOf="@id/episode_image" />
<FrameLayout
android:id="@+id/missing_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/circle_background"
android:backgroundTint="@color/red"
android:visibility="@{episode.locationType == LocationType.VIRTUAL ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toStartOf="@id/played_icon"
app:layout_constraintTop_toTopOf="@id/episode_image">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="M"
android:textColor="@color/white"
tools:ignore="HardcodedText" />
</FrameLayout>
<FrameLayout
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
@ -74,8 +97,8 @@
android:id="@+id/episode_desc"
android:layout_width="0dp"
android:layout_height="0dp"
android:text="@{episode.overview}"
android:scrollbars="none"
android:text="@{episode.overview}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View file

@ -16,18 +16,18 @@
android:layout_height="match_parent"
tools:context=".fragments.HomeFragment">
<com.google.android.material.progressindicator.CircularProgressIndicator
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:trackCornerRadius="10dp" />
app:layout_constraintTop_toTopOf="parent" />
<include android:id="@+id/error_layout" layout="@layout/error_panel" />
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/views_recycler_view"

View file

@ -358,7 +358,7 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:adjustViewBounds="true"
app:itemPrimaryImage="@{viewModel.nextUp}"
app:baseItemImage="@{viewModel.nextUp}"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View file

@ -22,7 +22,7 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:episodeImage="@{episode}"
app:baseItemImage="@{episode}"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View file

@ -32,7 +32,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:layoutAnimation="@anim/overview_media_animation"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:homeEpisodes="@{section.items}"

View file

@ -42,7 +42,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:clipToPadding="false"
android:layoutAnimation="@anim/overview_media_animation"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:items="@{view.items}"

View file

@ -84,6 +84,10 @@
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<argument
android:name="libraryType"
app:argType="string"
app:nullable="true" />
</fragment>
<fragment
android:id="@+id/mediaInfoFragment"
@ -156,9 +160,6 @@
<argument
android:name="items"
app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />
<argument
android:name="playbackPosition"
app:argType="long" />
</activity>
<fragment
android:id="@+id/favoriteFragment"
@ -239,5 +240,8 @@
</fragment>
<include app:graph="@navigation/aboutlibs_navigation" />
<action
android:id="@+id/action_global_loginFragment"
app:destination="@id/loginFragment" />
</navigation>

View file

@ -51,7 +51,7 @@
<string name="theme">Theme</string>
<string name="error_preparing_player_items">Error preparing player items.</string>
<string name="view_details">View details</string>
<string name="view_details_underlined"><u>@string/view_details</u></string>
<string name="view_details_underlined"><u>View details</u></string>
<string name="about">About</string>
<string name="privacy_policy">Privacy policy</string>
<string name="app_info">App info</string>

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.5.21"
ext.kotlin_version = "1.5.30"
repositories {
google()
mavenCentral()