mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add device profile & improvement playback reporting
This commit is contained in:
parent
3bf382a6b9
commit
bd75539cb6
19 changed files with 1056 additions and 117 deletions
|
|
@ -27,6 +27,7 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.ServerLoginPage
|
import com.github.damontecres.dolphin.ui.ServerLoginPage
|
||||||
import com.github.damontecres.dolphin.ui.nav.ApplicationContent
|
import com.github.damontecres.dolphin.ui.nav.ApplicationContent
|
||||||
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
|
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
|
||||||
|
import com.github.damontecres.dolphin.util.profile.createDeviceProfile
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -66,11 +67,16 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
isRestoringSession = false
|
isRestoringSession = false
|
||||||
}
|
}
|
||||||
|
val deviceProfile =
|
||||||
|
remember {
|
||||||
|
createDeviceProfile(this@MainActivity, preferences, false)
|
||||||
|
}
|
||||||
val server = serverRepository.currentServer
|
val server = serverRepository.currentServer
|
||||||
val user = serverRepository.currentUser
|
val user = serverRepository.currentUser
|
||||||
if (server != null && user != null) {
|
if (server != null && user != null) {
|
||||||
ApplicationContent(
|
ApplicationContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
deviceProfile = deviceProfile,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else if (isRestoringSession) {
|
} else if (isRestoringSession) {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class ServerRepository
|
||||||
apiClient.userApi
|
apiClient.userApi
|
||||||
.getCurrentUser()
|
.getCurrentUser()
|
||||||
.content
|
.content
|
||||||
|
|
||||||
val updatedServer = server.copy(name = userDto.serverName)
|
val updatedServer = server.copy(name = userDto.serverName)
|
||||||
val updatedUser =
|
val updatedUser =
|
||||||
user.copy(
|
user.copy(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,9 @@ import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EpisodeViewModel
|
class EpisodeViewModel
|
||||||
|
|
@ -64,6 +66,25 @@ fun EpisodeDetails(
|
||||||
Text(text = it)
|
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 {
|
item {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Video
|
import com.github.damontecres.dolphin.data.model.Video
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
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.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
import com.github.damontecres.dolphin.util.DolphinPager
|
import com.github.damontecres.dolphin.util.DolphinPager
|
||||||
|
|
@ -83,7 +83,7 @@ fun SeasonDetails(
|
||||||
Text(text = item.name ?: "Unknown")
|
Text(text = item.name ?: "Unknown")
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
CardRow(
|
ItemRow(
|
||||||
title = "Episodes",
|
title = "Episodes",
|
||||||
items = episodes,
|
items = episodes,
|
||||||
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Video
|
import com.github.damontecres.dolphin.data.model.Video
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
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.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
import com.github.damontecres.dolphin.util.DolphinPager
|
import com.github.damontecres.dolphin.util.DolphinPager
|
||||||
|
|
@ -88,7 +88,7 @@ fun SeriesDetails(
|
||||||
Text(text = item.name ?: "Unknown")
|
Text(text = item.name ?: "Unknown")
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
CardRow(
|
ItemRow(
|
||||||
title = "Seasons",
|
title = "Seasons",
|
||||||
items = seasons,
|
items = seasons,
|
||||||
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -18,12 +16,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.isNotNullOrBlank
|
import com.github.damontecres.dolphin.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.DefaultItemFields
|
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.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
@ -165,56 +162,24 @@ fun MainPage(
|
||||||
val homeRows by viewModel.homeRows.observeAsState(listOf())
|
val homeRows by viewModel.homeRows.observeAsState(listOf())
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
// TODO header?
|
// TODO header?
|
||||||
LazyColumn {
|
LazyColumn(
|
||||||
homeRows.forEach { row ->
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
item {
|
contentPadding = PaddingValues(16.dp),
|
||||||
HomePageRow(
|
|
||||||
row = row,
|
|
||||||
onClickItem = {
|
|
||||||
navigationManager.navigateTo(
|
|
||||||
Destination.MediaItem(
|
|
||||||
it.id,
|
|
||||||
it.type,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onLongClickItem = {},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 ->
|
items(homeRows) { row ->
|
||||||
ItemCard(
|
ItemRow(
|
||||||
item = item,
|
title = stringResource(row.section.nameRes),
|
||||||
onClick = { onClickItem.invoke(item) },
|
items = row.items,
|
||||||
onLongClick = { onLongClickItem.invoke(item) },
|
onClickItem = {
|
||||||
modifier = Modifier,
|
navigationManager.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
it.id,
|
||||||
|
it.type,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLongClickItem = {},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
|
||||||
import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator
|
import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ApplicationContent(
|
fun ApplicationContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
|
deviceProfile: DeviceProfile,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val backStack = rememberNavBackStack(Destination.Main)
|
val backStack = rememberNavBackStack(Destination.Main)
|
||||||
|
|
@ -51,6 +53,7 @@ fun ApplicationContent(
|
||||||
destination = key,
|
destination = key,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
|
deviceProfile = deviceProfile,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -58,6 +61,7 @@ fun ApplicationContent(
|
||||||
destination = key,
|
destination = key,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
|
deviceProfile = deviceProfile,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.detail.MediaItemContent
|
||||||
import com.github.damontecres.dolphin.ui.main.MainPage
|
import com.github.damontecres.dolphin.ui.main.MainPage
|
||||||
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DestinationContent(
|
fun DestinationContent(
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
|
deviceProfile: DeviceProfile,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (destination) {
|
when (destination) {
|
||||||
|
|
@ -35,6 +37,7 @@ fun DestinationContent(
|
||||||
PlaybackContent(
|
PlaybackContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
|
deviceProfile = deviceProfile,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -81,6 +82,7 @@ fun NavDrawer(
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
|
deviceProfile: DeviceProfile,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: NavDrawerViewModel = hiltViewModel(LocalView.current.findViewTreeViewModelStoreOwner()!!),
|
viewModel: NavDrawerViewModel = hiltViewModel(LocalView.current.findViewTreeViewModelStoreOwner()!!),
|
||||||
) {
|
) {
|
||||||
|
|
@ -126,7 +128,7 @@ fun NavDrawer(
|
||||||
IconNavItem(
|
IconNavItem(
|
||||||
text = "Home",
|
text = "Home",
|
||||||
icon = Icons.Default.Home,
|
icon = Icons.Default.Home,
|
||||||
onClick = {},
|
onClick = { navigationManager.goToHome() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(libraries) {
|
items(libraries) {
|
||||||
|
|
@ -158,6 +160,7 @@ fun NavDrawer(
|
||||||
destination = destination,
|
destination = destination,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
|
deviceProfile = deviceProfile,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
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.SkipIndicator
|
||||||
import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState
|
import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
@ -57,13 +58,14 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||||
fun PlaybackContent(
|
fun PlaybackContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
|
deviceProfile: DeviceProfile,
|
||||||
destination: Destination.Playback,
|
destination: Destination.Playback,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlaybackViewModel = hiltViewModel(),
|
viewModel: PlaybackViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(destination.itemId) {
|
LaunchedEffect(destination.itemId) {
|
||||||
viewModel.init(destination.itemId, destination.item)
|
viewModel.init(destination, deviceProfile)
|
||||||
}
|
}
|
||||||
val player = viewModel.player
|
val player = viewModel.player
|
||||||
val stream by viewModel.stream.observeAsState(null)
|
val stream by viewModel.stream.observeAsState(null)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
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.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -15,9 +16,15 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
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.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
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.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 org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -69,9 +76,11 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(
|
fun init(
|
||||||
itemId: UUID,
|
destination: Destination.Playback,
|
||||||
item: BaseItem?,
|
deviceProfile: DeviceProfile,
|
||||||
) {
|
) {
|
||||||
|
val itemId = destination.itemId
|
||||||
|
val item = destination.item
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
title.value = item.name
|
title.value = item.name
|
||||||
val base = item.data
|
val base = item.data
|
||||||
|
|
@ -99,7 +108,7 @@ class PlaybackViewModel
|
||||||
// TODO device profile, etc
|
// TODO device profile, etc
|
||||||
PlaybackInfoDto(
|
PlaybackInfoDto(
|
||||||
startTimeTicks = null,
|
startTimeTicks = null,
|
||||||
deviceProfile = null,
|
deviceProfile = deviceProfile,
|
||||||
enableDirectStream = true,
|
enableDirectStream = true,
|
||||||
enableDirectPlay = true,
|
enableDirectPlay = true,
|
||||||
maxAudioChannels = null,
|
maxAudioChannels = null,
|
||||||
|
|
@ -134,6 +143,23 @@ class PlaybackViewModel
|
||||||
else -> throw Exception("No supported playback method")
|
else -> throw Exception("No supported playback method")
|
||||||
}
|
}
|
||||||
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
val activityListener =
|
val activityListener =
|
||||||
TrackActivityPlaybackListener(api, itemId, player)
|
TrackActivityPlaybackListener(api, itemId, player)
|
||||||
|
|
@ -141,7 +167,10 @@ class PlaybackViewModel
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
duration.value = source.runTimeTicks?.ticks
|
duration.value = source.runTimeTicks?.ticks
|
||||||
stream.value = decision
|
stream.value = decision
|
||||||
player.setMediaItem(decision.mediaItem)
|
player.setMediaItem(
|
||||||
|
decision.mediaItem,
|
||||||
|
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
|
||||||
|
)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
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.api.RepeatMode
|
||||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -73,6 +74,15 @@ class TrackActivityPlaybackListener(
|
||||||
fun release() {
|
fun release() {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
TIMER.purge()
|
TIMER.purge()
|
||||||
|
coroutineScope.launch {
|
||||||
|
api.playStateApi.reportPlaybackStopped(
|
||||||
|
PlaybackStopInfo(
|
||||||
|
itemId = itemId,
|
||||||
|
positionTicks = player.currentPosition.milliseconds.inWholeTicks,
|
||||||
|
failed = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
|
@ -96,12 +106,14 @@ class TrackActivityPlaybackListener(
|
||||||
private fun saveActivity(position: Long) {
|
private fun saveActivity(position: Long) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val totalDuration = totalPlayDurationMilliseconds.get()
|
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
|
// TODO parameters
|
||||||
api.playStateApi.reportPlaybackProgress(
|
api.playStateApi.reportPlaybackProgress(
|
||||||
PlaybackProgressInfo(
|
PlaybackProgressInfo(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
positionTicks = calcPosition,
|
positionTicks = calcPosition.inWholeTicks,
|
||||||
canSeek = true,
|
canSeek = true,
|
||||||
isPaused = !player.isPlaying,
|
isPaused = !player.isPlaying,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ lifecycleRuntimeKtx = "2.9.4"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
androidx-media3 = "1.8.0"
|
androidx-media3 = "1.8.0"
|
||||||
coil = "3.3.0"
|
coil = "3.3.0"
|
||||||
jellyfin-sdk = "1.7.0-beta.6"
|
jellyfin-sdk = "1.7.0-beta.5"
|
||||||
nav3Core = "1.0.0-alpha09"
|
nav3Core = "1.0.0-alpha09"
|
||||||
lifecycleViewmodelNav3 = "1.0.0-alpha04"
|
lifecycleViewmodelNav3 = "1.0.0-alpha04"
|
||||||
material3AdaptiveNav3 = "1.0.0-SNAPSHOT"
|
material3AdaptiveNav3 = "1.0.0-SNAPSHOT"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue