Split mobile and TV playback profiles

This commit is contained in:
2026-04-08 19:46:37 +02:00
parent 1a17c52326
commit cf08db5034
19 changed files with 2055 additions and 188 deletions

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.tv.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
@Module
@InstallIn(SingletonComponent::class)
object TvPlaybackProfileFamilyModule {
@Provides
fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.TV
}

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
@Module
@InstallIn(SingletonComponent::class)
object PlaybackProfileFamilyModule {
@Provides
fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.MOBILE
}

View File

@@ -36,6 +36,7 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(libs.jellyfin.core)
implementation(libs.media3.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.hilt)
ksp(libs.hilt.compiler)

View File

@@ -0,0 +1,169 @@
package hu.bbara.purefin.core.data.client
import android.media.MediaCodecInfo
import android.media.MediaCodecInfo.CodecProfileLevel
import android.media.MediaFormat
internal data class AndroidCodecCatalog(
val supportedVideoCodecs: Set<String>,
val supportedAudioCodecs: Set<String>,
val videoCodecProfiles: Map<String, Set<String>>,
) {
companion object {
fun create(codecInfos: Array<MediaCodecInfo>): AndroidCodecCatalog {
val supportedVideoCodecs = linkedSetOf<String>()
val supportedAudioCodecs = linkedSetOf<String>()
val videoCodecProfiles = linkedMapOf<String, MutableSet<String>>()
for (codecInfo in codecInfos) {
if (codecInfo.isEncoder) continue
for (mimeType in codecInfo.supportedTypes) {
val capabilities = try {
codecInfo.getCapabilitiesForType(mimeType)
} catch (_: IllegalArgumentException) {
continue
}
androidVideoCodecName(mimeType)?.let { codec ->
supportedVideoCodecs += codec
val profiles = videoCodecProfiles.getOrPut(codec) { linkedSetOf() }
capabilities.profileLevels
.mapNotNull { androidVideoProfileName(codec, it.profile) }
.forEach(profiles::add)
}
androidAudioCodecName(mimeType)?.let(supportedAudioCodecs::add)
}
}
return AndroidCodecCatalog(
supportedVideoCodecs = supportedVideoCodecs,
supportedAudioCodecs = supportedAudioCodecs,
videoCodecProfiles = videoCodecProfiles.mapValues { it.value.toSet() },
)
}
}
}
private fun androidVideoCodecName(mimeType: String): String? = when (mimeType) {
MediaFormat.MIMETYPE_VIDEO_MPEG2 -> "mpeg2video"
MediaFormat.MIMETYPE_VIDEO_H263 -> "h263"
MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
MediaFormat.MIMETYPE_VIDEO_HEVC, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION -> "hevc"
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
else -> null
}
private fun androidAudioCodecName(mimeType: String): String? = when (mimeType) {
MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
MediaFormat.MIMETYPE_AUDIO_AC3 -> "ac3"
MediaFormat.MIMETYPE_AUDIO_AMR_WB, MediaFormat.MIMETYPE_AUDIO_AMR_NB -> "3gpp"
MediaFormat.MIMETYPE_AUDIO_EAC3 -> "eac3"
MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
MediaFormat.MIMETYPE_AUDIO_MPEG -> "mp3"
MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
MediaFormat.MIMETYPE_AUDIO_RAW -> "raw"
MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
MediaFormat.MIMETYPE_AUDIO_QCELP,
MediaFormat.MIMETYPE_AUDIO_MSGSM,
MediaFormat.MIMETYPE_AUDIO_G711_MLAW,
MediaFormat.MIMETYPE_AUDIO_G711_ALAW,
-> null
else -> null
}
private fun androidVideoProfileName(codec: String, profile: Int): String? = when (codec) {
"mpeg2video" -> mpeg2ProfileName(profile)
"h263" -> h263ProfileName(profile)
"mpeg4" -> mpeg4ProfileName(profile)
"h264" -> avcProfileName(profile)
"hevc" -> hevcProfileName(profile)
"vp8" -> vp8ProfileName(profile)
"vp9" -> vp9ProfileName(profile)
else -> null
}
private fun mpeg2ProfileName(profile: Int): String? = when (profile) {
CodecProfileLevel.MPEG2ProfileSimple -> "simple profile"
CodecProfileLevel.MPEG2ProfileMain -> "main profile"
CodecProfileLevel.MPEG2Profile422 -> "422 profile"
CodecProfileLevel.MPEG2ProfileSNR -> "snr profile"
CodecProfileLevel.MPEG2ProfileSpatial -> "spatial profile"
CodecProfileLevel.MPEG2ProfileHigh -> "high profile"
else -> null
}
private fun h263ProfileName(profile: Int): String? = when (profile) {
CodecProfileLevel.H263ProfileBaseline -> "baseline"
CodecProfileLevel.H263ProfileH320Coding -> "h320 coding"
CodecProfileLevel.H263ProfileBackwardCompatible -> "backward compatible"
CodecProfileLevel.H263ProfileISWV2 -> "isw v2"
CodecProfileLevel.H263ProfileISWV3 -> "isw v3"
CodecProfileLevel.H263ProfileHighCompression -> "high compression"
CodecProfileLevel.H263ProfileInternet -> "internet"
CodecProfileLevel.H263ProfileInterlace -> "interlace"
CodecProfileLevel.H263ProfileHighLatency -> "high latency"
else -> null
}
private fun mpeg4ProfileName(profile: Int): String? = when (profile) {
CodecProfileLevel.MPEG4ProfileAdvancedCoding -> "advanced coding profile"
CodecProfileLevel.MPEG4ProfileAdvancedCore -> "advanced core profile"
CodecProfileLevel.MPEG4ProfileAdvancedRealTime -> "advanced realtime profile"
CodecProfileLevel.MPEG4ProfileAdvancedSimple -> "advanced simple profile"
CodecProfileLevel.MPEG4ProfileBasicAnimated -> "basic animated profile"
CodecProfileLevel.MPEG4ProfileCore -> "core profile"
CodecProfileLevel.MPEG4ProfileCoreScalable -> "core scalable profile"
CodecProfileLevel.MPEG4ProfileHybrid -> "hybrid profile"
CodecProfileLevel.MPEG4ProfileNbit -> "nbit profile"
CodecProfileLevel.MPEG4ProfileScalableTexture -> "scalable texture profile"
CodecProfileLevel.MPEG4ProfileSimple -> "simple profile"
CodecProfileLevel.MPEG4ProfileSimpleFBA -> "simple fba profile"
CodecProfileLevel.MPEG4ProfileSimpleFace -> "simple face profile"
CodecProfileLevel.MPEG4ProfileSimpleScalable -> "simple scalable profile"
CodecProfileLevel.MPEG4ProfileMain -> "main profile"
else -> null
}
private fun avcProfileName(profile: Int): String? = when (profile) {
CodecProfileLevel.AVCProfileBaseline -> "baseline"
CodecProfileLevel.AVCProfileMain -> "main"
CodecProfileLevel.AVCProfileExtended -> "extended"
CodecProfileLevel.AVCProfileHigh -> "high"
CodecProfileLevel.AVCProfileHigh10 -> "high 10"
CodecProfileLevel.AVCProfileHigh422 -> "high 422"
CodecProfileLevel.AVCProfileHigh444 -> "high 444"
CodecProfileLevel.AVCProfileConstrainedBaseline -> "constrained baseline"
CodecProfileLevel.AVCProfileConstrainedHigh -> "constrained high"
else -> null
}
private fun hevcProfileName(profile: Int): String? = when (profile) {
CodecProfileLevel.HEVCProfileMain -> "Main"
CodecProfileLevel.HEVCProfileMain10 -> "Main 10"
CodecProfileLevel.HEVCProfileMain10HDR10 -> "Main 10 HDR 10"
CodecProfileLevel.HEVCProfileMain10HDR10Plus -> "Main 10 HDR 10 Plus"
CodecProfileLevel.HEVCProfileMainStill -> "Main Still"
else -> null
}
private fun vp8ProfileName(profile: Int): String? = when (profile) {
CodecProfileLevel.VP8ProfileMain -> "main"
else -> null
}
private fun vp9ProfileName(profile: Int): String? = when (profile) {
CodecProfileLevel.VP9Profile0 -> "Profile 0"
CodecProfileLevel.VP9Profile1 -> "Profile 1"
CodecProfileLevel.VP9Profile2,
CodecProfileLevel.VP9Profile2HDR,
-> "Profile 2"
CodecProfileLevel.VP9Profile3,
CodecProfileLevel.VP9Profile3HDR,
-> "Profile 3"
else -> null
}

View File

