Add device profile & improvement playback reporting

This commit is contained in:
Damontecres 2025-09-23 19:45:48 -04:00
parent 3bf382a6b9
commit bd75539cb6
No known key found for this signature in database
19 changed files with 1056 additions and 117 deletions

View file

@ -27,6 +27,7 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.ServerLoginPage
import com.github.damontecres.dolphin.ui.nav.ApplicationContent
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.util.profile.createDeviceProfile
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.OkHttpClient
import javax.inject.Inject
@ -66,11 +67,16 @@ class MainActivity : AppCompatActivity() {
}
isRestoringSession = false
}
val deviceProfile =
remember {
createDeviceProfile(this@MainActivity, preferences, false)
}
val server = serverRepository.currentServer
val user = serverRepository.currentUser
if (server != null && user != null) {
ApplicationContent(
preferences = preferences,
deviceProfile = deviceProfile,
modifier = Modifier.fillMaxSize(),
)
} else if (isRestoringSession) {

View file

@ -61,6 +61,7 @@ class ServerRepository
apiClient.userApi
.getCurrentUser()
.content
val updatedServer = server.copy(name = userDto.serverName)
val updatedUser =
user.copy(

View file

@ -1,49 +0,0 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
@Composable
fun CardRow(
title: String,
items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = title,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth(),
) {
items(items) { item ->
ItemCard(
item = item,
onClick = { if (item != null) onClickItem.invoke(item) },
onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier = Modifier,
)
}
}
}
}

View file

@ -0,0 +1,84 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.util.FocusPair
@Composable
fun ItemRow(
title: String,
items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
) {
itemsIndexed(items) { index, item ->
val cardModifier =
if (index == 0 && focusPair == null) {
Modifier.focusRequester(firstFocus)
} else {
if (focusPair != null && focusPair.column == index) {
Modifier.focusRequester(focusPair.focusRequester)
} else {
Modifier
}
}.onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
cardOnFocus?.invoke(it.isFocused, index)
}
ItemCard(
item = item,
onClick = { if (item != null) onClickItem.invoke(item) },
onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier = cardModifier,
)
}
}
}
}

View file

@ -18,7 +18,9 @@ import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.extensions.ticks
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class EpisodeViewModel
@ -64,6 +66,25 @@ fun EpisodeDetails(
Text(text = it)
}
}
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
item {
Button(
onClick = {
navigationManager.navigateTo(
Destination.Playback(
item.id,
it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
}
}
}
}
item {
Button(
onClick = {

View file

@ -14,7 +14,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.CardRow
import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.DolphinPager
@ -83,7 +83,7 @@ fun SeasonDetails(
Text(text = item.name ?: "Unknown")
}
item {
CardRow(
ItemRow(
title = "Episodes",
items = episodes,
onClickItem = { navigationManager.navigateTo(it.destination()) },

View file

@ -14,7 +14,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.CardRow
import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.DolphinPager
@ -88,7 +88,7 @@ fun SeriesDetails(
Text(text = item.name ?: "Unknown")
}
item {
CardRow(
ItemRow(
title = "Seasons",
items = seasons,
onClickItem = { navigationManager.navigateTo(it.destination()) },

View file

@ -4,9 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -18,12 +16,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.isNotNullOrBlank
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.ItemCard
import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
@ -165,11 +162,14 @@ fun MainPage(
val homeRows by viewModel.homeRows.observeAsState(listOf())
Column(modifier = modifier) {
// TODO header?
LazyColumn {
homeRows.forEach { row ->
item {
HomePageRow(
row = row,
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp),
) {
items(homeRows) { row ->
ItemRow(
title = stringResource(row.section.nameRes),
items = row.items,
onClickItem = {
navigationManager.navigateTo(
Destination.MediaItem(
@ -184,39 +184,4 @@ fun MainPage(
}
}
}
}
}
@Composable
fun HomePageRow(
row: HomeRow,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = stringResource(row.section.nameRes),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth(),
) {
items(row.items) { item ->
ItemCard(
item = item,
onClick = { onClickItem.invoke(item) },
onLongClick = { onLongClickItem.invoke(item) },
modifier = Modifier,
)
}
}
}
}

View file

@ -10,10 +10,12 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.github.damontecres.dolphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.DeviceProfile
@Composable
fun ApplicationContent(
preferences: UserPreferences,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
) {
val backStack = rememberNavBackStack(Destination.Main)
@ -51,6 +53,7 @@ fun ApplicationContent(
destination = key,
preferences = preferences,
navigationManager = navigationManager,
deviceProfile = deviceProfile,
modifier = modifier.fillMaxSize(),
)
} else {
@ -58,6 +61,7 @@ fun ApplicationContent(
destination = key,
preferences = preferences,
navigationManager = navigationManager,
deviceProfile = deviceProfile,
modifier = modifier,
)
}

View file

@ -6,12 +6,14 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.detail.MediaItemContent
import com.github.damontecres.dolphin.ui.main.MainPage
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
import org.jellyfin.sdk.model.api.DeviceProfile
@Composable
fun DestinationContent(
destination: Destination,
preferences: UserPreferences,
navigationManager: NavigationManager,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
) {
when (destination) {
@ -35,6 +37,7 @@ fun DestinationContent(
PlaybackContent(
preferences = preferences,
navigationManager = navigationManager,
deviceProfile = deviceProfile,
destination = destination,
modifier = modifier,
)

View file

@ -53,6 +53,7 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile
import javax.inject.Inject
@HiltViewModel
@ -81,6 +82,7 @@ fun NavDrawer(
destination: Destination,
preferences: UserPreferences,
navigationManager: NavigationManager,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel = hiltViewModel(LocalView.current.findViewTreeViewModelStoreOwner()!!),
) {
@ -126,7 +128,7 @@ fun NavDrawer(
IconNavItem(
text = "Home",
icon = Icons.Default.Home,
onClick = {},
onClick = { navigationManager.goToHome() },
)
}
items(libraries) {
@ -158,6 +160,7 @@ fun NavDrawer(
destination = destination,
preferences = preferences,
navigationManager = navigationManager,
deviceProfile = deviceProfile,
modifier = Modifier.fillMaxSize(),
)
}

View file

@ -50,6 +50,7 @@ import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.stashapp.ui.components.playback.SkipIndicator
import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState
import org.jellyfin.sdk.model.api.DeviceProfile
import kotlin.time.Duration.Companion.milliseconds
@OptIn(UnstableApi::class)
@ -57,13 +58,14 @@ import kotlin.time.Duration.Companion.milliseconds
fun PlaybackContent(
preferences: UserPreferences,
navigationManager: NavigationManager,
deviceProfile: DeviceProfile,
destination: Destination.Playback,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
LaunchedEffect(destination.itemId) {
viewModel.init(destination.itemId, destination.item)
viewModel.init(destination, deviceProfile)
}
val player = viewModel.player
val stream by viewModel.stream.observeAsState(null)

View file

@ -5,9 +5,10 @@ import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.nav.Destination
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@ -15,9 +16,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
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.PlaybackStartInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import javax.inject.Inject
@ -69,9 +76,11 @@ class PlaybackViewModel
}
fun init(
itemId: UUID,
item: BaseItem?,
destination: Destination.Playback,
deviceProfile: DeviceProfile,
) {
val itemId = destination.itemId
val item = destination.item
if (item != null) {
title.value = item.name
val base = item.data
@ -99,7 +108,7 @@ class PlaybackViewModel
// TODO device profile, etc
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile = null,
deviceProfile = deviceProfile,
enableDirectStream = true,
enableDirectPlay = true,
maxAudioChannels = null,
@ -134,6 +143,23 @@ class PlaybackViewModel
else -> throw Exception("No supported playback method")
}
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
itemId = itemId,
canSeek = false,
isPaused = true,
isMuted = false,
playMethod =
when {
it.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
it.supportsDirectStream -> PlayMethod.DIRECT_STREAM
it.supportsTranscoding -> PlayMethod.TRANSCODE
else -> throw Exception("No supported playback method")
},
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
)
withContext(Dispatchers.Main) {
val activityListener =
TrackActivityPlaybackListener(api, itemId, player)
@ -141,7 +167,10 @@ class PlaybackViewModel
player.addListener(activityListener)
duration.value = source.runTimeTicks?.ticks
stream.value = decision
player.setMediaItem(decision.mediaItem)
player.setMediaItem(
decision.mediaItem,
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
)
player.prepare()
}
}

View file

@ -11,6 +11,7 @@ import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber
@ -73,6 +74,15 @@ class TrackActivityPlaybackListener(
fun release() {
task.cancel()
TIMER.purge()
coroutineScope.launch {
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemId,
positionTicks = player.currentPosition.milliseconds.inWholeTicks,
failed = false,
),
)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
@ -96,12 +106,14 @@ class TrackActivityPlaybackListener(
private fun saveActivity(position: Long) {
coroutineScope.launch {
val totalDuration = totalPlayDurationMilliseconds.get()
val calcPosition = (if (position >= 0) position else player.currentPosition).milliseconds.inWholeTicks
val calcPosition =
(if (position >= 0) position else player.currentPosition).milliseconds
Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition")
// TODO parameters
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = itemId,
positionTicks = calcPosition,
positionTicks = calcPosition.inWholeTicks,
canSeek = true,
isPaused = !player.isPlaying,
isMuted = false,

View file

@ -0,0 +1,9 @@
package com.github.damontecres.dolphin.util
import androidx.compose.ui.focus.FocusRequester
data class FocusPair(
val row: Int,
val column: Int,
val focusRequester: FocusRequester,
)

View file

@ -0,0 +1,89 @@
package com.github.damontecres.dolphin.util.profile
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/c775603df457862c495b010550ae0aee1a66c0bc/app/src/main/java/org/jellyfin/androidtv/constant/Codec.kt
object Codec {
object Container {
@Suppress("ObjectPropertyName", "ObjectPropertyNaming")
const val `3GP` = "3gp"
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val HLS = "hls"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
const val MOV = "mov"
const val MP3 = "mp3"
const val MP4 = "mp4"
const val MPEG = "mpeg"
const val MPEGTS = "mpegts"
const val MPG = "mpg"
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 WTV = "wtv"
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 APE = "ape"
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 MPA = "mpa"
const val OGA = "oga"
const val OGG = "ogg"
const val OPUS = "opus"
const val PCM = "pcm"
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"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"
const val WMAV2 = "wmav2"
}
object Video {
const val H264 = "h264"
const val HEVC = "hevc"
const val MPEG = "mpeg"
const val MPEG2VIDEO = "mpeg2video"
const val VP8 = "vp8"
const val VP9 = "vp9"
const val AV1 = "av1"
}
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 SMI = "smi"
const val SRT = "srt"
const val SSA = "ssa"
const val SUB = "sub"
const val SUBRIP = "subrip"
const val VTT = "vtt"
const val SMIL = "smil"
const val TTML = "ttml"
const val WEBVTT = "webvtt"
}
}

View file

@ -0,0 +1,408 @@
package com.github.damontecres.dolphin.util.profile
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/c775603df457862c495b010550ae0aee1a66c0bc/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt
import android.content.Context
import androidx.media3.common.MimeTypes
import com.github.damontecres.dolphin.preferences.UserPreferences
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.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
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,
)
// TODO use preferences
fun createDeviceProfile(
context: Context,
userPreferences: UserPreferences,
disableDirectPlay: Boolean = false,
) = createDeviceProfile(
mediaTest = MediaCodecCapabilitiesTest(context),
maxBitrate = 100_000_000,
disableDirectPlay = disableDirectPlay,
isAC3Enabled = true,
downMixAudio = false,
assDirectPlay = true,
pgsDirectPlay = true,
)
fun createDeviceProfile(
mediaTest: MediaCodecCapabilitiesTest,
maxBitrate: Int,
disableDirectPlay: Boolean,
isAC3Enabled: Boolean,
downMixAudio: Boolean,
assDirectPlay: Boolean,
pgsDirectPlay: Boolean,
) = buildDeviceProfile {
val allowedAudioCodecs =
when {
downMixAudio -> downmixSupportedAudioCodecs
!isAC3Enabled -> supportedAudioCodecs.filterNot { it == Codec.Audio.EAC3 || it == Codec.Audio.AC3 }.toTypedArray()
else -> supportedAudioCodecs
}
val supportsHevc = mediaTest.supportsHevc()
val supportsHevcMain10 = mediaTest.supportsHevcMain10()
val hevcMainLevel = mediaTest.getHevcMainLevel()
val hevcMain10Level = mediaTest.getHevcMain10Level()
val supportsAVC = mediaTest.supportsAVC()
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10()
val avcMainLevel = mediaTest.getAVCMainLevel()
val avcHigh10Level = mediaTest.getAVCHigh10Level()
val supportsAV1 = mediaTest.supportsAV1()
val supportsAV1Main10 = mediaTest.supportsAV1Main10()
val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264)
val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265)
val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1)
name = "AndroidTV-Default"
// / Bitrate
maxStaticBitrate = maxBitrate
maxStreamingBitrate = maxBitrate
// / Transcoding profiles
// Video
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
if (supportsHevc) videoCodec(Codec.Video.HEVC)
videoCodec(Codec.Video.H264)
audioCodec(*allowedAudioCodecs)
copyTimestamps = false
enableSubtitlesInManifest = true
}
// Audio
transcodingProfile {
type = DlnaProfileType.AUDIO
context = EncodingContext.STREAMING
container = Codec.Container.MP3
audioCodec(Codec.Audio.MP3)
}
// / Direct play profiles
if (!disableDirectPlay) {
// Video
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.VP8,
Codec.Video.VP9,
)
audioCodec(*allowedAudioCodecs)
}
// Audio
directPlayProfile {
type = DlnaProfileType.AUDIO
audioCodec(*allowedAudioCodecs)
}
}
// / Codec profiles
// H264 profile
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) "main 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"
}
}
}
// H264 ref frames profile
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 12
}
applyConditions {
ProfileConditionValue.WIDTH greaterThanOrEquals 1200
}
}
// H264 ref frames profile
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 4
}
applyConditions {
ProfileConditionValue.WIDTH greaterThanOrEquals 1900
}
}
// HEVC profiles
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"
}
}
}
// AV1 profile
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"
}
}
}
// Get max resolutions for common codecs
// AVC
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAVC.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAVC.height
}
}
// HEVC
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionHevc.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionHevc.height
}
}
// AV1
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height
}
}
// HDR
codecProfile {
type = CodecType.VIDEO
conditions {
if (!mediaTest.supportsDolbyVision()) ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
if (!mediaTest.supportsHdr10()) ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
if (!mediaTest.supportsHdr10Plus()) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10_PLUS.serialName
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_ELHDR10_PLUS.serialName
}
}
}.let {
// Remove codec profile if all HDR types are fully supported
if (it.conditions.isEmpty()) codecProfiles.remove(it)
}
// Audio channel profile
codecProfile {
type = CodecType.VIDEO_AUDIO
conditions {
ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (downMixAudio) 2 else 8
}
}
// / Subtitle profiles
// Jellyfin server only supports WebVTT subtitles in HLS, other text subtitles will be converted to WebVTT
// which we do not want so only allow delivery over HLS for WebVTT subtitles
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)
// Not all subtitles can be loaded standalone by the player
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 = pgsDirectPlay, encode = true)
subtitleProfile(Codec.Subtitle.PGSSUB, embedded = pgsDirectPlay, encode = true)
// ASS/SSA is supported via libass extension
subtitleProfile(Codec.Subtitle.ASS, encode = true, embedded = assDirectPlay, external = assDirectPlay)
subtitleProfile(Codec.Subtitle.SSA, encode = true, embedded = assDirectPlay, external = assDirectPlay)
}
// Little helper function to more easily define subtitle profiles
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)
}

View file

@ -0,0 +1,352 @@
package com.github.damontecres.dolphin.util.profile
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/c775603df457862c495b010550ae0aee1a66c0bc/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt
import android.content.Context
import android.media.MediaCodecInfo.CodecProfileLevel
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
import android.util.Size
import android.view.Display
import androidx.core.content.ContextCompat
import timber.log.Timber
class MediaCodecCapabilitiesTest(
private val context: Context,
) {
private val display by lazy { ContextCompat.getDisplayOrDefault(context) }
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
@Suppress("DEPRECATION")
private val supportedHdrTypes by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
display.mode.supportedHdrTypes.toList()
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
display.hdrCapabilities.supportedHdrTypes.toList()
} else {
emptyList()
}
}
// Map common Dolby Vision Profiles to their corresponding CodecProfileLevel constant
private object DolbyVisionProfiles {
val Profile5: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
CodecProfileLevel.DolbyVisionProfileDvheStn
} else {
-1
}
}
val Profile7: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
CodecProfileLevel.DolbyVisionProfileDvheDtb
} else {
-1
}
}
val Profile8: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
CodecProfileLevel.DolbyVisionProfileDvheSt
} else {
-1
}
}
val Profile10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
CodecProfileLevel.DolbyVisionProfileDvav110
} else {
-1
}
}
}
// AVC levels as reported by ffprobe are multiplied by 10, e.g. level 4.1 is 41. Level 1b is set to 9
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,
)
// HEVC levels as reported by ffprobe are multiplied by 30, e.g. level 4.1 is 123
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,
)
fun supportsAV1(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AV1)
fun supportsAV1Main10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10,
CodecProfileLevel.AV1Level5,
)
fun supportsAV1DolbyVision(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
DolbyVisionProfiles.Profile10,
CodecProfileLevel.DolbyVisionLevelHd24,
)
fun supportsAV1HDR10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10HDR10,
CodecProfileLevel.AV1Level5,
)
fun supportsAV1HDR10Plus(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10HDR10Plus,
CodecProfileLevel.AV1Level5,
)
fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)
fun supportsAVCHigh10(): Boolean =
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AVC,
CodecProfileLevel.AVCProfileHigh10,
CodecProfileLevel.AVCLevel4,
)
fun getAVCMainLevel(): Int =
getAVCLevel(
CodecProfileLevel.AVCProfileMain,
)
fun getAVCHigh10Level(): Int =
getAVCLevel(
CodecProfileLevel.AVCProfileHigh10,
)
private fun getAVCLevel(profile: Int): Int {
val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile)
return avcLevels
.asReversed()
.find { item ->
level >= item.first
}?.second ?: 0
}
fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC)
fun supportsHevcMain10(): Boolean =
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10,
CodecProfileLevel.HEVCMainTierLevel4,
)
// Can safely assume Dolby Vision decoders support single-layer HEVC profiles
fun supportsHevcDolbyVision(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
// Checks for Dolby Vision Profile 7 (Enhancement Layer) and multi-instance HEVC support
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)
fun supportsHevcHDR10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10HDR10,
CodecProfileLevel.HEVCMainTierLevel4,
)
fun supportsHevcHDR10Plus(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10HDR10Plus,
CodecProfileLevel.HEVCMainTierLevel4,
)
fun getHevcMainLevel(): Int =
getHevcLevel(
CodecProfileLevel.HEVCProfileMain,
)
fun getHevcMain10Level(): Int =
getHevcLevel(
CodecProfileLevel.HEVCProfileMain10,
)
private fun getHevcLevel(profile: Int): Int {
val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile)
return hevcLevels
.asReversed()
.find { item ->
level >= item.first
}?.second ?: 0
}
private fun getDecoderLevel(
mime: String,
profile: Int,
): Int {
var maxLevel = 0
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mime)
for (profileLevel in capabilities.profileLevels) {
if (profileLevel.profile == profile) {
maxLevel = maxOf(maxLevel, profileLevel.level)
}
}
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
return maxLevel
}
private fun hasDecoder(
mime: String,
profile: Int,
level: Int,
): Boolean {
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mime)
for (profileLevel in capabilities.profileLevels) {
if (profileLevel.profile != profile) continue
// H.263 levels are not completely ordered:
// Level45 support only implies Level10 support
if (mime.equals(MediaFormat.MIMETYPE_VIDEO_H263, ignoreCase = true)) {
if (profileLevel.level != level && profileLevel.level == CodecProfileLevel.H263Level45 &&
level > CodecProfileLevel.H263Level10
) {
continue
}
}
if (profileLevel.level >= level) return true
}
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
return false
}
private fun hasCodecForMime(mime: String): Boolean {
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
if (info.supportedTypes.any { it.equals(mime, ignoreCase = true) }) {
Timber.i("found codec %s for mime %s", info.name, mime)
return true
}
}
return false
}
private fun supportsMultiInstance(mime: String): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val types = info.getSupportedTypes()
if (!types.contains(mime)) continue
val capabilities = info.getCapabilitiesForType(mime)
if (capabilities.maxSupportedInstances > 1) return true
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
return false
}
fun getMaxResolution(mime: String): Size {
var maxWidth = 0
var maxHeight = 0
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mime)
val videoCapabilities = capabilities.videoCapabilities ?: continue
val supportedWidth = videoCapabilities.supportedWidths?.upper ?: continue
val supportedHeight = videoCapabilities.supportedHeights?.upper ?: continue
maxWidth = maxOf(maxWidth, supportedWidth)
maxHeight = maxOf(maxHeight, supportedHeight)
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
Timber.d("Computed max resolution for %s: %dx%d", mime, maxWidth, maxHeight)
return Size(maxWidth, maxHeight)
}
fun supportsDolbyVision(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
fun supportsHdr10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_HDR10)
fun supportsHdr10Plus(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS)
}

View file

@ -14,7 +14,7 @@ lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
androidx-media3 = "1.8.0"
coil = "3.3.0"
jellyfin-sdk = "1.7.0-beta.6"
jellyfin-sdk = "1.7.0-beta.5"
nav3Core = "1.0.0-alpha09"
lifecycleViewmodelNav3 = "1.0.0-alpha04"
material3AdaptiveNav3 = "1.0.0-SNAPSHOT"