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.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter
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.dialogs.ErrorDialogFragment
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.utils.checkIfLoginRequired
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.BaseItemKind
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class MediaInfoFragment : Fragment() {
@ -47,6 +51,9 @@ class MediaInfoFragment : Fragment() {
lateinit var errorDialog: ErrorDialogFragment
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -157,8 +164,12 @@ class MediaInfoFragment : Fragment() {
when (viewModel.played) {
true -> {
viewModel.markAsUnplayed(args.itemId)
binding.checkButton.setTintColorAttribute(R.attr.colorOnSecondaryContainer, requireActivity().theme)
binding.checkButton.setTintColorAttribute(
R.attr.colorOnSecondaryContainer,
requireActivity().theme
)
}
false -> {
viewModel.markAsPlayed(args.itemId)
binding.checkButton.setTintColor(R.color.red, requireActivity().theme)
@ -171,8 +182,12 @@ class MediaInfoFragment : Fragment() {
true -> {
viewModel.unmarkAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
binding.favoriteButton.setTintColorAttribute(R.attr.colorOnSecondaryContainer, requireActivity().theme)
binding.favoriteButton.setTintColorAttribute(
R.attr.colorOnSecondaryContainer,
requireActivity().theme
)
}
false -> {
viewModel.markAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
@ -225,7 +240,10 @@ class MediaInfoFragment : Fragment() {
// Check icon
when (played) {
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
@ -241,8 +259,12 @@ class MediaInfoFragment : Fragment() {
binding.downloadButton.isVisible = true
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 -> {
binding.downloadButton.isVisible = false
}
@ -264,6 +286,68 @@ class MediaInfoFragment : Fragment() {
binding.communityRating.text = item.communityRating.toString()
binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false
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.director.text = director?.name
binding.writersLayout.isVisible = writers.isNotEmpty()
@ -324,7 +408,8 @@ class MediaInfoFragment : Fragment() {
)
binding.progressCircular.visibility = View.INVISIBLE
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
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="16dp">
android:layout_marginHorizontal="24dp">
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="2019" />
@ -89,6 +88,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="122 min" />
@ -97,6 +97,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="PG-13" />
@ -104,19 +105,93 @@
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:drawablePadding="4dp"
android:gravity="bottom"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow"
tools:text="7.3" />
</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
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">
android:layout_marginVertical="15dp"
android:baselineAligned="false"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
@ -253,6 +328,102 @@
android:layout_marginBottom="12dp"
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
android:id="@+id/genres_layout"
android:layout_width="match_parent"
@ -336,15 +507,6 @@
</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

View file

@ -72,15 +72,14 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="16dp">
android:layout_marginHorizontal="24dp">
<TextView
android:id="@+id/year"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="2019" />
@ -89,6 +88,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="122 min" />
@ -97,6 +97,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="PG-13" />
@ -104,19 +105,93 @@
android:id="@+id/community_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:drawablePadding="4dp"
android:gravity="bottom"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:drawableStartCompat="@drawable/ic_star"
app:drawableTint="@color/yellow"
tools:text="7.3" />
</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
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp">
android:layout_marginVertical="15dp">
<RelativeLayout
android:layout_width="wrap_content"
@ -233,7 +308,6 @@
android:textColor="?attr/colorError" />
</LinearLayout>
<LinearLayout
android:id="@+id/info"
android:layout_width="match_parent"
@ -242,6 +316,102 @@
android:layout_marginBottom="12dp"
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
android:id="@+id/genres_layout"
android:layout_width="match_parent"
@ -325,15 +495,6 @@
</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
android:id="@+id/next_up_layout"
android:layout_width="match_parent"

View file

@ -5,7 +5,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.Resolution
import dev.jdtech.jellyfin.models.VideoMetadata
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.canRetryDownload
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
@ -21,10 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.PlayAccess
import org.jellyfin.sdk.model.api.*
import timber.log.Timber
@HiltViewModel
@ -44,8 +46,12 @@ constructor(
val actors: List<BaseItemPerson>,
val director: BaseItemPerson?,
val writers: List<BaseItemPerson>,
val videoMetadata: VideoMetadata?,
val writersString: String,
val genresString: String,
val videoString: String,
val audioString: String,
val subtitleString: String,
val runTime: String,
val dateString: String,
val nextUp: BaseItemDto?,
@ -67,8 +73,12 @@ constructor(
private var actors: List<BaseItemPerson> = emptyList()
private var director: BaseItemPerson? = null
private var writers: List<BaseItemPerson> = emptyList()
private var videoMetadata: VideoMetadata? = null
private var writersString: String = ""
private var genresString: String = ""
private var videoString: String = ""
private var audioString: String = ""
private var subtitleString: String = ""
private var runTime: String = ""
private var dateString: String = ""
var nextUp: BaseItemDto? = null
@ -93,7 +103,12 @@ constructor(
director = getDirector(tempItem)
writers = getWriters(tempItem)
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
videoMetadata =
if (tempItem.type == BaseItemKind.MOVIE) parseVideoMetadata(tempItem) else null
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"
dateString = getDateString(tempItem)
played = tempItem.userData?.played ?: false
@ -111,8 +126,12 @@ constructor(
actors,
director,
writers,
videoMetadata,
writersString,
genresString,
videoString,
audioString,
subtitleString,
runTime,
dateString,
nextUp,
@ -141,7 +160,12 @@ constructor(
director = getDirector(tempItem)
writers = getWriters(tempItem)
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
videoMetadata =
if (tempItem.type == BaseItemKind.MOVIE) parseVideoMetadata(tempItem) else null
genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
videoString = getMediaString(tempItem, MediaStreamType.VIDEO)
audioString = getMediaString(tempItem, MediaStreamType.AUDIO)
subtitleString = getMediaString(tempItem, MediaStreamType.SUBTITLE)
runTime = ""
dateString = ""
played = tempItem.userData?.played ?: false
@ -154,8 +178,12 @@ constructor(
actors,
director,
writers,
videoMetadata,
writersString,
genresString,
videoString,
audioString,
subtitleString,
runTime,
dateString,
nextUp,
@ -196,6 +224,119 @@ constructor(
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? {
val nextUpItems = jellyfinRepository.getNextUp(seriesId)
return if (nextUpItems.isNotEmpty()) {
@ -256,6 +397,7 @@ constructor(
"Continuing" -> {
dateRange.add("Present")
}
"Ended" -> {
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 -->
<item name="android:colorBackground">@color/neutral_1000</item>
<item name="colorSurface">@color/neutral_900</item>
<!-- Extra -->
<item name="dolbyColor">#FFF</item>
</style>
</resources>

View file

@ -153,4 +153,12 @@
<string name="add_server_address">Add server address</string>
<string name="add">Add</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>

View file

@ -18,4 +18,17 @@
<item name="android:widgetLayout">@layout/preference_material3_switch</item>
</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>

View file

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

View file

@ -12,4 +12,9 @@
app:key="dynamic_colors"
app:summary="@string/dynamic_colors_summary"
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>

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
val theme get() = sharedPreferences.getString(Constants.PREF_THEME, null)
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
val displayExtendedTitle get() = sharedPreferences.getBoolean(Constants.PREF_DISPLAY_EXTENDED_TITLE, false)
@ -126,4 +133,5 @@ constructor(
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_SORT_BY = "pref_sort_by"
const val PREF_SORT_ORDER = "pref_sort_order"
const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info"
// caching
const val DEFAULT_CACHE_SIZE = 20