@@ -0,0 +1,315 @@
package hu.bbara.purefin.core.data.client
import android.media.MediaCodecInfo.CodecProfileLevel
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
import androidx.media3.common.MimeTypes
data class ProfileResolution(
val width: Int,
val height: Int,
)
interface DeviceProfileCapabilities {
fun supportsAv1(): Boolean
fun supportsAv1Main10(): Boolean
fun supportsAv1DolbyVision(): Boolean
fun supportsAv1Hdr10(): Boolean
fun supportsAv1Hdr10Plus(): Boolean
fun supportsAvc(): Boolean
fun supportsAvcHigh10(): Boolean
fun avcMainLevel(): Int
fun avcHigh10Level(): Int
fun supportsHevc(): Boolean
fun supportsHevcMain10(): Boolean
fun supportsHevcDolbyVision(): Boolean
fun supportsHevcDolbyVisionEl(): Boolean
fun supportsHevcHdr10(): Boolean
fun supportsHevcHdr10Plus(): Boolean
fun hevcMainLevel(): Int
fun hevcMain10Level(): Int
fun supportsVc1(): Boolean
fun maxResolution(mimeType: String): ProfileResolution
fun supportsVideoCodec(codec: String): Boolean
fun supportsAudioCodec(codec: String): Boolean
fun videoCodecProfiles(codec: String): Set<String>
val hevcDoviHdr10PlusBug: Boolean
}
internal class AndroidDeviceProfileCapabilities : DeviceProfileCapabilities {
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
private val codecCatalog by lazy { AndroidCodecCatalog.create(mediaCodecList.codecInfos) }
private object DolbyVisionProfiles {
val profile7: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
CodecProfileLevel.DolbyVisionProfileDvheDtb
} else {
-1
}
}
}
private object Av1ProfileLevels {
val profileMain10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
CodecProfileLevel.AV1ProfileMain10
} else {
0x2
}
}
val profileMain10Hdr10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
CodecProfileLevel.AV1ProfileMain10HDR10
} else {
0x1000
}
}
val profileMain10Hdr10Plus: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
CodecProfileLevel.AV1ProfileMain10HDR10Plus
} else {
0x2000
}
}
val dolbyVisionProfile10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
CodecProfileLevel.DolbyVisionProfileDvav110
} else {
0x400
}
}
val level5: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
CodecProfileLevel.AV1Level5
} else {
0x1000
}
}
}
private val avcLevels = listOf(
CodecProfileLevel.AVCLevel1b to 9,
CodecProfileLevel.AVCLevel1 to 10,
CodecProfileLevel.AVCLevel11 to 11,
CodecProfileLevel.AVCLevel12 to 12,
CodecProfileLevel.AVCLevel13 to 13,
CodecProfileLevel.AVCLevel2 to 20,
CodecProfileLevel.AVCLevel21 to 21,
CodecProfileLevel.AVCLevel22 to 22,
CodecProfileLevel.AVCLevel3 to 30,
CodecProfileLevel.AVCLevel31 to 31,
CodecProfileLevel.AVCLevel32 to 32,
CodecProfileLevel.AVCLevel4 to 40,
CodecProfileLevel.AVCLevel41 to 41,
CodecProfileLevel.AVCLevel42 to 42,
CodecProfileLevel.AVCLevel5 to 50,
CodecProfileLevel.AVCLevel51 to 51,
CodecProfileLevel.AVCLevel52 to 52,
)
private val hevcLevels = listOf(
CodecProfileLevel.HEVCMainTierLevel1 to 30,
CodecProfileLevel.HEVCMainTierLevel2 to 60,
CodecProfileLevel.HEVCMainTierLevel21 to 63,
CodecProfileLevel.HEVCMainTierLevel3 to 90,
CodecProfileLevel.HEVCMainTierLevel31 to 93,
CodecProfileLevel.HEVCMainTierLevel4 to 120,
CodecProfileLevel.HEVCMainTierLevel41 to 123,
CodecProfileLevel.HEVCMainTierLevel5 to 150,
CodecProfileLevel.HEVCMainTierLevel51 to 153,
CodecProfileLevel.HEVCMainTierLevel52 to 156,
CodecProfileLevel.HEVCMainTierLevel6 to 180,
CodecProfileLevel.HEVCMainTierLevel61 to 183,
CodecProfileLevel.HEVCMainTierLevel62 to 186,
)
override fun supportsAv1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1)
override fun supportsAv1Main10(): Boolean = hasDecoder(
MimeTypes.VIDEO_AV1,
Av1ProfileLevels.profileMain10,
Av1ProfileLevels.level5,
)
override fun supportsAv1DolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
MimeTypes.VIDEO_DOLBY_VISION,
Av1ProfileLevels.dolbyVisionProfile10,
CodecProfileLevel.DolbyVisionLevelHd24,
)
override fun supportsAv1Hdr10(): Boolean = hasDecoder(
MimeTypes.VIDEO_AV1,
Av1ProfileLevels.profileMain10Hdr10,
Av1ProfileLevels.level5,
)
override fun supportsAv1Hdr10Plus(): Boolean = hasDecoder(
MimeTypes.VIDEO_AV1,
Av1ProfileLevels.profileMain10Hdr10Plus,
Av1ProfileLevels.level5,
)
override fun supportsAvc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)
override fun supportsAvcHigh10(): Boolean = hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AVC,
CodecProfileLevel.AVCProfileHigh10,
CodecProfileLevel.AVCLevel4,
)
override fun avcMainLevel(): Int = avcLevel(CodecProfileLevel.AVCProfileMain)
override fun avcHigh10Level(): Int = avcLevel(CodecProfileLevel.AVCProfileHigh10)
override fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC)
override fun supportsHevcMain10(): Boolean = hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10,
CodecProfileLevel.HEVCMainTierLevel4,
)
override fun supportsHevcDolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
override fun supportsHevcDolbyVisionEl(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
DolbyVisionProfiles.profile7,
CodecProfileLevel.DolbyVisionLevelHd24,
) &&
supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC)
override fun supportsHevcHdr10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10HDR10,
CodecProfileLevel.HEVCMainTierLevel4,
)
override fun supportsHevcHdr10Plus(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10HDR10Plus,
CodecProfileLevel.HEVCMainTierLevel4,
)
override fun hevcMainLevel(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain)
override fun hevcMain10Level(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain10)
override fun supportsVc1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_VC1)
override fun maxResolution(mimeType: String): ProfileResolution {
var maxWidth = 0
var maxHeight = 0
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val videoCapabilities = info.getCapabilitiesForType(mimeType).videoCapabilities ?: continue
val supportedWidth = videoCapabilities.supportedWidths?.upper ?: continue
val supportedHeight = videoCapabilities.supportedHeights?.upper ?: continue
maxWidth = maxOf(maxWidth, supportedWidth)
maxHeight = maxOf(maxHeight, supportedHeight)
} catch (_: IllegalArgumentException) {
// Codec not supported.
}
}
return ProfileResolution(maxWidth, maxHeight)
}
override fun supportsVideoCodec(codec: String): Boolean = codecCatalog.supportedVideoCodecs.contains(codec)
override fun supportsAudioCodec(codec: String): Boolean = codecCatalog.supportedAudioCodecs.contains(codec)
override fun videoCodecProfiles(codec: String): Set<String> = codecCatalog.videoCodecProfiles[codec].orEmpty()
override val hevcDoviHdr10PlusBug: Boolean = Build.MODEL in modelsWithDoviHdr10PlusBug
private fun avcLevel(profile: Int): Int {
val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile)
return avcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0
}
private fun hevcLevel(profile: Int): Int {
val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile)
return hevcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0
}
private fun decoderLevel(mimeType: String, profile: Int): Int {
var maxLevel = 0
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mimeType)
for (profileLevel in capabilities.profileLevels) {
if (profileLevel.profile == profile) {
maxLevel = maxOf(maxLevel, profileLevel.level)
}
}
} catch (_: IllegalArgumentException) {
// Codec not supported.
}
}
return maxLevel
}
private fun hasDecoder(mimeType: String, profile: Int, level: Int): Boolean {
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mimeType)
for (profileLevel in capabilities.profileLevels) {
if (profileLevel.profile != profile) continue
if (profileLevel.level >= level) return true
}
} catch (_: IllegalArgumentException) {
// Codec not supported.
}
}
return false
}
private fun hasCodecForMime(mimeType: String): Boolean {
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
if (info.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) {
return true
}
}
return false
}
private fun supportsMultiInstance(mimeType: String): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
if (!info.supportedTypes.contains(mimeType)) continue
if (info.getCapabilitiesForType(mimeType).maxSupportedInstances > 1) {
return true
}
} catch (_: IllegalArgumentException) {
// Codec not supported.
}
}
return false
}
}
private val modelsWithDoviHdr10PlusBug = setOf(
"AFTKRT",
"AFTKA",
"AFTKM",
)

View File

@@ -0,0 +1,213 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.CodecProfile
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.ContainerProfile
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.ProfileCondition
import org.jellyfin.sdk.model.api.ProfileConditionType
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodingProfile
internal data class JellyfinAndroidMobileProfileConfig(
val assDirectPlay: Boolean = false,
)
internal object JellyfinAndroidMobileDeviceProfile {
val fixedConfig = JellyfinAndroidMobileProfileConfig()
private const val profileName = "Purefin"
private const val maxStreamingBitrate = 120_000_000
private const val maxStaticBitrate = 100_000_000
private const val maxMusicTranscodingBitrate = 384_000
private val supportedContainerFormats = arrayOf(
"mp4", "fmp4", "webm", "mkv", "mp3", "ogg", "wav", "mpegts", "flv", "aac", "flac", "3gp",
)
private val availableVideoCodecs = arrayOf(
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
arrayOf("vp8", "vp9", "av1"),
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp8", "vp9"),
emptyArray(),
emptyArray(),
emptyArray(),
arrayOf("mpeg1video", "mpeg2video", "mpeg4", "h264", "hevc"),
arrayOf("mpeg4", "h264"),
emptyArray(),
emptyArray(),
arrayOf("h263", "mpeg4", "h264", "hevc"),
)
private val pcmCodecs = arrayOf(
"pcm_s8",
"pcm_s16be",
"pcm_s16le",
"pcm_s24le",
"pcm_s32le",
"pcm_f32le",
"pcm_alaw",
"pcm_mulaw",
)
private val availableAudioCodecs = arrayOf(
arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3", "opus"),
arrayOf("mp3", "aac", "ac3", "eac3"),
arrayOf("vorbis", "opus"),
arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts", "mlp", "truehd"),
arrayOf("mp3"),
arrayOf("vorbis", "opus", "flac"),
pcmCodecs,
arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "ac3", "eac3", "dts", "mlp", "truehd"),
arrayOf("mp3", "aac"),
arrayOf("aac"),
arrayOf("flac"),
arrayOf("3gpp", "aac", "flac"),
)
private val forcedAudioCodecs = setOf(*pcmCodecs, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd")
private val exoEmbeddedSubtitles = arrayOf("dvbsub", "pgssub", "srt", "subrip", "ttml")
private val exoExternalSubtitles = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt")
private val ssaSubtitles = arrayOf("ssa", "ass")
private val transcodingProfiles = listOf(
TranscodingProfile(
type = DlnaProfileType.VIDEO,
container = "ts",
videoCodec = "h264",
audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd",
protocol = MediaStreamProtocol.HLS,
conditions = emptyList(),
),
TranscodingProfile(
type = DlnaProfileType.VIDEO,
container = "mkv",
videoCodec = "h264",
audioCodec = availableAudioCodecs[supportedContainerFormats.indexOf("mkv")].joinToString(","),
protocol = MediaStreamProtocol.HLS,
conditions = emptyList(),
),
TranscodingProfile(
type = DlnaProfileType.AUDIO,
container = "mp3",
videoCodec = "",
audioCodec = "mp3",
protocol = MediaStreamProtocol.HTTP,
conditions = emptyList(),
),
)
fun create(
capabilities: DeviceProfileCapabilities,
config: JellyfinAndroidMobileProfileConfig = fixedConfig,
): DeviceProfile {
val containerProfiles = mutableListOf<ContainerProfile>()
val directPlayProfiles = mutableListOf<DirectPlayProfile>()
val codecProfiles = mutableListOf<CodecProfile>()
for (i in supportedContainerFormats.indices) {
val container = supportedContainerFormats[i]
val supportedVideoCodecs = availableVideoCodecs[i]
.filter(capabilities::supportsVideoCodec)
.toTypedArray()
val supportedAudioCodecs = availableAudioCodecs[i]
.filter { capabilities.supportsAudioCodec(it) || it in forcedAudioCodecs }
.toTypedArray()
if (supportedVideoCodecs.isNotEmpty()) {
containerProfiles += ContainerProfile(
type = DlnaProfileType.VIDEO,
container = container,
conditions = emptyList(),
)
directPlayProfiles += DirectPlayProfile(
type = DlnaProfileType.VIDEO,
container = container,
videoCodec = supportedVideoCodecs.joinToString(","),
audioCodec = supportedAudioCodecs.joinToString(","),
)
supportedVideoCodecs.mapNotNullTo(codecProfiles) { videoCodec ->
generateCodecProfile(container, videoCodec, capabilities.videoCodecProfiles(videoCodec))
}
}
if (supportedAudioCodecs.isNotEmpty()) {
containerProfiles += ContainerProfile(
type = DlnaProfileType.AUDIO,
container = container,
conditions = emptyList(),
)
directPlayProfiles += DirectPlayProfile(
type = DlnaProfileType.AUDIO,
container = container,
audioCodec = supportedAudioCodecs.joinToString(","),
)
}
}
val subtitleProfiles = when {
config.assDirectPlay -> subtitleProfiles(
embedded = exoEmbeddedSubtitles + ssaSubtitles,
external = exoExternalSubtitles + ssaSubtitles,
)
else -> subtitleProfiles(
embedded = exoEmbeddedSubtitles,
external = exoExternalSubtitles,
)
}
return DeviceProfile(
name = profileName,
directPlayProfiles = directPlayProfiles,
transcodingProfiles = transcodingProfiles,
containerProfiles = containerProfiles,
codecProfiles = codecProfiles,
subtitleProfiles = subtitleProfiles,
maxStreamingBitrate = maxStreamingBitrate,
maxStaticBitrate = maxStaticBitrate,
musicStreamingTranscodingBitrate = maxMusicTranscodingBitrate,
)
}
private fun generateCodecProfile(
container: String,
videoCodec: String,
profiles: Set<String>,
): CodecProfile? {
if (profiles.isEmpty()) return null
return CodecProfile(
type = CodecType.VIDEO,
container = container,
codec = videoCodec,
applyConditions = emptyList(),
conditions = listOf(
ProfileCondition(
condition = ProfileConditionType.EQUALS_ANY,
property = ProfileConditionValue.VIDEO_PROFILE,
value = profiles.joinToString("|"),
isRequired = false,
),
),
)
}
private fun subtitleProfiles(
embedded: Array<String>,
external: Array<String>,
): List<SubtitleProfile> = buildList {
embedded.forEach { format ->
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED))
}
external.forEach { format ->
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL))
}
}
}

View File

@@ -0,0 +1,544 @@
package hu.bbara.purefin.core.data.client
import androidx.media3.common.MimeTypes
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
internal data class JellyfinAndroidTvProfileConfig(
val maxBitrate: Int = 100_000_000,
val isAc3Enabled: Boolean = true,
val downMixAudio: Boolean = false,
val assDirectPlay: Boolean = false,
val pgsDirectPlay: Boolean = true,
)
internal object JellyfinAndroidTvDeviceProfile {
val fixedConfig = JellyfinAndroidTvProfileConfig()
private val extendedRangeTypesServerVersion = ServerVersion(10, 11, 0)
private val downmixSupportedAudioCodecs = arrayOf(
Codec.Audio.AAC,
Codec.Audio.MP2,
Codec.Audio.MP3,
)
private val supportedAudioCodecs = arrayOf(
Codec.Audio.AAC,
Codec.Audio.AAC_LATM,
Codec.Audio.AC3,
Codec.Audio.ALAC,
Codec.Audio.DCA,
Codec.Audio.DTS,
Codec.Audio.EAC3,
Codec.Audio.FLAC,
Codec.Audio.MLP,
Codec.Audio.MP2,
Codec.Audio.MP3,
Codec.Audio.OPUS,
Codec.Audio.PCM_ALAW,
Codec.Audio.PCM_MULAW,
Codec.Audio.PCM_S16LE,
Codec.Audio.PCM_S20LE,
Codec.Audio.PCM_S24LE,
Codec.Audio.TRUEHD,
Codec.Audio.VORBIS,
)
private val hlsMpegTsAudioCodecs = arrayOf(
Codec.Audio.AAC,
Codec.Audio.AC3,
Codec.Audio.EAC3,
Codec.Audio.MP3,
)
private val hlsFmp4AudioCodecs = arrayOf(
Codec.Audio.AAC,
Codec.Audio.AC3,
Codec.Audio.EAC3,
Codec.Audio.MP3,
Codec.Audio.ALAC,
Codec.Audio.FLAC,
Codec.Audio.OPUS,
Codec.Audio.DTS,
Codec.Audio.TRUEHD,
)
fun create(
capabilities: DeviceProfileCapabilities,
serverVersion: ServerVersion,
config: JellyfinAndroidTvProfileConfig = fixedConfig,
): DeviceProfile = buildDeviceProfile {
val allowedAudioCodecs = when {
config.downMixAudio -> downmixSupportedAudioCodecs
!config.isAc3Enabled -> supportedAudioCodecs
.filterNot { it == Codec.Audio.AC3 || it == Codec.Audio.EAC3 }
.toTypedArray()
else -> supportedAudioCodecs
}
val supportsHevc = capabilities.supportsHevc()
val supportsHevcMain10 = capabilities.supportsHevcMain10()
val hevcMainLevel = capabilities.hevcMainLevel()
val hevcMain10Level = capabilities.hevcMain10Level()
val supportsAvc = capabilities.supportsAvc()
val supportsAvcHigh10 = capabilities.supportsAvcHigh10()
val avcMainLevel = capabilities.avcMainLevel()
val avcHigh10Level = capabilities.avcHigh10Level()
val supportsAv1 = capabilities.supportsAv1()
val supportsAv1Main10 = capabilities.supportsAv1Main10()
val supportsVc1 = capabilities.supportsVc1()
val maxResolutionAvc = capabilities.maxResolution(MimeTypes.VIDEO_H264)
val maxResolutionHevc = capabilities.maxResolution(MimeTypes.VIDEO_H265)
val maxResolutionAv1 = capabilities.maxResolution(MimeTypes.VIDEO_AV1)
val maxResolutionVc1 = capabilities.maxResolution(MimeTypes.VIDEO_VC1)
val supportsAv1DolbyVision = capabilities.supportsAv1DolbyVision()
val supportsAv1Hdr10 = capabilities.supportsAv1Hdr10()
val supportsAv1Hdr10Plus = capabilities.supportsAv1Hdr10Plus()
val supportsHevcDolbyVision = capabilities.supportsHevcDolbyVision()
val supportsHevcDolbyVisionEl = capabilities.supportsHevcDolbyVisionEl()
val supportsHevcHdr10 = capabilities.supportsHevcHdr10()
val supportsHevcHdr10Plus = capabilities.supportsHevcHdr10Plus()
val supportsExtendedRangeTypes = serverVersion >= extendedRangeTypesServerVersion
name = "AndroidTV-Default"
maxStaticBitrate = config.maxBitrate
maxStreamingBitrate = config.maxBitrate
val hlsVideoCodecs = listOfNotNull(
if (supportsHevc) Codec.Video.HEVC else null,
Codec.Video.H264,
).toTypedArray()
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
videoCodec(*hlsVideoCodecs)
audioCodec(*hlsMpegTsAudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray())
copyTimestamps = false
enableSubtitlesInManifest = true
}
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.MP4
protocol = MediaStreamProtocol.HLS
videoCodec(*hlsVideoCodecs)
audioCodec(*hlsFmp4AudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray())
copyTimestamps = false
enableSubtitlesInManifest = true
}
transcodingProfile {
type = DlnaProfileType.AUDIO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
audioCodec(Codec.Audio.AAC)
}
directPlayProfile {
type = DlnaProfileType.VIDEO
container(
Codec.Container.ASF,
Codec.Container.HLS,
Codec.Container.M4V,
Codec.Container.MKV,
Codec.Container.MOV,
Codec.Container.MP4,
Codec.Container.OGM,
Codec.Container.OGV,
Codec.Container.TS,
Codec.Container.VOB,
Codec.Container.WEBM,
Codec.Container.WMV,
Codec.Container.XVID,
)
videoCodec(
Codec.Video.AV1,
Codec.Video.H264,
Codec.Video.HEVC,
Codec.Video.MPEG,
Codec.Video.MPEG2VIDEO,
Codec.Video.VC1,
Codec.Video.VP8,
Codec.Video.VP9,
)
audioCodec(*allowedAudioCodecs)
}
directPlayProfile {
type = DlnaProfileType.AUDIO
audioCodec(*allowedAudioCodecs)
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
when {
!supportsAvc -> ProfileConditionValue.VIDEO_PROFILE equals "none"
else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull(
"high",
"main",
"baseline",
"constrained baseline",
if (supportsAvcHigh10) "high 10" else null,
)
}
}
}
if (supportsAvc) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcMainLevel
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE inCollection listOf(
"high",
"main",
"baseline",
"constrained baseline",
)
}
}
}
if (supportsAvcHigh10) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcHigh10Level
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE equals "high 10"
}
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 12
}
applyConditions {
ProfileConditionValue.WIDTH greaterThanOrEquals 1200
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 4
}
applyConditions {
ProfileConditionValue.WIDTH greaterThanOrEquals 1900
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
when {
!supportsHevc -> ProfileConditionValue.VIDEO_PROFILE equals "none"
else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull(
"main",
if (supportsHevcMain10) "main 10" else null,
)
}
}
}
if (supportsHevc) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMainLevel
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE equals "main"
}
}
}
if (supportsHevcMain10) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMain10Level
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE equals "main 10"
}
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
conditions {
when {
!supportsAv1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
!supportsAv1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10"
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
}
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.VC1
conditions {
when {
!supportsVc1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
}
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAvc.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAvc.height
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionHevc.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionHevc.height
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAv1.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAv1.height
}
}
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.VC1
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionVc1.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionVc1.height
}
}
val unsupportedRangeTypesAv1 = buildSet {
addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes)
if (!supportsAv1DolbyVision) {
add(VideoRangeType.DOVI)
if (!supportsAv1Hdr10) add(VideoRangeType.DOVI_WITH_HDR10)
if (!supportsAv1Hdr10Plus) addRangeTypeIfSupported(
VideoRangeType.DOVI_WITH_HDR10_PLUS,
supportsExtendedRangeTypes,
)
}
if (!supportsAv1Hdr10Plus) {
add(VideoRangeType.HDR10_PLUS)
if (!supportsAv1Hdr10) add(VideoRangeType.HDR10)
}
}
val unsupportedRangeTypesHevc = buildSet {
addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes)
if (!supportsHevcDolbyVisionEl) {
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_EL, supportsExtendedRangeTypes)
if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) {
addRangeTypeIfSupported(
VideoRangeType.DOVI_WITH_ELHDR10_PLUS,
supportsExtendedRangeTypes,
)
}
if (!supportsHevcDolbyVision) {
add(VideoRangeType.DOVI)
if (!supportsHevcHdr10) add(VideoRangeType.DOVI_WITH_HDR10)
if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) {
addRangeTypeIfSupported(
VideoRangeType.DOVI_WITH_HDR10_PLUS,
supportsExtendedRangeTypes,
)
}
}
}
if (!supportsHevcHdr10Plus) {
add(VideoRangeType.HDR10_PLUS)
if (!supportsHevcHdr10) add(VideoRangeType.HDR10)
}
if (capabilities.hevcDoviHdr10PlusBug) {
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_HDR10_PLUS, supportsExtendedRangeTypes)
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_ELHDR10_PLUS, supportsExtendedRangeTypes)
}
}
if (unsupportedRangeTypesAv1.isNotEmpty()) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
conditions {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesAv1.joinToString("|") { it.serialName }
}
applyConditions {
ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesAv1.map { it.serialName }
}
}
}
if (unsupportedRangeTypesHevc.isNotEmpty()) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesHevc.joinToString("|") { it.serialName }
}
applyConditions {
ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesHevc.map { it.serialName }
}
}
}
codecProfile {
type = CodecType.VIDEO_AUDIO
conditions {
ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (config.downMixAudio) 2 else 8
}
}
subtitleProfile(Codec.Subtitle.VTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.WEBVTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.SRT, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.SUBRIP, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.TTML, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.DVBSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.DVDSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.IDX, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.PGS, embedded = config.pgsDirectPlay, encode = true)
subtitleProfile(Codec.Subtitle.PGSSUB, embedded = config.pgsDirectPlay, encode = true)
subtitleProfile(
Codec.Subtitle.ASS,
encode = true,
embedded = config.assDirectPlay,
external = config.assDirectPlay,
)
subtitleProfile(
Codec.Subtitle.SSA,
encode = true,
embedded = config.assDirectPlay,
external = config.assDirectPlay,
)
}
}
private fun MutableSet<VideoRangeType>.addRangeTypeIfSupported(
type: VideoRangeType,
supportedByServer: Boolean,
) {
if (supportedByServer) add(type)
}
private fun DeviceProfileBuilder.subtitleProfile(
format: String,
embedded: Boolean = false,
external: Boolean = false,
hls: Boolean = false,
encode: Boolean = false,
) {
if (embedded) subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
if (external) subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
if (hls) subtitleProfile(format, SubtitleDeliveryMethod.HLS)
if (encode) subtitleProfile(format, SubtitleDeliveryMethod.ENCODE)
}
private object Codec {
object Container {
const val ASF = "asf"
const val HLS = "hls"
const val M4V = "m4v"
const val MKV = "mkv"
const val MOV = "mov"
const val MP4 = "mp4"
const val OGM = "ogm"
const val OGV = "ogv"
const val TS = "ts"
const val VOB = "vob"
const val WEBM = "webm"
const val WMV = "wmv"
const val XVID = "xvid"
}
object Audio {
const val AAC = "aac"
const val AAC_LATM = "aac_latm"
const val AC3 = "ac3"
const val ALAC = "alac"
const val DCA = "dca"
const val DTS = "dts"
const val EAC3 = "eac3"
const val FLAC = "flac"
const val MLP = "mlp"
const val MP2 = "mp2"
const val MP3 = "mp3"
const val OPUS = "opus"
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S20LE = "pcm_s20le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val VORBIS = "vorbis"
}
object Video {
const val AV1 = "av1"
const val H264 = "h264"
const val HEVC = "hevc"
const val MPEG = "mpeg"
const val MPEG2VIDEO = "mpeg2video"
const val VC1 = "vc1"
const val VP8 = "vp8"
const val VP9 = "vp9"
}
object Subtitle {
const val ASS = "ass"
const val DVBSUB = "dvbsub"
const val DVDSUB = "dvdsub"
const val IDX = "idx"
const val PGS = "pgs"
const val PGSSUB = "pgssub"
const val SRT = "srt"
const val SSA = "ssa"
const val SUBRIP = "subrip"
const val TTML = "ttml"
const val VTT = "vtt"
const val WEBVTT = "webvtt"
}
}

View File

@@ -18,8 +18,10 @@ import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -27,7 +29,6 @@ import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
@@ -38,6 +39,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@@ -45,6 +47,7 @@ import javax.inject.Singleton
class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository,
private val playbackProfilePolicy: PlaybackProfilePolicy,
) {
private val jellyfin = createJellyfin {
context = applicationContext
@@ -52,6 +55,7 @@ class JellyfinApiClient @Inject constructor(
}
private val api = jellyfin.createApi()
private val serverVersionCache = ConcurrentHashMap<String, ServerVersion>()
private suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
@@ -269,12 +273,14 @@ class JellyfinApiClient @Inject constructor(
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) {
return@withContext emptyList()
}
val result = api.mediaInfoApi
.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
// TODO add supportedContainers
deviceProfile = null,
maxStreamingBitrate = 100_000_000,
),
@@ -283,21 +289,72 @@ class JellyfinApiClient @Inject constructor(
result.content.mediaSources
}
suspend fun getPlaybackDecision(mediaId: UUID): PlaybackDecision? = withContext(Dispatchers.IO) {
if (!ensureConfigured()) {
return@withContext null
}
val serverUrl = userSessionRepository.serverUrl.first().trim()
val serverVersion = getServerVersion(serverUrl)
val deviceProfile = playbackProfilePolicy.create(serverVersion)
val response = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
deviceProfile = deviceProfile,
enableDirectPlay = true,
enableDirectStream = true,
enableTranscoding = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
autoOpenLiveStream = false,
),
)
val playbackInfo = response.content
if (playbackInfo.errorCode != null) {
Log.w(TAG, "Playback info failed for $mediaId with ${playbackInfo.errorCode}")
return@withContext null
}
val decision = PlaybackDecisionResolver.resolve(
mediaSources = playbackInfo.mediaSources,
playSessionId = playbackInfo.playSessionId,
serverUrl = serverUrl,
directPlayUrl = { mediaSource ->
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
container = mediaSource.container,
mediaSourceId = mediaSource.id,
static = true,
tag = mediaSource.eTag,
playSessionId = playbackInfo.playSessionId,
liveStreamId = mediaSource.liveStreamId,
)
},
)
if (decision == null) {
Log.w(TAG, "No compatible playback path for $mediaId")
} else {
Log.d(TAG, "Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}")
}
decision
}
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
if (!ensureConfigured()) {
return@withContext null
}
// Check if transcoding is required based on the MediaSourceInfo from getMediaSources
val shouldTranscode = mediaSource.supportsTranscoding == true &&
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) {
// Use transcoding URL
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
"$baseUrl${mediaSource.transcodingUrl}"
} else {
// Use direct play URL
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
@@ -309,7 +366,11 @@ class JellyfinApiClient @Inject constructor(
url
}
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) {
suspend fun reportPlaybackStart(
itemId: UUID,
positionTicks: Long = 0L,
reportContext: PlaybackReportContext,
) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
@@ -318,15 +379,24 @@ class JellyfinApiClient @Inject constructor(
canSeek = true,
isPaused = false,
isMuted = false,
// TODO Send correct play method
playMethod = PlayMethod.DIRECT_PLAY,
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT
playbackOrder = PlaybackOrder.DEFAULT,
)
)
}
suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) {
suspend fun reportPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
reportContext: PlaybackReportContext,
) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
@@ -335,21 +405,52 @@ class JellyfinApiClient @Inject constructor(
canSeek = true,
isPaused = isPaused,
isMuted = false,
playMethod = PlayMethod.DIRECT_PLAY,
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT
playbackOrder = PlaybackOrder.DEFAULT,
)
)
}
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) {
suspend fun reportPlaybackStopped(
itemId: UUID,
positionTicks: Long,
reportContext: PlaybackReportContext,
) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemId,
positionTicks = positionTicks,
failed = false
mediaSourceId = reportContext.mediaSourceId,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
failed = false,
)
)
}
private suspend fun getServerVersion(serverUrl: String): ServerVersion {
serverVersionCache[serverUrl]?.let { return it }
val parsedVersion = runCatching {
val versionString = SystemApi(api).getPublicSystemInfo().content.version
versionString?.let(ServerVersion::fromString)
}.onFailure { error ->
Log.w(TAG, "Unable to fetch server version for $serverUrl", error)
}.getOrNull()
val resolvedVersion = parsedVersion ?: PlaybackProfileDefaults.fallbackServerVersion
serverVersionCache[serverUrl] = resolvedVersion
return resolvedVersion
}
private companion object {
private const val TAG = "JellyfinApiClient"
}
}

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
data class PlaybackDecision(
val url: String,
val mediaSource: MediaSourceInfo,
val reportContext: PlaybackReportContext
)
data class PlaybackReportContext(
val playMethod: PlayMethod,
val mediaSourceId: String?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val liveStreamId: String?,
val playSessionId: String?,
)

View File

@@ -0,0 +1,51 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
internal object PlaybackDecisionResolver {
fun resolve(
mediaSources: List<MediaSourceInfo>,
playSessionId: String?,
serverUrl: String,
directPlayUrl: (MediaSourceInfo) -> String,
): PlaybackDecision? {
val mediaSource = mediaSources.firstOrNull { it.protocol == MediaProtocol.FILE && !it.isRemote }
?: return null
val playMethod = when {
mediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
mediaSource.supportsDirectStream && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.DIRECT_STREAM
mediaSource.supportsTranscoding && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.TRANSCODE
else -> return null
}
val url = when (playMethod) {
PlayMethod.DIRECT_PLAY -> directPlayUrl(mediaSource)
PlayMethod.DIRECT_STREAM,
PlayMethod.TRANSCODE,
-> absolutePlaybackUrl(serverUrl, requireNotNull(mediaSource.transcodingUrl))
}
return PlaybackDecision(
url = url,
mediaSource = mediaSource,
reportContext = PlaybackReportContext(
playMethod = playMethod,
mediaSourceId = mediaSource.id,
audioStreamIndex = mediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex,
liveStreamId = mediaSource.liveStreamId,
playSessionId = playSessionId,
),
)
}
fun absolutePlaybackUrl(serverUrl: String, playbackUrl: String): String = when {
playbackUrl.startsWith("http://", ignoreCase = true) -> playbackUrl
playbackUrl.startsWith("https://", ignoreCase = true) -> playbackUrl
playbackUrl.startsWith("/") -> "${serverUrl.trimEnd('/')}$playbackUrl"
else -> "${serverUrl.trimEnd('/')}/${playbackUrl.trimStart('/')}"
}
}

View File

@@ -0,0 +1,26 @@
package hu.bbara.purefin.core.data.client
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PlaybackProfileModule {
@Provides
@Singleton
fun provideDeviceProfileCapabilities(): DeviceProfileCapabilities = AndroidDeviceProfileCapabilities()
@Provides
@Singleton
fun providePlaybackProfilePolicy(
family: PlaybackProfileFamily,
capabilities: DeviceProfileCapabilities,
): PlaybackProfilePolicy = when (family) {
PlaybackProfileFamily.MOBILE -> MobilePlaybackProfilePolicy(capabilities)
PlaybackProfileFamily.TV -> TvPlaybackProfilePolicy(capabilities)
}
}

View File

@@ -0,0 +1,34 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.DeviceProfile
enum class PlaybackProfileFamily {
MOBILE,
TV,
}
interface PlaybackProfilePolicy {
fun create(serverVersion: ServerVersion): DeviceProfile
}
internal object PlaybackProfileDefaults {
val fallbackServerVersion = ServerVersion(10, 10, 0)
}
internal class MobilePlaybackProfilePolicy(
private val capabilities: DeviceProfileCapabilities,
) : PlaybackProfilePolicy {
override fun create(serverVersion: ServerVersion): DeviceProfile =
JellyfinAndroidMobileDeviceProfile.create(capabilities = capabilities)
}
internal class TvPlaybackProfilePolicy(
private val capabilities: DeviceProfileCapabilities,
) : PlaybackProfilePolicy {
override fun create(serverVersion: ServerVersion): DeviceProfile =
JellyfinAndroidTvDeviceProfile.create(
capabilities = capabilities,
serverVersion = serverVersion,
)
}

View File

@@ -1,34 +1,371 @@
package hu.bbara.purefin.core.data.client
import androidx.media3.common.MimeTypes
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.ProfileCondition
import org.jellyfin.sdk.model.api.ProfileConditionType
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidDeviceProfileTest {
class JellyfinAndroidTvDeviceProfileTest {
@Test
fun `device profile includes explicit video transcoding support`() {
val profile = AndroidDeviceProfile.createDeviceProfile(
audioCodecs = listOf("aac", "ac3"),
videoCodecs = listOf("h264", "hevc"),
maxAudioChannels = 6
fun `official defaults build expected profile structure`() {
val profile = JellyfinAndroidTvDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(),
serverVersion = ServerVersion(10, 11, 0),
)
val transcodingProfile = profile.transcodingProfiles.single()
assertEquals("AndroidTV-Default", profile.name)
assertEquals(100_000_000, profile.maxStreamingBitrate)
assertEquals(100_000_000, profile.maxStaticBitrate)
assertEquals(3, profile.transcodingProfiles.size)
assertEquals(DlnaProfileType.VIDEO, transcodingProfile.type)
assertEquals("ts", transcodingProfile.container)
assertEquals("h264", transcodingProfile.videoCodec)
assertEquals("aac", transcodingProfile.audioCodec)
assertEquals(MediaStreamProtocol.HLS, transcodingProfile.protocol)
assertEquals(EncodingContext.STREAMING, transcodingProfile.context)
assertEquals("6", transcodingProfile.maxAudioChannels)
assertEquals(3, transcodingProfile.minSegments)
assertEquals(6, transcodingProfile.segmentLength)
assertFalse(transcodingProfile.breakOnNonKeyFrames)
assertTrue(transcodingProfile.copyTimestamps)
val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO }
assertEquals(setOf("mp4", "ts"), videoProfiles.map { it.container }.toSet())
assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS })
assertTrue(videoProfiles.all { it.enableSubtitlesInManifest })
val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO }
assertEquals("ts", audioProfile.container)
assertEquals("aac", audioProfile.audioCodec)
val videoDirectPlay = profile.directPlayProfiles.single { it.type == DlnaProfileType.VIDEO }
assertTrue(videoDirectPlay.videoCodec.orEmpty().split(",").contains("vc1"))
assertTrue(videoDirectPlay.audioCodec.orEmpty().split(",").contains("ac3"))
assertTrue(
profile.subtitleProfiles.any {
it.format == "pgs" && it.method == SubtitleDeliveryMethod.EMBED
}
)
assertTrue(
profile.subtitleProfiles.any {
it.format == "webvtt" && it.method == SubtitleDeliveryMethod.HLS
}
)
}
@Test
fun `ac3 disabled removes ac3 and eac3 from advertised codecs`() {
val profile = JellyfinAndroidTvDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(),
serverVersion = ServerVersion(10, 11, 0),
config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(isAc3Enabled = false),
)
val directAudioCodecs = profile.directPlayProfiles
.single { it.type == DlnaProfileType.AUDIO }
.audioCodec
.orEmpty()
.split(",")
val transcodingAudioCodecs = profile.transcodingProfiles
.filter { it.type == DlnaProfileType.VIDEO }
.flatMap { it.audioCodec.orEmpty().split(",") }
assertFalse(directAudioCodecs.contains("ac3"))
assertFalse(directAudioCodecs.contains("eac3"))
assertFalse(transcodingAudioCodecs.contains("ac3"))
assertFalse(transcodingAudioCodecs.contains("eac3"))
}
@Test
fun `downmix mode limits audio codecs and channel profile`() {
val profile = JellyfinAndroidTvDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(),
serverVersion = ServerVersion(10, 11, 0),
config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(downMixAudio = true),
)
val directAudioCodecs = profile.directPlayProfiles
.single { it.type == DlnaProfileType.AUDIO }
.audioCodec
.orEmpty()
.split(",")
assertEquals(listOf("aac", "mp2", "mp3"), directAudioCodecs)
val audioChannelProfile = profile.codecProfiles.single { it.type == CodecType.VIDEO_AUDIO }
assertEquals("2", audioChannelProfile.conditions.single().value)
}
@Test
fun `older servers omit extended dolby vision range types`() {
val capabilities = FakeDeviceProfileCapabilities(
supportsAv1DolbyVision = false,
supportsAv1Hdr10 = false,
supportsAv1Hdr10Plus = false,
supportsHevcDolbyVision = false,
supportsHevcDolbyVisionEl = false,
supportsHevcHdr10 = false,
supportsHevcHdr10Plus = false,
)
val olderServerProfile = JellyfinAndroidTvDeviceProfile.create(
capabilities = capabilities,
serverVersion = ServerVersion(10, 10, 0),
)
val newerServerProfile = JellyfinAndroidTvDeviceProfile.create(
capabilities = capabilities,
serverVersion = ServerVersion(10, 11, 0),
)
val olderRangeTypes = codecRangeTypes(olderServerProfile.codecProfiles)
val newerRangeTypes = codecRangeTypes(newerServerProfile.codecProfiles)
assertFalse(olderRangeTypes.contains("DOVIWithEL"))
assertFalse(olderRangeTypes.contains("DOVIWithHDR10Plus"))
assertFalse(olderRangeTypes.contains("DOVIWithELHDR10Plus"))
assertFalse(olderRangeTypes.contains("DOVIInvalid"))
assertTrue(newerRangeTypes.contains("DOVIWithEL"))
assertTrue(newerRangeTypes.contains("DOVIWithHDR10Plus"))
assertTrue(newerRangeTypes.contains("DOVIWithELHDR10Plus"))
assertTrue(newerRangeTypes.contains("DOVIInvalid"))
}
@Test
fun `h264 high 10 is advertised as high 10`() {
val profile = JellyfinAndroidTvDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(supportsAvcHigh10 = true),
serverVersion = ServerVersion(10, 11, 0),
)
val h264Values = profile.codecProfiles
.filter { it.codec == "h264" }
.flatMap { it.conditions + it.applyConditions }
.mapNotNull(ProfileCondition::value)
assertTrue(h264Values.any { "high 10" in it })
assertFalse(h264Values.any { "main 10" in it })
}
@Test
fun `vc1 support condition changes with codec support`() {
val unsupportedProfile = JellyfinAndroidTvDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(supportsVc1 = false),
serverVersion = ServerVersion(10, 11, 0),
)
val supportedProfile = JellyfinAndroidTvDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(supportsVc1 = true),
serverVersion = ServerVersion(10, 11, 0),
)
val unsupportedCondition = vc1SupportCondition(unsupportedProfile.codecProfiles)
val supportedCondition = vc1SupportCondition(supportedProfile.codecProfiles)
assertEquals(ProfileConditionType.EQUALS, unsupportedCondition.condition)
assertEquals("none", unsupportedCondition.value)
assertEquals(ProfileConditionType.NOT_EQUALS, supportedCondition.condition)
assertEquals("none", supportedCondition.value)
}
}
class JellyfinAndroidMobileDeviceProfileTest {
@Test
fun `official mobile defaults build expected profile structure`() {
val profile = JellyfinAndroidMobileDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(),
)
assertEquals("Purefin", profile.name)
assertEquals(120_000_000, profile.maxStreamingBitrate)
assertEquals(100_000_000, profile.maxStaticBitrate)
assertEquals(384_000, profile.musicStreamingTranscodingBitrate)
val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO }
assertEquals(setOf("mkv", "ts"), videoProfiles.map { it.container }.toSet())
assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS })
val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO }
assertEquals("mp3", audioProfile.container)
assertEquals(MediaStreamProtocol.HTTP, audioProfile.protocol)
assertEquals("mp3", audioProfile.audioCodec)
assertTrue(
profile.directPlayProfiles.any {
it.type == DlnaProfileType.VIDEO && it.container == "mp4"
}
)
assertTrue(
profile.subtitleProfiles.any {
it.format == "pgssub" && it.method == SubtitleDeliveryMethod.EMBED
}
)
assertFalse(
profile.subtitleProfiles.any {
it.format == "ass"
}
)
}
@Test
fun `mobile profile advertises forced audio codecs even without codec support`() {
val profile = JellyfinAndroidMobileDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(
supportedAudioCodecs = emptySet(),
supportedVideoCodecs = setOf("h264"),
videoProfiles = mapOf("h264" to setOf("high")),
),
)
val mkvAudioProfile = profile.directPlayProfiles.single {
it.type == DlnaProfileType.AUDIO && it.container == "mkv"
}
val codecs = mkvAudioProfile.audioCodec.orEmpty().split(",")
assertTrue(codecs.contains("aac"))
assertTrue(codecs.contains("ac3"))
assertTrue(codecs.contains("truehd"))
assertTrue(codecs.contains("pcm_s16le"))
}
@Test
fun `mobile ass direct play opt in adds ass and ssa subtitles`() {
val profile = JellyfinAndroidMobileDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(),
config = JellyfinAndroidMobileDeviceProfile.fixedConfig.copy(assDirectPlay = true),
)
assertTrue(
profile.subtitleProfiles.any {
it.format == "ass" && it.method == SubtitleDeliveryMethod.EMBED
}
)
assertTrue(
profile.subtitleProfiles.any {
it.format == "ssa" && it.method == SubtitleDeliveryMethod.EXTERNAL
}
)
}
@Test
fun `mobile profile omits tv hdr range conditions`() {
val profile = JellyfinAndroidMobileDeviceProfile.create(
capabilities = FakeDeviceProfileCapabilities(),
)
assertFalse(
profile.codecProfiles.any { codecProfile ->
(codecProfile.conditions + codecProfile.applyConditions).any {
it.property == ProfileConditionValue.VIDEO_RANGE_TYPE
}
}
)
}
}
private fun codecRangeTypes(codecProfiles: List<org.jellyfin.sdk.model.api.CodecProfile>): Set<String> =
codecProfiles
.filter { it.propertyValuesContain(ProfileConditionValue.VIDEO_RANGE_TYPE) }
.flatMap { (it.conditions + it.applyConditions).mapNotNull(ProfileCondition::value) }
.flatMap { it.split("|") }
.toSet()
private fun vc1SupportCondition(codecProfiles: List<org.jellyfin.sdk.model.api.CodecProfile>): ProfileCondition =
codecProfiles
.single {
it.codec == "vc1" && it.conditions.any { condition ->
condition.property == ProfileConditionValue.VIDEO_PROFILE
}
}
.conditions
.single()
private fun org.jellyfin.sdk.model.api.CodecProfile.propertyValuesContain(value: ProfileConditionValue): Boolean =
(conditions + applyConditions).any { it.property == value }
private data class FakeDeviceProfileCapabilities(
val supportsAv1: Boolean = true,
val supportsAv1Main10: Boolean = true,
val supportsAv1DolbyVision: Boolean = true,
val supportsAv1Hdr10: Boolean = true,
val supportsAv1Hdr10Plus: Boolean = true,
val supportsAvc: Boolean = true,
val supportsAvcHigh10: Boolean = false,
val avcMainLevel: Int = 41,
val avcHigh10Level: Int = 41,
val supportsHevc: Boolean = true,
val supportsHevcMain10: Boolean = true,
val supportsHevcDolbyVision: Boolean = true,
val supportsHevcDolbyVisionEl: Boolean = true,
val supportsHevcHdr10: Boolean = true,
val supportsHevcHdr10Plus: Boolean = true,
val hevcMainLevel: Int = 123,
val hevcMain10Level: Int = 123,
val supportsVc1: Boolean = true,
val maxResolutionAvc: ProfileResolution = ProfileResolution(3840, 2160),
val maxResolutionHevc: ProfileResolution = ProfileResolution(3840, 2160),
val maxResolutionAv1: ProfileResolution = ProfileResolution(3840, 2160),
val maxResolutionVc1: ProfileResolution = ProfileResolution(1920, 1080),
val supportedVideoCodecs: Set<String> = setOf(
"h263",
"mpeg2video",
"mpeg4",
"h264",
"hevc",
"vp8",
"vp9",
"av1",
),
val supportedAudioCodecs: Set<String> = setOf(
"aac",
"ac3",
"eac3",
"flac",
"mp3",
"opus",
"vorbis",
"3gpp",
),
val videoProfiles: Map<String, Set<String>> = mapOf(
"h263" to setOf("baseline"),
"mpeg2video" to setOf("main profile"),
"mpeg4" to setOf("simple profile"),
"h264" to setOf("high", "main", "baseline"),
"hevc" to setOf("Main", "Main 10"),
"vp8" to setOf("main"),
"vp9" to setOf("Profile 0"),
),
override val hevcDoviHdr10PlusBug: Boolean = false,
) : DeviceProfileCapabilities {
override fun supportsAv1(): Boolean = supportsAv1
override fun supportsAv1Main10(): Boolean = supportsAv1Main10
override fun supportsAv1DolbyVision(): Boolean = supportsAv1DolbyVision
override fun supportsAv1Hdr10(): Boolean = supportsAv1Hdr10
override fun supportsAv1Hdr10Plus(): Boolean = supportsAv1Hdr10Plus
override fun supportsAvc(): Boolean = supportsAvc
override fun supportsAvcHigh10(): Boolean = supportsAvcHigh10
override fun avcMainLevel(): Int = avcMainLevel
override fun avcHigh10Level(): Int = avcHigh10Level
override fun supportsHevc(): Boolean = supportsHevc
override fun supportsHevcMain10(): Boolean = supportsHevcMain10
override fun supportsHevcDolbyVision(): Boolean = supportsHevcDolbyVision
override fun supportsHevcDolbyVisionEl(): Boolean = supportsHevcDolbyVisionEl
override fun supportsHevcHdr10(): Boolean = supportsHevcHdr10
override fun supportsHevcHdr10Plus(): Boolean = supportsHevcHdr10Plus
override fun hevcMainLevel(): Int = hevcMainLevel
override fun hevcMain10Level(): Int = hevcMain10Level
override fun supportsVc1(): Boolean = supportsVc1
override fun maxResolution(mimeType: String): ProfileResolution = when (mimeType) {
MimeTypes.VIDEO_H264 -> maxResolutionAvc
MimeTypes.VIDEO_H265 -> maxResolutionHevc
MimeTypes.VIDEO_AV1 -> maxResolutionAv1
MimeTypes.VIDEO_VC1 -> maxResolutionVc1
else -> ProfileResolution(0, 0)
}
override fun supportsVideoCodec(codec: String): Boolean = supportedVideoCodecs.contains(codec)
override fun supportsAudioCodec(codec: String): Boolean = supportedAudioCodecs.contains(codec)
override fun videoCodecProfiles(codec: String): Set<String> = videoProfiles[codec].orEmpty()
}

