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.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) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class ServerRepository
|
|||
apiClient.userApi
|
||||
.getCurrentUser()
|
||||
.content
|
||||
|
||||
val updatedServer = server.copy(name = userDto.serverName)
|
||||
val updatedUser =
|
||||
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 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 = {
|
||||
|
|
|
|||
|
|
@ -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()) },
|
||||
|
|
|
|||
|
|
@ -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()) },
|
||||
|
|
|
|||
|
|
@ -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,56 +162,24 @@ fun MainPage(
|
|||
val homeRows by viewModel.homeRows.observeAsState(listOf())
|
||||
Column(modifier = modifier) {
|
||||
// TODO header?
|
||||
LazyColumn {
|
||||
homeRows.forEach { row ->
|
||||
item {
|
||||
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(),
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
) {
|
||||
items(row.items) { item ->
|
||||
ItemCard(
|
||||
item = item,
|
||||
onClick = { onClickItem.invoke(item) },
|
||||
onLongClick = { onLongClickItem.invoke(item) },
|
||||
modifier = Modifier,
|
||||
items(homeRows) { row ->
|
||||
ItemRow(
|
||||
title = stringResource(row.section.nameRes),
|
||||
items = row.items,
|
||||
onClickItem = {
|
||||
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.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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue