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