diff --git a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt index 4450208c..885a2d06 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt index 11f1b39d..b3ce7ecb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt @@ -61,6 +61,7 @@ class ServerRepository apiClient.userApi .getCurrentUser() .content + val updatedServer = server.copy(name = userDto.serverName) val updatedUser = user.copy( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/CardRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/CardRow.kt deleted file mode 100644 index 2aef10bc..00000000 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/CardRow.kt +++ /dev/null @@ -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, - 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, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt new file mode 100644 index 00000000..1893ad54 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt @@ -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, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt index 3cd50d86..4cf345eb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt @@ -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 = { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt index fca418db..121f0982 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt @@ -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()) }, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 172ad998..c62b9c3c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -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()) }, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt index 8da48b18..b37dc251 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt @@ -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(), ) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt index b7a57400..9c10d6b2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt @@ -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, ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 34b5bd14..fadfc1c8 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -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, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index 5aa71dad..7cb9c189 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -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(), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt index 5fed78ce..f91aa668 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 19e942d1..83411a8d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -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() } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt index 19dae32a..48d15e3b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/FocusPair.kt b/app/src/main/java/com/github/damontecres/dolphin/util/FocusPair.kt new file mode 100644 index 00000000..dae9fd72 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/util/FocusPair.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/profile/Codec.kt b/app/src/main/java/com/github/damontecres/dolphin/util/profile/Codec.kt new file mode 100644 index 00000000..88531d26 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/util/profile/Codec.kt @@ -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" + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/dolphin/util/profile/DeviceProfileUtils.kt new file mode 100644 index 00000000..7cd3ec32 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/util/profile/DeviceProfileUtils.kt @@ -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) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/dolphin/util/profile/MediaCodecCapabilitiesTest.kt new file mode 100644 index 00000000..a7f75f49 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/util/profile/MediaCodecCapabilitiesTest.kt @@ -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) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d29e4382..f34dc1ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" androidx-media3 = "1.8.0" coil = "3.3.0" -jellyfin-sdk = "1.7.0-beta.6" +jellyfin-sdk = "1.7.0-beta.5" nav3Core = "1.0.0-alpha09" lifecycleViewmodelNav3 = "1.0.0-alpha04" material3AdaptiveNav3 = "1.0.0-SNAPSHOT" @@ -94,4 +94,4 @@ kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization" ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } -room = { id = "androidx.room", version.ref = "room" } \ No newline at end of file +room = { id = "androidx.room", version.ref = "room" }