View File

@@ -1,29 +1,151 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaSourceType
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.PlayMethod
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class JellyfinApiClientTest {
class PlaybackDecisionResolverTest {
@Test
fun `direct play requests do not apply HLS stability options`() {
val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY)
fun `resolve prefers local file-backed source for direct play`() {
val remoteSource = mediaSource(
id = "remote",
protocol = MediaProtocol.HTTP,
isRemote = true,
supportsDirectPlay = true,
)
val localSource = mediaSource(
id = "local",
supportsDirectPlay = true,
defaultAudioStreamIndex = 2,
defaultSubtitleStreamIndex = 4,
liveStreamId = "live-1",
)
assertNull(options)
val decision = requireNotNull(
PlaybackDecisionResolver.resolve(
mediaSources = listOf(remoteSource, localSource),
playSessionId = "session-1",
serverUrl = "https://example.com/jellyfin",
) { mediaSource ->
"direct://${mediaSource.id}"
}
)
assertSame(localSource, decision.mediaSource)
assertEquals("direct://local", decision.url)
assertEquals(PlayMethod.DIRECT_PLAY, decision.reportContext.playMethod)
assertEquals("local", decision.reportContext.mediaSourceId)
assertEquals(2, decision.reportContext.audioStreamIndex)
assertEquals(4, decision.reportContext.subtitleStreamIndex)
assertEquals("live-1", decision.reportContext.liveStreamId)
assertEquals("session-1", decision.reportContext.playSessionId)
}
@Test
fun `direct stream requests use conservative HLS stability options`() {
val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM)
fun `resolve uses transcoding url for direct stream`() {
val source = mediaSource(
id = "direct-stream",
supportsDirectStream = true,
transcodingUrl = "/Videos/1/master.m3u8",
)
assertEquals(HlsPlaybackStability.conservativeHlsOptions, options)
val decision = requireNotNull(
PlaybackDecisionResolver.resolve(
mediaSources = listOf(source),
playSessionId = "session-2",
serverUrl = "https://example.com/jellyfin",
) { error("direct play should not be used") }
)
assertEquals(PlayMethod.DIRECT_STREAM, decision.reportContext.playMethod)
assertEquals("https://example.com/jellyfin/Videos/1/master.m3u8", decision.url)
}
@Test
fun `transcode requests use the same HLS stability options as direct stream`() {
val directStreamOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM)
val transcodeOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE)
fun `resolve uses transcoding when direct stream is unavailable`() {
val source = mediaSource(
id = "transcode",
supportsTranscoding = true,
transcodingUrl = "Videos/2/master.m3u8",
)
assertEquals(directStreamOptions, transcodeOptions)
val decision = requireNotNull(
PlaybackDecisionResolver.resolve(
mediaSources = listOf(source),
playSessionId = "session-3",
serverUrl = "https://example.com/jellyfin",
) { error("direct play should not be used") }
)
assertEquals(PlayMethod.TRANSCODE, decision.reportContext.playMethod)
assertEquals("https://example.com/jellyfin/Videos/2/master.m3u8", decision.url)
}
@Test
fun `resolve returns null when no usable source exists`() {
val source = mediaSource(id = "unusable")
val decision = PlaybackDecisionResolver.resolve(
mediaSources = listOf(source),
playSessionId = "session-4",
serverUrl = "https://example.com/jellyfin",
) { error("direct play should not be used") }
assertNull(decision)
}
@Test
fun `absolute playback url preserves absolute urls`() {
val url = PlaybackDecisionResolver.absolutePlaybackUrl(
serverUrl = "https://example.com/jellyfin",
playbackUrl = "https://cdn.example.com/master.m3u8",
)
assertEquals("https://cdn.example.com/master.m3u8", url)
}
private fun mediaSource(
id: String,
protocol: MediaProtocol = MediaProtocol.FILE,
isRemote: Boolean = false,
supportsDirectPlay: Boolean = false,
supportsDirectStream: Boolean = false,
supportsTranscoding: Boolean = false,
transcodingUrl: String? = null,
defaultAudioStreamIndex: Int? = null,
defaultSubtitleStreamIndex: Int? = null,
liveStreamId: String? = null,
): MediaSourceInfo {
return MediaSourceInfo(
protocol = protocol,
id = id,
type = MediaSourceType.DEFAULT,
container = "mkv",
isRemote = isRemote,
readAtNativeFramerate = true,
ignoreDts = false,
ignoreIndex = false,
genPtsInput = false,
supportsTranscoding = supportsTranscoding,
supportsDirectStream = supportsDirectStream,
supportsDirectPlay = supportsDirectPlay,
isInfiniteStream = false,
requiresOpening = false,
requiresClosing = false,
liveStreamId = liveStreamId,
requiresLooping = false,
supportsProbing = true,
transcodingUrl = transcodingUrl,
defaultAudioStreamIndex = defaultAudioStreamIndex,
defaultSubtitleStreamIndex = defaultSubtitleStreamIndex,
transcodingSubProtocol = MediaStreamProtocol.HLS,
hasSegments = false,
)
}
}

