From a6570d8a0207a5fa8a5639648b118c4861781532 Mon Sep 17 00:00:00 2001 From: Yash Garg Date: Wed, 8 Feb 2023 02:54:16 +0530 Subject: [PATCH] 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 --- .../jellyfin/fragments/MediaInfoFragment.kt | 95 ++++++++- .../res/layout-w600dp/fragment_media_info.xml | 192 ++++++++++++++++-- .../main/res/layout/fragment_media_info.xml | 191 +++++++++++++++-- .../jellyfin/viewmodels/MediaInfoViewModel.kt | 150 +++++++++++++- core/src/main/res/drawable/ic_dolby.xml | 12 ++ core/src/main/res/values-night/themes.xml | 3 + core/src/main/res/values/strings.xml | 8 + core/src/main/res/values/styles.xml | 13 ++ core/src/main/res/values/themes.xml | 1 + .../res/xml/fragment_settings_appearance.xml | 5 + .../jdtech/jellyfin/models/VideoMetadata.kt | 46 +++++ .../dev/jdtech/jellyfin/AppPreferences.kt | 8 + .../java/dev/jdtech/jellyfin/Constants.kt | 1 + 13 files changed, 686 insertions(+), 39 deletions(-) create mode 100644 core/src/main/res/drawable/ic_dolby.xml create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/VideoMetadata.kt diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt index 65dcbb00..7a62ba46 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -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) } } diff --git a/app/phone/src/main/res/layout-w600dp/fragment_media_info.xml b/app/phone/src/main/res/layout-w600dp/fragment_media_info.xml index a78dcf46..f7291e5c 100644 --- a/app/phone/src/main/res/layout-w600dp/fragment_media_info.xml +++ b/app/phone/src/main/res/layout-w600dp/fragment_media_info.xml @@ -72,15 +72,14 @@ - + android:layout_marginHorizontal="24dp"> @@ -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" /> + + + + + + + + + + + + + + android:layout_marginVertical="15dp" + android:baselineAligned="false" + android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + + + + - - - + android:layout_marginHorizontal="24dp"> @@ -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" /> + + + + + + + + + + + + + + + android:layout_marginVertical="15dp"> - + + + + + + + + + + + + + + + + + + + + + + + - - , val director: BaseItemPerson?, val writers: List, + 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 = emptyList() private var director: BaseItemPerson? = null private var writers: List = 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 + 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() + val audioChannels = mutableListOf() + val displayProfile = mutableListOf() + val audioCodecs = mutableListOf() + val isAtmosAudio = mutableListOf() + + 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()) } } diff --git a/core/src/main/res/drawable/ic_dolby.xml b/core/src/main/res/drawable/ic_dolby.xml new file mode 100644 index 00000000..231a732e --- /dev/null +++ b/core/src/main/res/drawable/ic_dolby.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/src/main/res/values-night/themes.xml b/core/src/main/res/values-night/themes.xml index 76453273..a27db654 100644 --- a/core/src/main/res/values-night/themes.xml +++ b/core/src/main/res/values-night/themes.xml @@ -15,5 +15,8 @@ @color/neutral_1000 @color/neutral_900 + + + #FFF \ No newline at end of file diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index aff2c0b6..7f63cdd3 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -153,4 +153,12 @@ Add server address Add Quick Connect + Video + Audio + Subtitles + CC + temp + Dolby Logo + Display Extra Info. + Displays detailed information about Audio, Video and Subtitles \ No newline at end of file diff --git a/core/src/main/res/values/styles.xml b/core/src/main/res/values/styles.xml index 896ebf02..77fdf0b8 100644 --- a/core/src/main/res/values/styles.xml +++ b/core/src/main/res/values/styles.xml @@ -18,4 +18,17 @@ @layout/preference_material3_switch + + + + \ No newline at end of file diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml index 254ebf81..8a17c4fb 100644 --- a/core/src/main/res/values/themes.xml +++ b/core/src/main/res/values/themes.xml @@ -45,6 +45,7 @@ @style/ThemeOverlay.Material3.MaterialAlertDialog 28dp @style/ThemeOverlay.Findroid.Preference + #000 diff --git a/core/src/main/res/xml/fragment_settings_appearance.xml b/core/src/main/res/xml/fragment_settings_appearance.xml index 412eb528..ac1a9202 100644 --- a/core/src/main/res/xml/fragment_settings_appearance.xml +++ b/core/src/main/res/xml/fragment_settings_appearance.xml @@ -12,4 +12,9 @@ app:key="dynamic_colors" app:summary="@string/dynamic_colors_summary" app:title="@string/dynamic_colors" /> + \ No newline at end of file diff --git a/data/src/main/java/dev/jdtech/jellyfin/models/VideoMetadata.kt b/data/src/main/java/dev/jdtech/jellyfin/models/VideoMetadata.kt new file mode 100644 index 00000000..9ebe49e7 --- /dev/null +++ b/data/src/main/java/dev/jdtech/jellyfin/models/VideoMetadata.kt @@ -0,0 +1,46 @@ +@file:Suppress("Unused") + +package dev.jdtech.jellyfin.models + +data class VideoMetadata( + val resolution: List, + val displayProfiles: List, + val audioChannels: List, + val audioCodecs: List, + val isAtmos: List +) + +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() +} diff --git a/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt b/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt index 2bae895e..b07107b1 100644 --- a/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt +++ b/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt @@ -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) } } + } diff --git a/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt b/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt index f97001ab..ab6cc2b2 100644 --- a/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt +++ b/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt @@ -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