feat(media): add detailed metadata for file on MediaInfoFragment (#246)

* feat: add video file metadata on `MediaInfoFragment`

* feat(metadata): add chips within a chipgroup to showcase major parameters

Set a "temp" text as default for chips since without it, the style resets when text is changed through code
(kind of a hacky fix)

* feat(parser): implement data model for VideoMetadata and parse function

* feat(metadata): show dolby/dts audio codecs and hide SDR display profile

* feat(dolby): add a dolby logo after the rating and per-theme color

* feat(settings): add a preference switch for showing detailed A/V & Subs info

* feat: add dolby logo for video and audio profile inside chip

* feat: handle different audio profiles and change raw names

* feat(audio): add atmos text with the audio codec itself

* feat: only parse metadata when item is a movie

Also correct spacing when there are no chips

* fix(metadata): check for DoVi title since codec shows as HDR10

* fixup!: parsing of audio codecs and display name

---------

Co-authored-by: Jarne Demeulemeester <jarnedemeulemeester@gmail.com>
This commit is contained in:
Yash Garg 2023-02-08 02:54:16 +05:30 committed by GitHub
parent b74c313a4e
commit a6570d8a02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 686 additions and 39 deletions

View file

@ -17,6 +17,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
@ -25,6 +26,8 @@ import dev.jdtech.jellyfin.bindItemBackdropImage
import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.AudioCodec
import dev.jdtech.jellyfin.models.DisplayProfile
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.setTintColor import dev.jdtech.jellyfin.utils.setTintColor
@ -36,6 +39,7 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MediaInfoFragment : Fragment() { class MediaInfoFragment : Fragment() {
@ -47,6 +51,9 @@ class MediaInfoFragment : Fragment() {
lateinit var errorDialog: ErrorDialogFragment lateinit var errorDialog: ErrorDialogFragment
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -157,8 +164,12 @@ class MediaInfoFragment : Fragment() {
when (viewModel.played) { when (viewModel.played) {
true -> { true -> {
viewModel.markAsUnplayed(args.itemId) viewModel.markAsUnplayed(args.itemId)
binding.checkButton.setTintColorAttribute(R.attr.colorOnSecondaryContainer, requireActivity().theme) binding.checkButton.setTintColorAttribute(
R.attr.colorOnSecondaryContainer,
requireActivity().theme
)
} }
false -> { false -> {
viewModel.markAsPlayed(args.itemId) viewModel.markAsPlayed(args.itemId)
binding.checkButton.setTintColor(R.color.red, requireActivity().theme) binding.checkButton.setTintColor(R.color.red, requireActivity().theme)
@ -171,8 +182,12 @@ class MediaInfoFragment : Fragment() {
true -> { true -> {
viewModel.unmarkAsFavorite(args.itemId) viewModel.unmarkAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart) binding.favoriteButton.setImageResource(R.drawable.ic_heart)
binding.favoriteButton.setTintColorAttribute(R.attr.colorOnSecondaryContainer, requireActivity().theme) binding.favoriteButton.setTintColorAttribute(
R.attr.colorOnSecondaryContainer,
requireActivity().theme
)
} }
false -> { false -> {
viewModel.markAsFavorite(args.itemId) viewModel.markAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled) binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
@ -225,7 +240,10 @@ class MediaInfoFragment : Fragment() {
// Check icon // Check icon
when (played) { when (played) {
true -> binding.checkButton.setTintColor(R.color.red, requireActivity().theme) true -> binding.checkButton.setTintColor(R.color.red, requireActivity().theme)
false -> binding.checkButton.setTintColorAttribute(R.attr.colorOnSecondaryContainer, requireActivity().theme) false -> binding.checkButton.setTintColorAttribute(
R.attr.colorOnSecondaryContainer,
requireActivity().theme
)
} }
// Favorite icon // Favorite icon
@ -241,8 +259,12 @@ class MediaInfoFragment : Fragment() {
binding.downloadButton.isVisible = true binding.downloadButton.isVisible = true
binding.downloadButton.isEnabled = !downloaded binding.downloadButton.isEnabled = !downloaded
if (downloaded) binding.downloadButton.setTintColor(R.color.red, requireActivity().theme) if (downloaded) binding.downloadButton.setTintColor(
R.color.red,
requireActivity().theme
)
} }
false -> { false -> {
binding.downloadButton.isVisible = false binding.downloadButton.isVisible = false
} }
@ -264,6 +286,68 @@ class MediaInfoFragment : Fragment() {
binding.communityRating.text = item.communityRating.toString() binding.communityRating.text = item.communityRating.toString()
binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false
binding.genres.text = genresString binding.genres.text = genresString
binding.videoMeta.text = videoString
binding.audio.text = audioString
binding.subtitles.text = subtitleString
binding.subsChip.isVisible = subtitleString.isNotEmpty()
if (appPreferences.displayExtraInfo) {
binding.subtitlesLayout.isVisible = subtitleString.isNotEmpty()
binding.videoMetaLayout.isVisible = videoString.isNotEmpty()
binding.audioLayout.isVisible = audioString.isNotEmpty()
}
videoMetadata?.let {
with(binding) {
videoMetaChips.isVisible = true
audioChannelChip.text = it.audioChannels.firstOrNull()?.raw
resChip.text = it.resolution.firstOrNull()?.raw
audioChannelChip.isVisible = it.audioChannels.isNotEmpty()
resChip.isVisible = it.resolution.isNotEmpty()
it.displayProfiles.firstOrNull()?.apply {
videoProfileChip.text = this.raw
videoProfileChip.isVisible = when (this) {
DisplayProfile.HDR,
DisplayProfile.HDR10,
DisplayProfile.HLG -> {
videoProfileChip.chipStartPadding = .0f
true
}
DisplayProfile.DOLBY_VISION -> {
videoProfileChip.isChipIconVisible = true
true
}
else -> false
}
}
audioCodecChip.text = when (val codec = it.audioCodecs.firstOrNull()) {
AudioCodec.AC3, AudioCodec.EAC3, AudioCodec.TRUEHD -> {
audioCodecChip.isVisible = true
if (it.isAtmos.firstOrNull() == true) {
"${codec.raw} | Atmos"
} else codec.raw
}
AudioCodec.DTS -> {
audioCodecChip.apply {
isVisible = true
isChipIconVisible = false
chipStartPadding = .0f
}
codec.raw
}
else -> {
audioCodecChip.isVisible = false
null
}
}
}
}
binding.directorLayout.isVisible = director != null binding.directorLayout.isVisible = director != null
binding.director.text = director?.name binding.director.text = director?.name
binding.writersLayout.isVisible = writers.isNotEmpty() binding.writersLayout.isVisible = writers.isNotEmpty()
@ -324,7 +408,8 @@ class MediaInfoFragment : Fragment() {
) )
binding.progressCircular.visibility = View.INVISIBLE binding.progressCircular.visibility = View.INVISIBLE
binding.playerItemsErrorDetails.setOnClickListener { binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment.newInstance(error.error).show(parentFragmentManager, ErrorDialogFragment.TAG) ErrorDialogFragment.newInstance(error.error)
.show(parentFragmentManager, ErrorDialogFragment.TAG)
} }
} }

View file

@ -72,15 +72,14 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp">
android:layout_marginBottom="16dp">
<TextView <TextView
android:id="@+id/year" android:id="@+id/year"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="2019" /> tools:text="2019" />
@ -89,6 +88,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="122 min" /> tools:text="122 min" />
@ -97,6 +97,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="PG-13" /> tools:text="PG-13" />
@ -104,19 +105,93 @@
android:id="@+id/community_rating" android:id="@+id/community_rating"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:drawablePadding="4dp" android:drawablePadding="4dp"
android:gravity="bottom" android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:drawableStartCompat="@drawable/ic_star" app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow" app:drawableTint="@color/yellow"
tools:text="7.3" /> tools:text="7.3" />
</LinearLayout> </LinearLayout>
<com.google.android.material.chip.ChipGroup
android:id="@+id/video_meta_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="10dp"
android:visibility="gone"
app:singleLine="true"
tools:visibility="visible">
<com.google.android.material.chip.Chip
android:id="@+id/res_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
tools:text="4K"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/video_profile_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
app:chipIcon="@drawable/ic_dolby"
app:chipIconSize="0dp"
app:chipIconTint="?attr/colorOnPrimarySurface"
app:chipIconVisible="false"
app:chipStartPadding="8dp"
tools:text="Vision"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/audio_codec_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
app:chipIcon="@drawable/ic_dolby"
app:chipIconSize="0dp"
app:chipIconTint="?attr/colorOnBackground"
app:chipIconVisible="true"
app:chipStartPadding="8dp"
tools:text="ATMOS"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/audio_channel_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
tools:text="5.1"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/subs_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/subtitle_chip_text"
android:visibility="gone"
tools:text="CC"
tools:visibility="visible" />
</com.google.android.material.chip.ChipGroup>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:layout_marginVertical="15dp"
android:baselineAligned="false"> android:baselineAligned="false"
android:orientation="horizontal">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
@ -253,6 +328,102 @@
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/video_meta_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone">
<TextView
android:id="@+id/video_meta_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/video"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/video_meta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="4K HEVC HDR" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/audio_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone">
<TextView
android:id="@+id/audio_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/audio"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/audio"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:ellipsize="end"
android:maxLines="3"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="AC-3 Eng 5.1, AC-3 iTA 5.1" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/subtitles_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:visibility="gone">
<TextView
android:id="@+id/subtitles_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/subtitles"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:ellipsize="end"
android:maxLines="3"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="English - SUBRIP, SDH - English - SUBRIP" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
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." />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/genres_layout" android:id="@+id/genres_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -336,15 +507,6 @@
</LinearLayout> </LinearLayout>
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout

View file

@ -72,15 +72,14 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp">
android:layout_marginBottom="16dp">
<TextView <TextView
android:id="@+id/year" android:id="@+id/year"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="2019" /> tools:text="2019" />
@ -89,6 +88,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="122 min" /> tools:text="122 min" />
@ -97,6 +97,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="PG-13" /> tools:text="PG-13" />
@ -104,19 +105,93 @@
android:id="@+id/community_rating" android:id="@+id/community_rating"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:drawablePadding="4dp" android:drawablePadding="4dp"
android:gravity="bottom" android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:drawableStartCompat="@drawable/ic_star" app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow" app:drawableTint="@color/yellow"
tools:text="7.3" /> tools:text="7.3" />
</LinearLayout> </LinearLayout>
<com.google.android.material.chip.ChipGroup
android:id="@+id/video_meta_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="10dp"
android:visibility="gone"
app:singleLine="true"
tools:visibility="visible">
<com.google.android.material.chip.Chip
android:id="@+id/res_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
tools:text="4K"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/video_profile_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
app:chipIcon="@drawable/ic_dolby"
app:chipIconSize="0dp"
app:chipIconTint="?attr/colorOnPrimarySurface"
app:chipIconVisible="false"
app:chipStartPadding="8dp"
tools:text="Vision"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/audio_codec_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
app:chipIcon="@drawable/ic_dolby"
app:chipIconSize="0dp"
app:chipIconTint="?attr/colorOnBackground"
app:chipIconVisible="true"
app:chipStartPadding="8dp"
tools:text="ATMOS"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/audio_channel_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/temp"
android:visibility="gone"
tools:text="5.1"
tools:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/subs_chip"
style="@style/MetaChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/subtitle_chip_text"
android:visibility="gone"
tools:text="CC"
tools:visibility="visible" />
</com.google.android.material.chip.ChipGroup>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"> android:layout_marginVertical="15dp">
<RelativeLayout <RelativeLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -233,7 +308,6 @@
android:textColor="?attr/colorError" /> android:textColor="?attr/colorError" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/info" android:id="@+id/info"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -242,6 +316,102 @@
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/video_meta_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone">
<TextView
android:id="@+id/video_meta_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/video"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/video_meta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="4K HEVC HDR" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/audio_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone">
<TextView
android:id="@+id/audio_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/audio"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/audio"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:ellipsize="end"
android:maxLines="3"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="AC-3 Eng 5.1, AC-3 iTA 5.1" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/subtitles_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:visibility="gone">
<TextView
android:id="@+id/subtitles_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/subtitles"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:ellipsize="end"
android:maxLines="3"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="English - SUBRIP, SDH - English - SUBRIP" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
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." />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/genres_layout" android:id="@+id/genres_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -325,15 +495,6 @@
</LinearLayout> </LinearLayout>
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
<LinearLayout <LinearLayout
android:id="@+id/next_up_layout" android:id="@+id/next_up_layout"
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -5,7 +5,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.AudioChannel
import dev.jdtech.jellyfin.models.AudioCodec
import dev.jdtech.jellyfin.models.DisplayProfile
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.models.Resolution
import dev.jdtech.jellyfin.models.VideoMetadata
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.canRetryDownload import dev.jdtech.jellyfin.utils.canRetryDownload
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
@ -21,10 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.*
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.PlayAccess
import timber.log.Timber import timber.log.Timber
@HiltViewModel @HiltViewModel
@ -44,8 +46,12 @@ constructor(
val actors: List<BaseItemPerson>, val actors: List<BaseItemPerson>,
val director: BaseItemPerson?, val director: BaseItemPerson?,
val writers: List<BaseItemPerson>, val writers: List<BaseItemPerson>,
val videoMetadata: VideoMetadata?,
val writersString: String, val writersString: String,
val genresString: String, val genresString: String,
val videoString: String,
val audioString: String,
val subtitleString: String,
val runTime: String, val runTime: String,
val dateString: String, val dateString: String,
val nextUp: BaseItemDto?, val nextUp: BaseItemDto?,
@ -67,8 +73,12 @@ constructor(
private var actors: List<BaseItemPerson> = emptyList() private var actors: List<BaseItemPerson> = emptyList()
private var director: BaseItemPerson? = null private var director: BaseItemPerson? = null
private var writers: List<BaseItemPerson> = emptyList() private var writers: List<BaseItemPerson> = emptyList()
private var videoMetadata: VideoMetadata? = null
private var writersString: String = "" private var writersString: String = ""
private var genresString: String = "" private var genresString: String = ""
private var videoString: String = ""
private var audioString: String = ""
private var subtitleString: String = ""
private var runTime: String = "" private var runTime: String = ""
private var dateString: String = "" private var dateString: String = ""
var nextUp: BaseItemDto? = null var nextUp: BaseItemDto? = null
@ -93,7 +103,12 @@ constructor(
director = getDirector(tempItem) director = getDirector(tempItem)
writers = getWriters(tempItem) writers = getWriters(tempItem)
writersString = writers.joinToString(separator = ", ") { it.name.toString() } writersString = writers.joinToString(separator = ", ") { it.name.toString() }
videoMetadata =
if (tempItem.type == BaseItemKind.MOVIE) parseVideoMetadata(tempItem) else null
genresString = tempItem.genres?.joinToString(separator = ", ") ?: "" genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
videoString = getMediaString(tempItem, MediaStreamType.VIDEO)
audioString = getMediaString(tempItem, MediaStreamType.AUDIO)
subtitleString = getMediaString(tempItem, MediaStreamType.SUBTITLE)
runTime = "${tempItem.runTimeTicks?.div(600000000)} min" runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
dateString = getDateString(tempItem) dateString = getDateString(tempItem)
played = tempItem.userData?.played ?: false played = tempItem.userData?.played ?: false
@ -111,8 +126,12 @@ constructor(
actors, actors,
director, director,
writers, writers,
videoMetadata,
writersString, writersString,
genresString, genresString,
videoString,
audioString,
subtitleString,
runTime, runTime,
dateString, dateString,
nextUp, nextUp,
@ -141,7 +160,12 @@ constructor(
director = getDirector(tempItem) director = getDirector(tempItem)
writers = getWriters(tempItem) writers = getWriters(tempItem)
writersString = writers.joinToString(separator = ", ") { it.name.toString() } writersString = writers.joinToString(separator = ", ") { it.name.toString() }
videoMetadata =
if (tempItem.type == BaseItemKind.MOVIE) parseVideoMetadata(tempItem) else null
genresString = tempItem.genres?.joinToString(separator = ", ") ?: "" genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
videoString = getMediaString(tempItem, MediaStreamType.VIDEO)
audioString = getMediaString(tempItem, MediaStreamType.AUDIO)
subtitleString = getMediaString(tempItem, MediaStreamType.SUBTITLE)
runTime = "" runTime = ""
dateString = "" dateString = ""
played = tempItem.userData?.played ?: false played = tempItem.userData?.played ?: false
@ -154,8 +178,12 @@ constructor(
actors, actors,
director, director,
writers, writers,
videoMetadata,
writersString, writersString,
genresString, genresString,
videoString,
audioString,
subtitleString,
runTime, runTime,
dateString, dateString,
nextUp, nextUp,
@ -196,6 +224,119 @@ constructor(
return writers return writers
} }
private suspend fun getMediaString(item: BaseItemDto, type: MediaStreamType): String {
val streams: List<MediaStream>
withContext(Dispatchers.Default) {
streams = item.mediaStreams?.filter { it.type == type } ?: emptyList()
}
return streams.map { it.displayTitle }.joinToString(separator = ", ")
}
private suspend fun parseVideoMetadata(item: BaseItemDto): VideoMetadata {
val resolution = mutableListOf<Resolution>()
val audioChannels = mutableListOf<AudioChannel>()
val displayProfile = mutableListOf<DisplayProfile>()
val audioCodecs = mutableListOf<AudioCodec>()
val isAtmosAudio = mutableListOf<Boolean>()
withContext(Dispatchers.Default) {
item.mediaStreams?.filter { stream ->
when (stream.type) {
MediaStreamType.AUDIO -> {
/**
* Match audio profile from [MediaStream.channelLayout]
*/
audioChannels.add(
when (stream.channelLayout) {
AudioChannel.CH_2_1.raw -> AudioChannel.CH_2_1
AudioChannel.CH_5_1.raw -> AudioChannel.CH_5_1
AudioChannel.CH_7_1.raw -> AudioChannel.CH_7_1
else -> AudioChannel.CH_2_0
}
)
/**
* Match [MediaStream.displayTitle] for Dolby Atmos
*/
stream.displayTitle?.apply {
isAtmosAudio.add(contains("ATMOS", true))
}
/**
* Match audio codec from [MediaStream.codec]
*/
audioCodecs.add(
when (stream.codec?.lowercase()) {
AudioCodec.FLAC.toString() -> AudioCodec.FLAC
AudioCodec.AAC.toString() -> AudioCodec.AAC
AudioCodec.AC3.toString() -> AudioCodec.AC3
AudioCodec.EAC3.toString() -> AudioCodec.EAC3
AudioCodec.VORBIS.toString() -> AudioCodec.VORBIS
AudioCodec.OPUS.toString() -> AudioCodec.OPUS
AudioCodec.TRUEHD.toString() -> AudioCodec.TRUEHD
AudioCodec.DTS.toString() -> AudioCodec.DTS
else -> AudioCodec.MP3
}
)
true
}
MediaStreamType.VIDEO -> {
with(stream) {
/**
* Match dynamic range from [MediaStream.videoRangeType]
*/
displayProfile.add(
/**
* Since [MediaStream.videoRangeType] is [DisplayProfile.HDR10]
* Check if [MediaStream.videoDoViTitle] is not null and return
* [DisplayProfile.DOLBY_VISION] accordingly
*/
if (stream.videoDoViTitle != null) {
DisplayProfile.DOLBY_VISION
} else when (videoRangeType) {
DisplayProfile.HDR.raw -> DisplayProfile.HDR
DisplayProfile.HDR10.raw -> DisplayProfile.HDR10
DisplayProfile.HLG.raw -> DisplayProfile.HLG
else -> DisplayProfile.SDR
}
)
/**
* Force stream [MediaStream.height] and [MediaStream.width] as not null
* since we are inside [MediaStreamType.VIDEO] block
*/
resolution.add(
when {
height!! <= 1080 && width!! <= 1920 -> {
Resolution.HD
}
height!! <= 2160 && width!! <= 3840 -> {
Resolution.UHD
}
else -> Resolution.SD
}
)
}
true
}
else -> false
}
}
}
return VideoMetadata(
resolution,
displayProfile.toSet().toList(),
audioChannels.toSet().toList(),
audioCodecs.toSet().toList(),
isAtmosAudio
)
}
private suspend fun getNextUp(seriesId: UUID): BaseItemDto? { private suspend fun getNextUp(seriesId: UUID): BaseItemDto? {
val nextUpItems = jellyfinRepository.getNextUp(seriesId) val nextUpItems = jellyfinRepository.getNextUp(seriesId)
return if (nextUpItems.isNotEmpty()) { return if (nextUpItems.isNotEmpty()) {
@ -256,6 +397,7 @@ constructor(
"Continuing" -> { "Continuing" -> {
dateRange.add("Present") dateRange.add("Present")
} }
"Ended" -> { "Ended" -> {
item.endDate?.let { dateRange.add(it.year.toString()) } item.endDate?.let { dateRange.add(it.year.toString()) }
} }

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="17dp"
android:height="12dp"
android:viewportWidth="17"
android:viewportHeight="12">
<group>
<clip-path android:pathData="M0,0h17v12h-17z" />
<path
android:fillColor="#000000"
android:pathData="M0,12.017H1.773C5.083,12.017 7.779,9.319 7.779,6.013C7.779,2.704 5.083,0.005 1.773,0.005H0V12.017ZM17.088,0.005H15.317C12.007,0.005 9.309,2.704 9.309,6.013C9.309,9.319 12.007,12.017 15.317,12.017H17.088V0.005Z" />
</group>
</vector>

View file

@ -15,5 +15,8 @@
<!-- Surface --> <!-- Surface -->
<item name="android:colorBackground">@color/neutral_1000</item> <item name="android:colorBackground">@color/neutral_1000</item>
<item name="colorSurface">@color/neutral_900</item> <item name="colorSurface">@color/neutral_900</item>
<!-- Extra -->
<item name="dolbyColor">#FFF</item>
</style> </style>
</resources> </resources>

View file

@ -153,4 +153,12 @@
<string name="add_server_address">Add server address</string> <string name="add_server_address">Add server address</string>
<string name="add">Add</string> <string name="add">Add</string>
<string name="quick_connect">Quick Connect</string> <string name="quick_connect">Quick Connect</string>
<string name="video">Video</string>
<string name="audio">Audio</string>
<string name="subtitle">Subtitles</string>
<string name="subtitle_chip_text">CC</string>
<string name="temp">temp</string>
<string name="dolby_logo_desc">Dolby Logo</string>
<string name="extra_info">Display Extra Info.</string>
<string name="extra_info_summary">Displays detailed information about Audio, Video and Subtitles</string>
</resources> </resources>

View file

@ -18,4 +18,17 @@
<item name="android:widgetLayout">@layout/preference_material3_switch</item> <item name="android:widgetLayout">@layout/preference_material3_switch</item>
</style> </style>
<style name="MetaChip" parent="Widget.Material3.Chip.Assist">
<item name="android:textAppearance">@style/TextAppearance.Material3.LabelLarge</item>
<item name="android:textStyle">bold</item>
<item name="chipBackgroundColor">?attr/colorSecondaryContainer</item>
<item name="chipCornerRadius">6dp</item>
<item name="chipEndPadding">0dp</item>
<item name="chipStartPadding">0dp</item>
<item name="chipMinTouchTargetSize">20dp</item>
<item name="chipStrokeWidth">0dp</item>
</style>
<attr name="dolbyColor" format="reference|color" />
</resources> </resources>

View file

@ -45,6 +45,7 @@
<item name="alertDialogTheme">@style/ThemeOverlay.Material3.MaterialAlertDialog</item> <item name="alertDialogTheme">@style/ThemeOverlay.Material3.MaterialAlertDialog</item>
<item name="dialogCornerRadius">28dp</item> <item name="dialogCornerRadius">28dp</item>
<item name="preferenceTheme">@style/ThemeOverlay.Findroid.Preference</item> <item name="preferenceTheme">@style/ThemeOverlay.Findroid.Preference</item>
<item name="dolbyColor">#000</item>
</style> </style>
<string-array name="themes"> <string-array name="themes">

View file

@ -12,4 +12,9 @@
app:key="dynamic_colors" app:key="dynamic_colors"
app:summary="@string/dynamic_colors_summary" app:summary="@string/dynamic_colors_summary"
app:title="@string/dynamic_colors" /> app:title="@string/dynamic_colors" />
<SwitchPreferenceCompat
app:defaultValue="false"
app:key="pref_display_extra_info"
app:summary="@string/extra_info_summary"
app:title="@string/extra_info" />
</PreferenceScreen> </PreferenceScreen>

View file

@ -0,0 +1,46 @@
@file:Suppress("Unused")
package dev.jdtech.jellyfin.models
data class VideoMetadata(
val resolution: List<Resolution>,
val displayProfiles: List<DisplayProfile>,
val audioChannels: List<AudioChannel>,
val audioCodecs: List<AudioCodec>,
val isAtmos: List<Boolean>
)
enum class Resolution(val raw: String) {
SD("SD"),
HD("HD"),
UHD("4K");
}
enum class DisplayProfile(val raw: String) {
SDR("SDR"),
HDR("HDR"),
HDR10("HDR10"),
DOLBY_VISION("Vision"),
HLG("HLG");
}
enum class AudioChannel(val raw: String) {
CH_2_0("2.0"),
CH_2_1("2.1"),
CH_5_1("5.1"),
CH_7_1("7.1");
}
enum class AudioCodec(val raw: String) {
FLAC("FLAC"),
MP3("MP3"),
AAC("AAC"),
AC3("Digital"),
EAC3("Digital+"),
VORBIS("VORBIS"),
DTS("DTS"),
TRUEHD("TrueHD"),
OPUS("OPUS");
override fun toString() = super.toString().lowercase()
}

View file

@ -24,6 +24,13 @@ constructor(
// Appearance // Appearance
val theme get() = sharedPreferences.getString(Constants.PREF_THEME, null) val theme get() = sharedPreferences.getString(Constants.PREF_THEME, null)
val dynamicColors get() = sharedPreferences.getBoolean(Constants.PREF_DYNAMIC_COLORS, true) val dynamicColors get() = sharedPreferences.getBoolean(Constants.PREF_DYNAMIC_COLORS, true)
var displayExtraInfo: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_DISPLAY_EXTRA_INFO, false)
set(value) {
sharedPreferences.edit {
putBoolean(Constants.PREF_DISPLAY_EXTRA_INFO, value)
}
}
// Player // Player
val displayExtendedTitle get() = sharedPreferences.getBoolean(Constants.PREF_DISPLAY_EXTENDED_TITLE, false) val displayExtendedTitle get() = sharedPreferences.getBoolean(Constants.PREF_DISPLAY_EXTENDED_TITLE, false)
@ -126,4 +133,5 @@ constructor(
putString(Constants.PREF_SORT_ORDER, value) putString(Constants.PREF_SORT_ORDER, value)
} }
} }
} }

View file

@ -38,6 +38,7 @@ object Constants {
const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming" const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming"
const val PREF_SORT_BY = "pref_sort_by" const val PREF_SORT_BY = "pref_sort_by"
const val PREF_SORT_ORDER = "pref_sort_order" const val PREF_SORT_ORDER = "pref_sort_order"
const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info"
// caching // caching
const val DEFAULT_CACHE_SIZE = 20 const val DEFAULT_CACHE_SIZE = 20