View File

@@ -1,111 +0,0 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaSourceType
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.PlayMethod
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackSourcePlannerTest {
@Test
fun `transcode-only source is selected for playback`() {
val mediaSource = mediaSource(id = "transcode-only", supportsTranscoding = true)
val plan = requireNotNull(
PlaybackSourcePlanner.plan(
mediaSources = listOf(mediaSource),
forceTranscode = false
)
)
assertSame(mediaSource, plan.mediaSource)
assertEquals(PlaybackSourcePlanner.UrlStrategy.TRANSCODE, plan.urlStrategy)
assertEquals(PlayMethod.TRANSCODE, plan.playMethod)
assertFalse(plan.canRetryWithTranscoding)
assertFalse(plan.usedFallback)
}
@Test
fun `first source falls back to direct stream when Jellyfin flags are inconsistent`() {
val mediaSource = mediaSource(id = "flagless")
val plan = requireNotNull(
PlaybackSourcePlanner.plan(
mediaSources = listOf(mediaSource),
forceTranscode = false
)
)
assertSame(mediaSource, plan.mediaSource)
assertEquals(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM, plan.urlStrategy)
assertEquals(PlayMethod.DIRECT_STREAM, plan.playMethod)
assertFalse(plan.canRetryWithTranscoding)
assertTrue(plan.usedFallback)
}
@Test
fun `retry with transcoding stays enabled when another source can transcode`() {
val directPlaySource = mediaSource(id = "direct-play", supportsDirectPlay = true)
val transcodeSource = mediaSource(id = "transcode", supportsTranscoding = true)
val plan = requireNotNull(
PlaybackSourcePlanner.plan(
mediaSources = listOf(directPlaySource, transcodeSource),
forceTranscode = false
)
)
assertSame(directPlaySource, plan.mediaSource)
assertEquals(PlayMethod.DIRECT_PLAY, plan.playMethod)
assertTrue(plan.canRetryWithTranscoding)
assertFalse(plan.usedFallback)
}
@Test
fun `force transcode does not fall back to unusable source`() {
val mediaSource = mediaSource(id = "flagless")
val plan = PlaybackSourcePlanner.plan(
mediaSources = listOf(mediaSource),
forceTranscode = true
)
assertNull(plan)
}
private fun mediaSource(
id: String,
supportsDirectPlay: Boolean = false,
supportsDirectStream: Boolean = false,
supportsTranscoding: Boolean = false,
container: String? = "mkv"
): MediaSourceInfo {
return MediaSourceInfo(
protocol = MediaProtocol.FILE,
id = id,
type = MediaSourceType.DEFAULT,
container = container,
isRemote = false,
readAtNativeFramerate = true,
ignoreDts = false,
ignoreIndex = false,
genPtsInput = false,
supportsTranscoding = supportsTranscoding,
supportsDirectStream = supportsDirectStream,
supportsDirectPlay = supportsDirectPlay,
isInfiniteStream = false,
requiresOpening = false,
requiresClosing = false,
requiresLooping = false,
supportsProbing = true,
transcodingSubProtocol = MediaStreamProtocol.HTTP,
hasSegments = false
)
}
}

View File

@@ -6,6 +6,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
@@ -24,26 +25,21 @@ class PlayerMediaRepository @Inject constructor(
) {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource
) ?: return@withContext null
val playbackDecision = jellyfinApiClient.getPlaybackDecision(mediaId) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
// Calculate resume position
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
title = baseItem?.name ?: selectedMediaSource.name!!,
subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}",
playbackUrl = playbackDecision.url,
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
)
Pair(mediaItem, resumePositionMs)
@@ -59,19 +55,15 @@ class PlayerMediaRepository @Inject constructor(
if (existingIds.contains(stringId)) {
return@mapNotNull null
}
val mediaSources = jellyfinApiClient.getMediaSources(id)
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = id,
mediaSource = selectedMediaSource
) ?: return@mapNotNull null
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
createMediaItem(
mediaId = stringId,
playbackUrl = playbackUrl,
title = episode.name ?: selectedMediaSource.name!!,
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
playbackUrl = playbackDecision.url,
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
subtitle = seasonEpisodeLabel(episode),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
)
}
}.getOrElse { error ->
@@ -86,6 +78,7 @@ class PlayerMediaRepository @Inject constructor(
title: String,
subtitle: String?,
artworkUrl: String,
playbackReportContext: PlaybackReportContext?,
): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
@@ -96,6 +89,7 @@ class PlayerMediaRepository @Inject constructor(
.setUri(playbackUrl.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setTag(playbackReportContext)
.build()
}
@@ -122,4 +116,10 @@ class PlayerMediaRepository @Inject constructor(
// Apply thresholds: resume only if 5% ≤ progress ≤ 95%
return if (percentage in 5.0..95.0) positionMs else null
}
private fun seasonEpisodeLabel(item: BaseItemDto?): String? {
val seasonNumber = item?.parentIndexNumber ?: return null
val episodeNumber = item.indexNumber ?: return null
return "S$seasonNumber:E$episodeNumber"
}
}

View File

@@ -9,6 +9,7 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
@@ -311,10 +312,12 @@ class PlayerManager @Inject constructor(
}
private fun refreshMetadata(mediaItem: MediaItem?) {
val playbackReportContext = mediaItem?.localConfiguration?.tag as? PlaybackReportContext
_metadata.value = MetadataState(
mediaId = mediaItem?.mediaId,
title = mediaItem?.mediaMetadata?.title?.toString(),
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(),
playbackReportContext = playbackReportContext,
)
}
@@ -341,6 +344,7 @@ data class MetadataState(
val mediaId: String? = null,
val title: String? = null,
val subtitle: String? = null,
val playbackReportContext: PlaybackReportContext? = null,
)
data class MediaContext(

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.core.player.manager
import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -25,6 +26,7 @@ class ProgressManager @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null
private var activeItemId: UUID? = null
private var activePlaybackReportContext: PlaybackReportContext? = null
private var lastPositionMs: Long = 0L
private var lastDurationMs: Long = 0L
private var isPaused: Boolean = false
@@ -42,27 +44,29 @@ class ProgressManager @Inject constructor(
lastDurationMs = prog.durationMs
isPaused = !state.isPlaying
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val nextPlaybackReportContext = meta.playbackReportContext
// Media changed or ended - stop session
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
stopSession()
}
// Start session when we have a media item and none is active
if (activeItemId == null && mediaId != null && !state.isEnded) {
startSession(mediaId, prog.positionMs)
startSession(mediaId, prog.positionMs, nextPlaybackReportContext)
} else if (activeItemId == mediaId) {
activePlaybackReportContext = nextPlaybackReportContext
}
}
}
}
private fun startSession(itemId: UUID, positionMs: Long) {
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
activeItemId = itemId
report(itemId, positionMs, isStart = true)
activePlaybackReportContext = reportContext
report(itemId, positionMs, reportContext = reportContext, isStart = true)
progressJob = scope.launch {
while (isActive) {
delay(5000)
report(itemId, lastPositionMs, isPaused = isPaused)
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
}
}
}
@@ -70,7 +74,7 @@ class ProgressManager @Inject constructor(
private fun stopSession() {
progressJob?.cancel()
activeItemId?.let { itemId ->
report(itemId, lastPositionMs, isStop = true)
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
scope.launch(Dispatchers.IO) {
try {
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
@@ -80,11 +84,13 @@ class ProgressManager @Inject constructor(
}
}
activeItemId = null
activePlaybackReportContext = null
}
private fun report(
itemId: UUID,
positionMs: Long,
reportContext: PlaybackReportContext?,
isPaused: Boolean = false,
isStart: Boolean = false,
isStop: Boolean = false
@@ -92,10 +98,13 @@ class ProgressManager @Inject constructor(
val ticks = positionMs * 10_000
scope.launch(Dispatchers.IO) {
try {
if (reportContext == null) {
return@launch
}
when {
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
}
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
} catch (e: Exception) {
@@ -112,7 +121,9 @@ class ProgressManager @Inject constructor(
val durMs = lastDurationMs
CoroutineScope(Dispatchers.IO).launch {
try {
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
activePlaybackReportContext?.let { reportContext ->
jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
}
updateWatchProgressUseCase(itemId, posMs, durMs)
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
} catch (e: Exception) {

View File

@@ -58,6 +58,7 @@ medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls"
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"}
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }