Add device profile & improvement playback reporting

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,9 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -18,12 +16,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.isNotNullOrBlank
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.ItemCard
import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
@ -165,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(),
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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